Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/memory/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions .claude/memory/mise-monorepo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down Expand Up @@ -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)

Expand Down
66 changes: 66 additions & 0 deletions .claude/memory/npm-package-scaffold.md
Original file line number Diff line number Diff line change
@@ -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/<name>: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.<id>] …")` 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`.
8 changes: 5 additions & 3 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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/<name>`) + **ts-rest contract**
sharing across go members, then the **npm-package** (`packages/<name>`) + **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
Expand Down
4 changes: 2 additions & 2 deletions cmd/specify/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 != "" {
Expand Down
2 changes: 1 addition & 1 deletion cmd/specify/monorepo.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ func wireMonorepo(root string) error {
// and member dir (the recorded report's parent — every scaffold's report is
// "<dir>/<file>", so filepath.Dir(t.Report) recovers the member dir, honoring a
// --dir override without re-deriving memberDir/<name>). 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 {
Expand Down
8 changes: 4 additions & 4 deletions cmd/specify/register_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@ 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()
if err := os.MkdirAll(filepath.Join(root, "packages/services/src"), 0o755); err != nil {
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",
})
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/design/library-products.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/design/mise-monorepo-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 4 additions & 4 deletions docs/design/stack-scaffolding.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ specify target add <name> --stack <stack> [--dir <path>] [--product <p>] [--data
```

- `<name>` — 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 `<memberDir>/<name>`, 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.
Expand Down Expand Up @@ -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.<id>]` above `it(…)` | Vitest config emitting junit at `report` |
| web / website / node-cli / npm-package | Vitest → `junit` | `// [scenario.<id>]` 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.<id>]` comment | gotestsum junit output |
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/usage/offline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading