diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index f80f36dc5b..8e8fa87bf5 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -11,6 +11,7 @@ skill. - [Engine boundaries](engine-boundaries.md) — the offline determinism line; where GitHub/network code may live - [Dev workflow](dev-workflow.md) — the CI gate, golden regen, the dual exit-code convention, scaffold gotchas - [Web scaffold](web-scaffold.md) — feature/variant composition, the provider `Wrap` seam, and the toolchain gotchas when adding a `--with` +- [npm-package scaffold](npm-package-scaffold.md) — the node-family single-TS-library twin of swift-package (replaced the old `ts-lib` roster name): scoped bindings, source-read junit binding, the tsdown/oxfmt gotchas - [racket-ui via shadcn](rac-ui-shadcn.md) — the proven recipe to replace the foundation with racket-ui (`markmals/racket-ui`) via the shadcn CLI; **gated on committing `registry.json` to that repo's root** - [Apple scaffold](apple-scaffold.md) — the `apple` stack + the `swift-package`/`swift-cli` sibling library stacks: verify=headless Core/package, the swift event-stream command, the dynamic-module/static-dir `path:` trick, `.tmpl`-for-substitution-only, the `.scenario()` binding, packless-stack handling - [Mise monorepo](mise-monorepo.md) — generated root config invariant; the unstable-parser comment-preserving merge; the family↔member drift coupling diff --git a/.claude/memory/mise-monorepo.md b/.claude/memory/mise-monorepo.md index 646f6b0ff6..999bbf91d0 100644 --- a/.claude/memory/mise-monorepo.md +++ b/.claude/memory/mise-monorepo.md @@ -26,9 +26,10 @@ orchestrator (`wireMonorepo` in `cmd/specify/monorepo.go`) runs after every invocation runs with cwd = the member dir, so `report: {{.Dir}}/junit.xml` still resolves correctly from the project root. -Families today: `node` (`web`), `swift` (`apple`, `swift-package`, `swift-cli`), -`go` (`go-service`). A `node` second member is reachable via a second `web` target; -a `swift` second member by e.g. `apple` + `swift-package`. +Families today: `node` (`web`, `npm-package`), `swift` (`apple`, `swift-package`, +`swift-cli`), `go` (`go-service`). A `node` second member is reachable via `web` + +`npm-package` (the node analog of `apple` + `swift-package`); a `swift` second member +by e.g. `apple` + `swift-package`. See [[npm-package-scaffold]]. ## The merge engine (`go-toml/v2` `unstable`) @@ -56,7 +57,8 @@ strings must stay **byte-identical** to the member scaffolds' inline `run` bodie means `PromoteMember` silently skips that task forever. Enforced by drift tests: -- `TestNodeFamilyMatchesWebInline` (node family ↔ web member) +- `TestNodeFamilyMatchesWebInline` (node family ↔ web member, all promotable tasks) +- `TestNodeFamilyMatchesNpmPackageInline` (node family ↔ npm-package's shared `test`/`typecheck`) - `TestSwiftFamilyMatchesMemberInline` (swift family ↔ apple/swift-package/swift-cli) - `TestGoFamilyMatchesMemberInline` (go family ↔ go-service member) diff --git a/.claude/memory/npm-package-scaffold.md b/.claude/memory/npm-package-scaffold.md new file mode 100644 index 0000000000..74a3b4a4d3 --- /dev/null +++ b/.claude/memory/npm-package-scaffold.md @@ -0,0 +1,66 @@ +--- +description: The npm-package scaffold — the node-family single-TS-library twin of swift-package; its scoped-binding choice, the source-read junit binding, and the toolchain gotchas (tsdown extension, oxfmt chain). +--- + +# npm-package scaffold (internal/coreassets/templates/scaffolds/npm-package) + +A single TypeScript library — the **node-family twin of [[apple-scaffold|swift-package]]** +(built 2026-06-27). `family: node`, `memberDir: packages`. Verify target = the package's +own Vitest suite (`mise //packages/:test` → junit), green-on-arrival with just the +node toolchain. Render test: `internal/scaffold/npm_package_test.go`. Engine: zero changes +— it reuses the junit path web already proved. + +## The decided calls + +- **`bindings: scoped`, NOT web's strict/empty.** A library mixes scenario tests with + plain unit/property tests; scoped leaves untagged tests out of scope instead of failing + `verify` on them. This matches the swift library twins — web is strict because it's an + *app*. Proven: adding an untagged `*.test.ts` to `src/` keeps verify green. +- **The junit binding is read from SOURCE, never the report.** `vitestBindRe` + (`internal/engine/verify.go`) scans the `it("[scenario.] …")` title in `src/*.ts`; + the report carries only test identity + pass/fail. "Report-carried" is a misnomer — the + difference from swift is only *where the id sits* (visible it() title vs a `.scenario()` + trait), both source-read. +- **`nameRule: "npm"`.** `src/` is name-agnostic (the module is named by package.json + `name: {{.Name}}`), so it needs no swift-style *identifier* rule. But the member name *is* + the npm package name, so the `npm` rule (`scaffold.go ValidateName` + the `npmReserved` + map) rejects capitals, the blacklist (`node_modules`/`favicon.ico`), Node core-module + names, and >214 chars at `target add` — before they'd fail at `npm publish`. The base slug + check runs first (excludes spaces/`@`/`/`/leading-dot), so the rule only adds what a valid + slug still slips past npm. The `npmReserved` list is a fail-open snapshot (stale → misses a + new builtin, never a false reject). +- **fmt/lint scoped to `src/`** (`oxfmt src` / `oxlint src`), like web's `app/`. So + package.json + the configs at member root are NOT format-gated — only the three + `src/*.ts` files must be oxfmt/oxlint-clean. This is what lets `pnpm add` rewrite + package.json freely without breaking `fmt:check`. +- **Example bound to `story.slug.create`** (a `slugify`), chosen DISTINCT from web + (`welcome.greet`) and swift-package (`version.compare`) so two stacks in one repo never + collide on `features/`. `kind: story` (not `kind: library` — that taxonomy is still + pending across all library stacks). + +## Toolchain gotchas that cost real time + +- **tsdown defaults to `.mjs`/`.d.mts`** — mismatches package.json `exports` (`./dist/ + index.js`). Fix in `tsdown.config.ts`: `outExtensions: () => ({ js: ".js", dts: ".d.ts" })`. +- **oxfmt breaks a 3+ method chain across lines.** `slugify.ts`'s `.toLowerCase().replace() + .replace()` must be pre-formatted multi-line in the template — the scaffold runs no + formatter, so the emitted bytes must already be oxfmt-clean (verify by rendering + `oxfmt + --check src`). Same green-on-arrival discipline as [[web-scaffold]]. +- **The node drift test couples only `test` + `typecheck`** (`TestNodeFamilyMatchesNpmPackageInline`) + — their bodies are byte-identical to node.toml so they promote to `extends` when + npm-package is the 2nd node member; build/fmt/lint are src-scoped, differ from web's + app-scoped templates, and stay inline. See [[mise-monorepo]]. +- **Test each library stack in its OWN fresh repo** (the first-target-seeds-example gotcha + — [[apple-scaffold]]). + +## `npm-package` replaced `ts-lib` (Mark's call, 2026-06-27) + +The roster name for the TS-library stack used to be `ts-lib` (a planned, scaffold-LESS +"register an existing library" name). `npm-package` **retired and replaced it** everywhere: +the `config.go` Stack enum, `stack-scaffolding.md`, `library-products.md`, `BACKLOG.md`, and +`HANDOFF.md` now say `npm-package`. The canonical **scaffold-less** example in the register +code/tests (`register_test.go`, `main.go`/`monorepo.go` comments) moved to **`go-cli`** +(`node-cli` as the second example). You can still `register` an existing TS library as +`npm-package` without scaffolding — `register` seeds wiring from the scaffold manifest, or +takes explicit `--format/--source/--bindings` flags. Stacks with no scaffold dir today: +`go-cli`, `node-cli`, `website`, `android`, `vscode-extension`. diff --git a/BACKLOG.md b/BACKLOG.md index 0ecba2bbdb..346f2ca1b9 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -210,11 +210,13 @@ Ready) and baked in as `specify work`'s defaults. (design doc updated). `.vscode` is inert to the build (outside `app/`); render-tested + a full combo stays green. - ⬜ **Library / non-app coverage** — add a product **`kind: app | library`** (relax the "actor is human" rule for libraries; `story`+`domain`+`error` apply, UI kinds don't) and the - stacks **`swift-package`**, **`swift-cli`**, **`ts-lib`**, **`vscode-extension`**. Engine + stacks **`swift-package`**, **`swift-cli`**, **`npm-package`**, **`vscode-extension`**. Engine unchanged (joins scenario↔test regardless). Design: [library-products.md](docs/design/library-products.md). Roster is evidence-based (web · - website · apple · android · go-cli · node-cli · swift-package · swift-cli · ts-lib · - vscode-extension; dropped rust-cli/windows/linux/browser-extension). + website · apple · android · go-cli · node-cli · swift-package · swift-cli · npm-package · + vscode-extension; dropped rust-cli/windows/linux/browser-extension). **`npm-package` + shipped 2026-06-27** — the node-family single-TS-library stack (tsdown + Vitest junit + + scoped bindings), the node twin of `swift-package`. - ✅ **`go-service` scaffold (Tier-2 structural, trove's daemon).** A new stack: a runnable Go HTTP daemon (net/http ServeMux + graceful shutdown + a scenario-bound httptest) that's green-on-arrival via the **Tier-1 gotest format + Go leading-comment bindings + scoped mode** (dogfoods Tier 1: diff --git a/HANDOFF.md b/HANDOFF.md index 02fae806e1..ffc8ecf963 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -22,7 +22,7 @@ arc is the actual conversion of [`~/Developer/Projects/trove`](file:///Users/ori | `cmd/troved`, `cmd/tangerined` | go-service | gotest · scoped | | `cmd/trove-transcode` | go CLI | gotest · scoped | | `apps/trove`, `apps/tangerine-dashboard` | web | junit · scoped | -| `packages/services`, `packages/tangerined-contract` | ts-lib | junit · scoped | +| `packages/services`, `packages/tangerined-contract` | npm-package | junit · scoped | The conversion work, in order: @@ -142,7 +142,7 @@ or work in a throwaway checkout. the `--ssr`/`--spa` modes (trove proves SPA is needed — per-app). 2. **Tier 2 monorepo (trove parity)** — the go-service shared `go.mod` ✅; still: a root `pnpm-workspace.yaml` (+ catalogs) maintained by `target add`, repo-root `internal/` - sharing across go members, then the **ts-lib** (`packages/`) + **ts-rest contract** + sharing across go members, then the **npm-package** (`packages/`) + **ts-rest contract** scaffolds. 3. **New stack coverage** — **apple next** (exercises the `swift` report format + `SpecTraits.swift`), then `kind: library` products + a **go-cli** stack (trove-transcode's diff --git a/cmd/specify/main.go b/cmd/specify/main.go index 4716ba6b00..95111188cb 100644 --- a/cmd/specify/main.go +++ b/cmd/specify/main.go @@ -395,7 +395,7 @@ func targetAddCmd() *cobra.Command { // without scaffolding or installing anything — the onboarding path for adopting // SpecKit in a repo whose code already exists (converting a Workbench-shaped repo // like trove). It seeds the target's test wiring from the stack's scaffold manifest -// when one exists (web, go-service); for stacks without a scaffold (ts-lib, go-cli) +// when one exists (web, go-service); for stacks without a scaffold (go-cli, node-cli) // — or to match a member wired differently than the scaffold — pass the fields as // flags. Unlike `target add`, it writes no files and runs no scripts. func targetRegisterCmd() *cobra.Command { @@ -439,7 +439,7 @@ func registerTarget(root string, o regOpts) error { } // Seed wiring from the stack's scaffold manifest when it has one (web, - // go-service). Stacks without a scaffold (ts-lib, go-cli) leave rt empty — the + // go-service). Stacks without a scaffold (go-cli, node-cli) leave rt empty — the // fields then come from flags. var rt scaffold.RenderedTarget if o.stack != "" { diff --git a/cmd/specify/monorepo.go b/cmd/specify/monorepo.go index 9458d840d2..62088656b1 100644 --- a/cmd/specify/monorepo.go +++ b/cmd/specify/monorepo.go @@ -95,7 +95,7 @@ func wireMonorepo(root string) error { // and member dir (the recorded report's parent — every scaffold's report is // "/", so filepath.Dir(t.Report) recovers the member dir, honoring a // --dir override without re-deriving memberDir/). A stack with no scaffold -// (ts-lib, go-cli) has no family; a malformed scaffold.json is a real error. +// (go-cli, node-cli) has no family; a malformed scaffold.json is a real error. func familyAndDir(t config.Target) (family, dir string, err error) { sub, err := fs.Sub(coreassets.FS, "templates/scaffolds/"+t.Stack) if err != nil { diff --git a/cmd/specify/register_test.go b/cmd/specify/register_test.go index 6e203fefc0..a5ec597403 100644 --- a/cmd/specify/register_test.go +++ b/cmd/specify/register_test.go @@ -36,7 +36,7 @@ func TestRegisterTargetFromManifest(t *testing.T) { } } -// register a member whose stack has NO scaffold (e.g. ts-lib): the wiring comes +// register a member whose stack has NO scaffold (e.g. go-cli): the wiring comes // entirely from flags. func TestRegisterTargetExplicitFlags(t *testing.T) { root := t.TempDir() @@ -44,7 +44,7 @@ func TestRegisterTargetExplicitFlags(t *testing.T) { t.Fatal(err) } err := registerTarget(root, regOpts{ - name: "services", stack: "ts-lib", dir: "packages/services", + name: "services", stack: "go-cli", dir: "packages/services", format: "junit", command: "cd packages/services && mise run test", report: "packages/services/junit.xml", source: []string{"packages/services/src"}, bindings: "scoped", }) @@ -53,7 +53,7 @@ func TestRegisterTargetExplicitFlags(t *testing.T) { } cfg, _, _ := config.Load(root) got := cfg.Targets["services"] - if got.Stack != "ts-lib" || got.Format != "junit" || got.Source.First() != "packages/services/src" || got.Bindings != "scoped" { + if got.Stack != "go-cli" || got.Format != "junit" || got.Source.First() != "packages/services/src" || got.Bindings != "scoped" { t.Errorf("target = %+v", got) } } @@ -84,7 +84,7 @@ func TestRegisterTargetErrors(t *testing.T) { if err := os.MkdirAll(filepath.Join(root, "packages/x"), 0o755); err != nil { t.Fatal(err) } - if err := registerTarget(root, regOpts{name: "x", stack: "ts-lib", dir: "packages/x"}); err == nil { + if err := registerTarget(root, regOpts{name: "x", stack: "go-cli", dir: "packages/x"}); err == nil { t.Error("expected error for incomplete wiring (no scaffold, no --format)") } // bad name diff --git a/docs/design/library-products.md b/docs/design/library-products.md index 23aa83030a..893558e318 100644 --- a/docs/design/library-products.md +++ b/docs/design/library-products.md @@ -22,7 +22,7 @@ A product is one or the other. The kind selects the authoring path; it does - **Default from stack.** App stacks (`web`, `website`, `apple`, `android`, the CLI stacks) ⇒ `app`. Library stacks (`swift-package`, `swift-cli`, - `ts-lib`, `vscode-extension`) ⇒ `library`. An explicit `kind` overrides. + `npm-package`, `vscode-extension`) ⇒ `library`. An explicit `kind` overrides. - **Open: where it's declared.** Cleanest is to derive it from the feature's target stacks, with an explicit override when a repo mixes both. (CLIs are the ambiguous case — a CLI has a human user, so `remctl` is app-flavored, but a diff --git a/docs/design/mise-monorepo-plan.md b/docs/design/mise-monorepo-plan.md index c4553ff802..869d4a51e6 100644 --- a/docs/design/mise-monorepo-plan.md +++ b/docs/design/mise-monorepo-plan.md @@ -1356,7 +1356,7 @@ func wireMonorepo(root string) error { func familyAndDir(t config.Target) (family, dir string, err error) { sub, err := fs.Sub(coreassets.FS, "templates/scaffolds/"+t.Stack) if err != nil { - return "", "", nil // stack without a scaffold (ts-lib, go-cli): no family + return "", "", nil // stack without a scaffold (go-cli, node-cli): no family } m, err := scaffold.LoadManifest(sub) if err != nil { diff --git a/docs/design/stack-scaffolding.md b/docs/design/stack-scaffolding.md index 3489296fba..8c0b260305 100644 --- a/docs/design/stack-scaffolding.md +++ b/docs/design/stack-scaffolding.md @@ -45,7 +45,7 @@ specify target add --stack [--dir ] [--product

] [--data ``` - `` — the target's key in `.speckit/specs.json` (e.g. `web`, `consumer-web`). -- `--stack` — which scaffold. The roster is **evidence-based** — only stacks Mark has actually worked on (per `~/Developer` + the `markmals` / `markmals-archive` GitHub accounts). **App stacks:** `web` · `website` · `apple` · `android` · `go-cli` · `go-service` · `node-cli`. **Library stacks:** `swift-package` · `swift-cli` · `ts-lib` · `vscode-extension` — these will set the product `kind: library` once that taxonomy lands ([library-products.md](library-products.md)); the shipped swift stacks' example specs use `kind: story` in the meantime. +- `--stack` — which scaffold. The roster is **evidence-based** — only stacks Mark has actually worked on (per `~/Developer` + the `markmals` / `markmals-archive` GitHub accounts). **App stacks:** `web` · `website` · `apple` · `android` · `go-cli` · `go-service` · `node-cli`. **Library stacks:** `swift-package` · `swift-cli` · `npm-package` · `vscode-extension` — these will set the product `kind: library` once that taxonomy lands ([library-products.md](library-products.md)); the shipped swift stacks' example specs use `kind: story` in the meantime. - `--dir` — where to scaffold (default `/`, where `memberDir` is the manifest's placement — `apps` for web, `cmd` for go-service, `packages` for a library). - `--product` — optional product label written onto the target. - `--data` / `--runtime` — **variants**: selectable axes the manifest declares (e.g. web's `--data convex|drizzle|none` × `--runtime cloudflare|node`), each rendered *over* the base. @@ -170,7 +170,7 @@ working loop rather than wiring one. Concretely, per stack: | stack | test framework → report `format` | binding affordance pre-wired | harness artifact | | --- | --- | --- | --- | -| web / website / node-cli / ts-lib | Vitest → `junit` | `// [scenario.]` above `it(…)` | Vitest config emitting junit at `report` | +| web / website / node-cli / npm-package | Vitest → `junit` | `// [scenario.]` above `it(…)` | Vitest config emitting junit at `report` | | apple / swift-package / swift-cli | Swift Testing → `swift` (event-stream NDJSON) | `@Suite(.spec)` / `@Test(.scenario)` traits | `SpecTraits.swift` (no simulator for package/cli) | | android | kotlin.test/JUnit → `junit` | `@Tag("spec:…")` / `@Tag("scenario:…")` | Gradle test task emitting junit | | go-cli | `go test` → `junit` | `// [scenario.]` comment | gotestsum junit output | @@ -188,7 +188,7 @@ the library layout; their example specs use `kind: story` until then. The roster is evidence-based, but the **exact framework + tooling** for each stack is *not* pre-decided here. The harness table above fixes only the contract (report `format` + the binding affordance — SpecKit's convention). The actual -stack — web framework (TanStack Start vs Solid Start vs …), the `ts-lib` bundler, +stack — web framework (TanStack Start vs Solid Start vs …), the `npm-package` bundler, the `apple` minimum-OS / Swift version, the Vitest/test-runner versions — is chosen at build time, targeting the **latest / bleeding-edge** of each ecosystem, and **previewed for Mark's sign-off before that stack's scaffold is built**. Each @@ -263,7 +263,7 @@ Non-JS stacks use the same shape with their own tools (`swift build`, the headless SwiftPM `Core` is the verify target (no Tuist/Xcode/simulator/ signing needed), green-on-arrival on the Xcode toolchain alone. Remaining slices (Tuist app surface, `--with` features, the AppKit pack) in - [scaffolds/apple.md](scaffolds/apple.md). Then **ts-lib** + **go-cli** (the + [scaffolds/apple.md](scaffolds/apple.md). Then **npm-package** + **go-cli** (the remaining trove member shapes). The remaining stacks follow one at a time — each gated on its tooling preview diff --git a/docs/usage/offline.md b/docs/usage/offline.md index 5820bcbd8a..8e181bd87d 100644 --- a/docs/usage/offline.md +++ b/docs/usage/offline.md @@ -45,7 +45,7 @@ specify target add web --stack web # green on verify out of the box # in place (writes no files, runs nothing) — seeded from the stack's scaffold, or # wired explicitly when the member differs / its stack has no scaffold: specify target register api --stack go-service --dir cmd/api -specify target register lib --stack ts-lib --dir packages/lib \ +specify target register lib --stack vscode-extension --dir packages/lib \ --format junit --command "mise //packages/lib:test" \ --report packages/lib/junit.xml --source packages/lib/src --bindings scoped ``` diff --git a/internal/config/config.go b/internal/config/config.go index 4406ff5cce..8febd80ce1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -105,7 +105,7 @@ func (sp SourcePaths) Validate(target string) []error { type Target struct { Product string `json:"product,omitempty"` Products []string `json:"products,omitempty"` - Stack string `json:"stack,omitempty"` // selects the pack/scaffold: web|website|apple|android|go-cli|go-service|node-cli|swift-package|swift-cli|ts-lib|vscode-extension + Stack string `json:"stack,omitempty"` // selects the pack/scaffold: web|website|apple|android|go-cli|go-service|node-cli|swift-package|swift-cli|npm-package|vscode-extension Command string `json:"command,omitempty"` Format string `json:"format"` // junit | swift | gotest Report string `json:"report"` diff --git a/internal/coreassets/templates/scaffolds/npm-package/files/.gitignore b/internal/coreassets/templates/scaffolds/npm-package/files/.gitignore new file mode 100644 index 0000000000..19e7a7b26d --- /dev/null +++ b/internal/coreassets/templates/scaffolds/npm-package/files/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ + +# the junit report the engine joins (regenerated by `mise run test`) +junit.xml + +# Local env +.env +.env.local diff --git a/internal/coreassets/templates/scaffolds/npm-package/files/.oxfmtrc.json b/internal/coreassets/templates/scaffolds/npm-package/files/.oxfmtrc.json new file mode 100644 index 0000000000..f6ffe3f3f0 --- /dev/null +++ b/internal/coreassets/templates/scaffolds/npm-package/files/.oxfmtrc.json @@ -0,0 +1,33 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "ignorePatterns": [ + "dist/**" + ], + "printWidth": 100, + "tabWidth": 4, + "arrowParens": "avoid", + "sortPackageJson": true, + "sortImports": { + "groups": [ + "type-import", + [ + "value-builtin", + "value-external" + ], + "type-internal", + "value-internal", + [ + "type-parent", + "type-sibling", + "type-index" + ], + [ + "value-parent", + "value-sibling", + "value-index" + ], + "unknown" + ], + "partitionByComment": true + } +} diff --git a/internal/coreassets/templates/scaffolds/npm-package/files/.oxlintrc.json b/internal/coreassets/templates/scaffolds/npm-package/files/.oxlintrc.json new file mode 100644 index 0000000000..ddeb9795d6 --- /dev/null +++ b/internal/coreassets/templates/scaffolds/npm-package/files/.oxlintrc.json @@ -0,0 +1,30 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "ignorePatterns": [ + "dist/**" + ], + "plugins": [ + "import", + "typescript" + ], + "options": { + "typeAware": true, + "typeCheck": true + }, + "jsPlugins": [ + "eslint-plugin-perfectionist", + "eslint-plugin-prefer-let" + ], + "rules": { + "typescript/no-floating-promises": "allow", + "typescript/unbound-method": "allow", + "import/extensions": "off", + "eslint/prefer-const": "off", + "prefer-let/prefer-let": [ + 2, + { + "forceUpperCaseConst": true + } + ] + } +} diff --git a/internal/coreassets/templates/scaffolds/npm-package/files/README.md.tmpl b/internal/coreassets/templates/scaffolds/npm-package/files/README.md.tmpl new file mode 100644 index 0000000000..e025a6eba8 --- /dev/null +++ b/internal/coreassets/templates/scaffolds/npm-package/files/README.md.tmpl @@ -0,0 +1,25 @@ +# {{.Name}} + +A TypeScript library scaffolded by SpecKit. The public surface lives in `src/` and is +re-exported from `src/index.ts`; tsdown bundles it to `dist/` (with `.d.ts`). + +- `mise run test` — Vitest; a test is bound to a spec scenario by an + `[scenario.]` prefix on its `it(...)` title (see `src/slugify.test.ts`). +- `mise run build` — bundle to `dist/` (tsdown) +- `mise run lint` — Oxlint · `mise run typecheck` — tsgo +- `mise run fmt` — Oxfmt (format `src/`) · `mise run fmt:check` — the CI check + +`specify verify {{.Name}}` runs the tests and joins each scenario to its bound test. +Only tests whose `it(...)` title carries a `[scenario.]` prefix are bound; plain +unit/property tests are out of scope for `verify` (`bindings: scoped`), so add as many +as you like alongside the scenario tests. + +In CI, `lint` / `typecheck` / `fmt:check` make up the **quality** job and `verify` +(the spec gate, which also runs `test`) makes up the **verify** job — both required. + +## Publishing + +The member name (`{{.Name}}`) is the npm package name; `specify target add` enforces a +publishable name (lowercase, not a reserved or Node core-module name), so it's ready as-is. +To publish under a scope, set `name` to `@scope/{{.Name}}` in `package.json`. The package is +shaped for publishing (`exports` + types + `files`); wiring a release workflow is left to you. diff --git a/internal/coreassets/templates/scaffolds/npm-package/files/mise.toml b/internal/coreassets/templates/scaffolds/npm-package/files/mise.toml new file mode 100644 index 0000000000..f009cebb53 --- /dev/null +++ b/internal/coreassets/templates/scaffolds/npm-package/files/mise.toml @@ -0,0 +1,29 @@ +# Call npm binaries (vitest, tsgo, tsdown, oxlint, oxfmt) by bare name — no npx. +[env] +_.path = ['{{config_root}}/node_modules/.bin'] + +[tasks.test] +description = "run Vitest with the junit reporter the engine joins" +run = "vitest run --reporter=junit --outputFile=junit.xml" + +[tasks.build] +description = "bundle the library to dist/ (tsdown, with .d.ts)" +run = "tsdown" + +# Quality tasks — the CI `quality` job calls fmt:check / lint / typecheck. Scoped to +# src/ (the library source) so the gate stays on code, not config or build output. +[tasks.typecheck] +description = "type-check with tsgo" +run = "tsgo --noEmit" + +[tasks.fmt] +description = "format src/ in place (Oxfmt)" +run = "oxfmt src" + +[tasks."fmt:check"] +description = "fail if src/ needs formatting (Oxfmt)" +run = "oxfmt --check src" + +[tasks.lint] +description = "lint src/ (Oxlint)" +run = "oxlint src" diff --git a/internal/coreassets/templates/scaffolds/npm-package/files/package.json.tmpl b/internal/coreassets/templates/scaffolds/npm-package/files/package.json.tmpl new file mode 100644 index 0000000000..1bea89af76 --- /dev/null +++ b/internal/coreassets/templates/scaffolds/npm-package/files/package.json.tmpl @@ -0,0 +1,15 @@ +{ + "name": "{{.Name}}", + "version": "0.0.0", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": ["dist"], + "sideEffects": false +} diff --git a/internal/coreassets/templates/scaffolds/npm-package/files/src/index.ts b/internal/coreassets/templates/scaffolds/npm-package/files/src/index.ts new file mode 100644 index 0000000000..44a131932a --- /dev/null +++ b/internal/coreassets/templates/scaffolds/npm-package/files/src/index.ts @@ -0,0 +1 @@ +export { slugify } from "./slugify.ts"; diff --git a/internal/coreassets/templates/scaffolds/npm-package/files/src/slugify.test.ts b/internal/coreassets/templates/scaffolds/npm-package/files/src/slugify.test.ts new file mode 100644 index 0000000000..4b0f514e51 --- /dev/null +++ b/internal/coreassets/templates/scaffolds/npm-package/files/src/slugify.test.ts @@ -0,0 +1,16 @@ +import { expect, it } from "vitest"; + +import { slugify } from "./slugify.ts"; + +// SPEC: story.slug.create — each test binds to a scenario via the [scenario.] +// prefix on its it() title (CONVENTIONS). The engine reads the binding from THIS +// SOURCE, never from the report (which carries only each test's identity + outcome). +// Plain unit/property tests without the prefix are fine — `bindings: scoped` leaves +// them out of scope rather than failing verify. +it("[scenario.slug.create.basic] lowercases and hyphenates words", () => { + expect(slugify("Hello World")).toBe("hello-world"); +}); + +it("[scenario.slug.create.symbols] collapses punctuation and trims hyphens", () => { + expect(slugify("TypeScript & You!")).toBe("typescript-you"); +}); diff --git a/internal/coreassets/templates/scaffolds/npm-package/files/src/slugify.ts b/internal/coreassets/templates/scaffolds/npm-package/files/src/slugify.ts new file mode 100644 index 0000000000..d7cb103a3a --- /dev/null +++ b/internal/coreassets/templates/scaffolds/npm-package/files/src/slugify.ts @@ -0,0 +1,8 @@ +// Convert text into a URL-safe slug: lowercase, collapse every run of +// non-alphanumeric characters to a single hyphen, and trim leading/trailing hyphens. +export function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} diff --git a/internal/coreassets/templates/scaffolds/npm-package/files/tsconfig.json b/internal/coreassets/templates/scaffolds/npm-package/files/tsconfig.json new file mode 100644 index 0000000000..86e9c9e13b --- /dev/null +++ b/internal/coreassets/templates/scaffolds/npm-package/files/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ESNext"], + "types": ["@types/node"], + "esModuleInterop": true, + "resolveJsonModule": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist"] +} diff --git a/internal/coreassets/templates/scaffolds/npm-package/files/tsdown.config.ts b/internal/coreassets/templates/scaffolds/npm-package/files/tsdown.config.ts new file mode 100644 index 0000000000..ba71559996 --- /dev/null +++ b/internal/coreassets/templates/scaffolds/npm-package/files/tsdown.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + dts: true, + // Emit .js/.d.ts (not tsdown's default .mjs/.d.mts) to match package.json exports. + outExtensions: () => ({ js: ".js", dts: ".d.ts" }), +}); diff --git a/internal/coreassets/templates/scaffolds/npm-package/files/vitest.config.ts b/internal/coreassets/templates/scaffolds/npm-package/files/vitest.config.ts new file mode 100644 index 0000000000..c12de09624 --- /dev/null +++ b/internal/coreassets/templates/scaffolds/npm-package/files/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/internal/coreassets/templates/scaffolds/npm-package/root/features/0001-slug/stories/slug.create.md.tmpl b/internal/coreassets/templates/scaffolds/npm-package/root/features/0001-slug/stories/slug.create.md.tmpl new file mode 100644 index 0000000000..977d2e3730 --- /dev/null +++ b/internal/coreassets/templates/scaffolds/npm-package/root/features/0001-slug/stories/slug.create.md.tmpl @@ -0,0 +1,29 @@ +--- +id: story.slug.create +kind: story +--- + +**As a** developer using the library +**I want** to turn arbitrary text into a URL-safe slug +**So that** the scaffold's spec → test → verify loop is proven end to end + +**Independent test:** `mise run -C {{.Dir}} test` proves the scenarios below +against the library. + +# Acceptance Criteria + +## Scenario 1: words become a lowercase, hyphenated slug + + + +- Given the text "Hello World" +- When it is slugified +- Then the result is "hello-world" + +## Scenario 2: punctuation collapses and hyphens are trimmed + + + +- Given the text "TypeScript & You!" +- When it is slugified +- Then the result is "typescript-you" diff --git a/internal/coreassets/templates/scaffolds/npm-package/scaffold.json b/internal/coreassets/templates/scaffolds/npm-package/scaffold.json new file mode 100644 index 0000000000..479d7cbcd7 --- /dev/null +++ b/internal/coreassets/templates/scaffolds/npm-package/scaffold.json @@ -0,0 +1,23 @@ +{ + "stack": "npm-package", + "family": "node", + "memberDir": "packages", + "nameRule": "npm", + "scripts": [ + { + "phase": 0, + "commands": [ + "mise trust", + "pnpm add -D tsdown vitest @typescript/native-preview@latest oxlint oxlint-tsgolint oxfmt eslint-plugin-perfectionist eslint-plugin-prefer-let @types/node", + "pnpm install" + ] + } + ], + "target": { + "command": "mise //{{.Dir}}:test", + "format": "junit", + "report": "{{.Dir}}/junit.xml", + "source": "{{.Dir}}/src", + "bindings": "scoped" + } +} diff --git a/internal/scaffold/monorepo_assets_test.go b/internal/scaffold/monorepo_assets_test.go index 095a7613f6..9241568796 100644 --- a/internal/scaffold/monorepo_assets_test.go +++ b/internal/scaffold/monorepo_assets_test.go @@ -63,6 +63,45 @@ func TestNodeFamilyMatchesWebInline(t *testing.T) { } } +// TestNodeFamilyMatchesNpmPackageInline asserts the two node family task bodies that +// npm-package shares (test, typecheck) stay byte-identical to its inline tasks — so when +// npm-package is the second node member, PromoteMember converts them to `extends` instead +// of silently skipping them (the failure the mise-monorepo memory warns about). The +// member's other tasks (build/fmt/fmt:check/lint) are deliberately src-scoped and differ +// from web's app-scoped family templates, so they stay inline and are not coupled here. +func TestNodeFamilyMatchesNpmPackageInline(t *testing.T) { + fam, err := LoadFamily(coreassets.FS, "node") + if err != nil { + t.Fatal(err) + } + sub, _ := fs.Sub(coreassets.FS, "templates/scaffolds/npm-package") + dir := t.TempDir() + if _, err := Render(sub, dir, Data{Name: "string-kit", Dir: "packages/string-kit"}); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(dir, "mise.toml")) + if err != nil { + t.Fatal(err) + } + // npm-package members carry no [tools] (the node family pins hoist to the root). + if strings.Contains(string(data), "[tools]") { + t.Errorf("npm-package member must not declare [tools] (hoisted to root):\n%s", data) + } + ex, _ := parseExprs(data) + vars := memberVars(data) + for _, task := range []string{"test", "typecheck"} { + want := substituteVars(fam.Templates[task].Run, vars) + got, found := inlineRun(ex, task) + if !found { + t.Errorf("npm-package member has no inline [tasks.%s] for family template node:%s", task, task) + continue + } + if got != want { + t.Errorf("drift: node:%s\n family: %q\n member: %q", task, want, got) + } + } +} + // TestGoFamilyMatchesMemberInline asserts the go family templates' run strings // equal the go-service member scaffold's inline task bodies — the coupling // promotion relies on. If you change one, change the other. diff --git a/internal/scaffold/npm_package_test.go b/internal/scaffold/npm_package_test.go new file mode 100644 index 0000000000..fcd826176b --- /dev/null +++ b/internal/scaffold/npm_package_test.go @@ -0,0 +1,142 @@ +package scaffold + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/markmals/speckit/internal/coreassets" +) + +// TestNpmPackageScaffold renders the embedded npm-package scaffold and asserts the +// single-library wiring: members land in packages/ (memberDir), the stack shares the +// node toolchain with web (family), the target uses Vitest's junit format with scoped +// bindings (the scenario id is read from SOURCE — the [scenario.] prefix in the +// it() title — never from the report; scoped lets the library mix scenario tests with +// plain unit/property tests, matching the swift-package twin), the library source is +// name-agnostic (named by package.json, never interpolated), and the seeded story's +// scenario sub-ids match the [scenario.] prefixes in the bound test — the agreement +// that makes a fresh `specify verify` green with the Node toolchain alone. +func TestNpmPackageScaffold(t *testing.T) { + sub, err := fs.Sub(coreassets.FS, "templates/scaffolds/npm-package") + if err != nil { + t.Fatal(err) + } + m, err := LoadManifest(sub) + if err != nil { + t.Fatal(err) + } + if m.Stack != "npm-package" || m.MemberDir != "packages" { + t.Errorf("stack=%q memberDir=%q, want npm-package / packages", m.Stack, m.MemberDir) + } + if m.Family != "node" { + t.Errorf("family=%q, want node (shares the node toolchain with web)", m.Family) + } + // junit + scoped bindings: the scenario id is read from SOURCE (the [scenario.] + // prefix in the it() title, scanned by the engine — never from the report, which + // carries only test identity + outcome). scoped (like the swift library twins) lets a + // library mix scenario tests with plain unit/property tests without the untagged ones + // turning verify red. + if m.Target.Format != "junit" || m.Target.Bindings != "scoped" { + t.Errorf("target format=%q bindings=%q, want junit / scoped", + m.Target.Format, m.Target.Bindings) + } + // The source is name-agnostic (named via package.json), so it needs no *identifier* rule + // like the swift stacks. But the member name IS the npm package name, so it carries the + // "npm" rule — rejecting capitals / reserved names / core modules at `target add` rather + // than letting them surface at `npm publish`. + if m.NameRule != "npm" { + t.Errorf("nameRule=%q, want npm (the member name becomes the npm package name)", + m.NameRule) + } + if m.SharedModule { + t.Error("npm-package must not be sharedModule") + } + + dir := t.TempDir() + data := Data{Name: "string-kit", Dir: "packages/string-kit"} + if _, err := Render(sub, dir, data); err != nil { + t.Fatal(err) + } + for _, p := range []string{ + "mise.toml", ".gitignore", ".oxfmtrc.json", ".oxlintrc.json", + "package.json", "tsconfig.json", "tsdown.config.ts", "vitest.config.ts", + "README.md", + "src/index.ts", + "src/slugify.ts", + "src/slugify.test.ts", + } { + if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(p))); err != nil { + t.Errorf("missing %s: %v", p, err) + } + } + + // package.json carries the member name; the source files must not (the type is + // name-agnostic and the module is named by package.json, like swift-package's lib). + pkg := string(mustRead(t, filepath.Join(dir, "package.json"))) + if !strings.Contains(pkg, `"name": "string-kit"`) { + t.Errorf("package.json missing the member name:\n%s", pkg) + } + src := string(mustRead(t, filepath.Join(dir, "src/slugify.ts"))) + if !strings.Contains(src, "export function slugify") { + t.Errorf("slugify.ts missing the public function:\n%s", src) + } + if strings.Contains(src, "string-kit") || strings.Contains(src, "StringKit") { + t.Errorf("library source must be name-agnostic, but interpolated the member name:\n%s", src) + } + + // The bound test imports the sibling source and binds via the [scenario.] prefix + // on the it() title — the report-carried identity the engine joins against. + test := string(mustRead(t, filepath.Join(dir, "src/slugify.test.ts"))) + for _, want := range []string{ + `from "vitest"`, + `import { slugify } from "./slugify.ts"`, + `it("[scenario.slug.create.basic]`, + `it("[scenario.slug.create.symbols]`, + } { + if !strings.Contains(test, want) { + t.Errorf("slugify.test.ts missing %q:\n%s", want, test) + } + } + + // The mise test task writes the junit report the engine joins, byte-identical to the + // node family's canonical node:test so a second node member promotes cleanly. + mise := string(mustRead(t, filepath.Join(dir, "mise.toml"))) + if !strings.Contains(mise, "vitest run --reporter=junit --outputFile=junit.xml") { + t.Errorf("mise.toml test task must write the junit report:\n%s", mise) + } + + // The seeded story (root/) and the test's [scenario.] prefixes must name the same + // scenario sub-ids, or a fresh verify would dangle. + root := t.TempDir() + if _, err := RenderRoot(sub, root, data); err != nil { + t.Fatal(err) + } + story := string(mustRead(t, filepath.Join(root, "features/0001-slug/stories/slug.create.md"))) + for _, scen := range []string{"scenario.slug.create.basic", "scenario.slug.create.symbols"} { + if !strings.Contains(story, "") { + t.Errorf("seeded story missing scenario sub-id %q", scen) + } + if !strings.Contains(test, "["+scen+"]") { + t.Errorf("seeded test missing the [scenario] prefix for %q", scen) + } + } + + // The rendered target wiring: verify runs `mise //packages/string-kit:test`, the + // report lands at the member root, and the source dir is the library's src/. + rt, err := RenderTarget(m, data) + if err != nil { + t.Fatal(err) + } + if rt.Command != "mise //packages/string-kit:test" { + t.Errorf("target command = %q", rt.Command) + } + if rt.Report != "packages/string-kit/junit.xml" || rt.Source != "packages/string-kit/src" { + t.Errorf("target report=%q source=%q", rt.Report, rt.Source) + } + if rt.Bindings != "scoped" { + t.Errorf("target bindings=%q, want scoped", rt.Bindings) + } +} diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go index 411cc2e94c..1308b95359 100644 --- a/internal/scaffold/scaffold.go +++ b/internal/scaffold/scaffold.go @@ -163,6 +163,27 @@ func LoadManifest(src fs.FS) (Manifest, error) { return m, nil } +// npmReserved is the set of names npm rejects for a NEW package (validate-npm-package-name's +// validForNewPackages): the two blacklisted names plus the Node.js core modules (a +// core-module collision is a validForNewPackages=false warning). Bare names only — the +// '_'-prefixed, 'node:'-prefixed, and '/'-bearing builtins can't pass the base slug check +// in the first place. This is a conservative snapshot (Node 22/24, removed names like `sys` +// omitted): the list only ever grows, so a stale entry fails OPEN — a brand-new builtin +// slips through to publish time, but a valid name is never falsely rejected. +var npmReserved = map[string]bool{ + "node_modules": true, "favicon.ico": true, + "assert": true, "async_hooks": true, "buffer": true, "child_process": true, + "cluster": true, "console": true, "constants": true, "crypto": true, + "dgram": true, "diagnostics_channel": true, "dns": true, "domain": true, + "events": true, "fs": true, "http": true, "http2": true, "https": true, + "inspector": true, "module": true, "net": true, "os": true, "path": true, + "perf_hooks": true, "process": true, "punycode": true, "querystring": true, + "readline": true, "repl": true, "stream": true, "string_decoder": true, + "timers": true, "tls": true, "trace_events": true, "tty": true, "url": true, + "util": true, "v8": true, "vm": true, "wasi": true, "worker_threads": true, + "zlib": true, +} + // ValidateName checks a member name against the manifest's NameRule, a constraint // beyond the CLI's base slug check (`target add` calls it after LoadManifest, before // any render). The rule exists because a stack may render {{pascal .Name}} directly as @@ -185,6 +206,28 @@ func (m Manifest) ValidateName(name string) error { m.Stack, name, p) } return nil + case "npm": + // The member name becomes the npm package name (package.json "name": "{{.Name}}"), + // so it must be publishable. The base slug check (validTargetName) runs first and + // already excludes spaces, '@', '/', leading '.'/'_'/'-', and other non-URL-friendly + // chars; this adds the npm-specific gates a safe slug still slips through, so the + // failure surfaces at `target add` instead of at `npm publish`. + if low := strings.ToLower(name); low != name { + return fmt.Errorf( + "stack %q needs an npm-publishable name, but %q has capital letters — npm package names must be lowercase (try %q)", + m.Stack, name, low) + } + if len(name) > 214 { + return fmt.Errorf( + "stack %q needs an npm-publishable name, but %q is %d characters — npm package names must be 214 or fewer", + m.Stack, name, len(name)) + } + if npmReserved[name] { + return fmt.Errorf( + "stack %q can't use %q — npm rejects it for a new package (a blacklisted name or a Node.js core module); pick a distinct name", + m.Stack, name) + } + return nil default: return fmt.Errorf("scaffold.json: unknown nameRule %q", m.NameRule) } diff --git a/internal/scaffold/scaffold_test.go b/internal/scaffold/scaffold_test.go index 07d436e0c6..a351f5b2fc 100644 --- a/internal/scaffold/scaffold_test.go +++ b/internal/scaffold/scaffold_test.go @@ -123,6 +123,27 @@ func TestValidateName(t *testing.T) { t.Errorf("identifier rule should reject %q (pascal-cases to a digit-led identifier)", bad) } } + // the npm rule accepts publishable names and rejects what `npm publish` blocks for a + // new package but the base slug still admits (capitals, the blacklist, core modules). + npmRule := Manifest{Stack: "npm-package", NameRule: "npm"} + for _, ok := range []string{"string-kit", "my-lib", "a", "foo_bar", "foo.bar", "9lives", "react-dom"} { + if err := npmRule.ValidateName(ok); err != nil { + t.Errorf("npm rule should accept %q: %v", ok, err) + } + } + for _, bad := range []string{"MyKit", "X", "Foo-Bar", "node_modules", "favicon.ico", "http", "fs", "events", "stream"} { + if err := npmRule.ValidateName(bad); err == nil { + t.Errorf("npm rule should reject %q (npm blocks it for a new package)", bad) + } + } + // length: ≤ 214 ok, > 214 rejected. + if err := npmRule.ValidateName(strings.Repeat("a", 214)); err != nil { + t.Errorf("npm rule should accept a 214-char name: %v", err) + } + if err := npmRule.ValidateName(strings.Repeat("a", 215)); err == nil { + t.Error("npm rule should reject a 215-char name (npm caps at 214)") + } + // the default (no rule) accepts anything the base slug check allowed — incl. a // leading digit, harmless for a dir/npm name (a web app or a go-service binary). none := Manifest{Stack: "web"}