diff --git a/.changeset/3577-adr-violations-and-validation-port.md b/.changeset/3577-adr-violations-and-validation-port.md new file mode 100644 index 000000000..8a398a2d2 --- /dev/null +++ b/.changeset/3577-adr-violations-and-validation-port.md @@ -0,0 +1,21 @@ +--- +type: Fixed +pr: 3577 +--- +**Phase 6 ADR/PRD compliance: out-of-seam Modules removed from SDK catalog; CJS-only verbs dispatch direct** — `verify.codebase-drift` and the eight `intel.*` verbs were wrongly bound in the SDK catalog/manifests, in violation of `docs/adr/3524-cjs-sdk-hard-seam.md` §3 and `docs/prd/3524-cjs-sdk-hard-seam.md` L160 which list `drift`, `intel`, `graphify`, `gsd2-import`, `schema-detect`, `fallow-runner`, `installer-migrations` as CJS-only ("...keep their in-process CJS implementations because no SDK counterpart exists"). The `verifyCodebaseDrift` SDK stub then `execFileSync`'d back to `gsd-tools verify codebase-drift`, which the router routed back through the SDK bridge — an infinite recursion that forked hundreds of node processes on the remote 64 GiB docker host before manual kill. All wrongly-bound entries removed; the CJS router and `gsd-tools.cjs` already had direct CJS dispatch paths for these verbs that are now the only path. + +**Phase 6 `config-ensure-section` cutover via catalog rebind, not CJS fallback** — restored the legacy "no-arg full default config.json init" contract on the SDK path by binding the catalog entry `'config-ensure-section'` to `configNewProject` (whose no-args branch produces the same shape as the legacy `ensureConfigFile → buildNewProjectConfig` chain). The original Phase 6 binding to the new `configEnsureSection` handler (single-section ensure, requires `args[0]=sectionName`) broke every CLI caller, which all invoke the no-arg form. + +**`configNewProject` defaults sourced from canonical Configuration Module manifest** — replaced the hardcoded duplicate `defaults` object with a derivation from `sdk/shared/config-defaults.manifest.json` (exported as `CONFIG_DEFAULTS` from `sdk/src/configuration/index.ts`). The previous duplicate had drifted from the manifest — omitted `workflow.{ai_integration_phase, tdd_mode, human_verify_mode, pattern_mapper, plan_bounce*, auto_prune_state, subagent_timeout, security_*, post_planning_gaps}`, `git.create_tag`, `claude_md_path`, `planning.*`, `graphify.*`, `mode`, `resolve_model_ids`, `context_window`. Closes the same `DEFECT.PORT-DRIFT.cjs-sdk` family the ADR was written to prevent. + +**SDK `configSet` value-validation port from CJS `cmdConfigSet`** — added the missing enum/shape validators that the CJS handler enforced: `workflow.drift_action` (warn|auto-remap), `workflow.drift_threshold` (positive integer), `workflow.human_verify_mode` (mid-flight|end-of-phase), `statusline.context_position` (front|end), `code_quality.fallow.scope` (phase|repo), `code_quality.fallow.profile` (minimal|standard|strict), and `review.default_reviewers` (array of slug strings matching `^[a-zA-Z0-9_-]+$`, normalized to lowercase-unique, with the normalized value persisted to disk). + +**Init handlers honor `--tdd` flag and `workflow.subagent_timeout`** — `initExecutePhase` and `initPlanPhase` now parse the `--tdd` boolean override (matching `parseNamedArgs(args, [], ['validate', 'tdd'])` in the CJS router and `options.tdd || config.tdd_mode || false` in the CJS handler), and `initMapCodebase` reads `subagent_timeout` from the canonical `workflow.subagent_timeout` location with the manifest-mandated 300000 default instead of an undefined fallback. + +**`roadmap.analyze` surfaces `mode` per phase** — extracts the same `**Mode:**` field that `roadmapGetPhase` already parses, so consumers can read MVP-mode flagging from either query handler without divergence. + +**SDK `phaseComplete` performs auto-prune of STATE.md when configured** — ported the `workflow.auto_prune_state === true` branch from CJS `cmdPhaseComplete`, calling `statePrune(['--keep-recent', '3', '--silent'], ...)` so completing phase N actually removes stale `[Phase 1..N-3]` decisions instead of leaving them forever. (#2087) + +**SDK `initRemoveWorkspace` errors via thrown `GSDError`** — returning `{ data: { error } }` was treated as success by the CLI output path; the no-name and workspace-not-found branches now throw `GSDError(..., ErrorClassification.Validation)` so the CLI returns non-zero and writes the message to stderr. + +**SDK `frontmatterGet` parses `--field `** — the CLI invocation `frontmatter get --field phase` was passing `args = [file, '--field', 'phase']`; the handler treated `args[1]` as the field name and saw the literal string `--field`. Now handles both `--field ` and positional `args[1]`. diff --git a/.changeset/3577-config-ensure-section-parity.md b/.changeset/3577-config-ensure-section-parity.md new file mode 100644 index 000000000..1547e2726 --- /dev/null +++ b/.changeset/3577-config-ensure-section-parity.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3577 +--- +**Phase 6 `config-*` SDK port parity carve-outs** — restored the legacy contract for four CLI tests broken by the Phase 6 router migration. `config-ensure-section` no longer routes through the new SDK `configEnsureSection` handler (which expected a positional `
` arg the CLI never passes) and instead keeps the `cmdConfigEnsureSection → ensureConfigFile → buildNewProjectConfig` CJS path that produces the full default `.planning/config.json`. The SDK `configNewProject` defaults now match `sdk/shared/config-defaults.manifest.json` (`commit_docs: true`, `parallelization: true`) and report the project-rooted relative path `.planning/config.json` to mirror the CJS shape. SDK error vocabulary is aligned with CJS: `Unknown config key: ` (no quotes), and config-get's malformed-JSON error is led by `Failed to read config.json:` so legacy regression tests keep matching. Closes the `Usage: config-ensure-section
` regression seen in `tests/{config,agent-skills,ai-evals}.test.cjs`. diff --git a/.changeset/3577-docker-test-fixup.md b/.changeset/3577-docker-test-fixup.md new file mode 100644 index 000000000..dd61eb3fa --- /dev/null +++ b/.changeset/3577-docker-test-fixup.md @@ -0,0 +1,11 @@ +--- +type: Fixed +pr: 3577 +--- +**Docker test fix-forward: 12 ubuntu-only regressions surfaced by `gsd-test-summary` cleared** — +- `agents/gsd-intel-updater.md` retargeted from `gsd-sdk query intel.*` to `gsd-tools intel ` (intel is out-of-seam per ADR §3 / PRD L160; the SDK has no handler for it, so the agent's CLI calls were broken). +- `roadmap.get-phase` two-pass lookup for project-code-prefixed IDs (port of CJS `phaseMarkdownRegexSourceExact`, #3599): a `PROJ-42` query now matches `### Phase PROJ-42:` directly without cross-matching a bare `### Phase 42:` that happens to share the trailing integer. +- `roadmap.analyze` extracts the `**Mode:**` field per phase (parity with `roadmap.get-phase`). +- `phase.remove` depth-aware end-of-section regex (port of CJS #3601 fix): removing `### Phase 2:` stops at `### Phase 2.1:` (peer-depth decimal preserved) but continues past `#### Phase 27.1:` (child-depth decimal of `### Phase 27:`). Named capture `(?#{2,4})` + backreference `\k(?!#)` enforces same-depth termination. +- `phase.remove` slugged-plan reference renumbering (port of CJS #3602 fix): the padded-plan-reference pattern now allows arbitrary kebab-case slug segments between `NN-NN` and the `-PLAN.md` / `-SUMMARY.md` suffix, so references like `07-01-cherry-pick-foundation-PLAN.md` get renumbered to `06-01-…` when Phase 7 is removed. +- `configNewProject` filters out manifest keys that legacy CJS init does not materialize (`git.base_branch`, `resolve_model_ids`, `context_window`, `mode`, `planning`, `graphify`): these have their own resolution paths (auto-detect, opt-in) and materializing manifest values would suppress them. `config-get git.base_branch` correctly returns "Key not found" so workflows can fall back to `origin/HEAD` resolution. diff --git a/.changeset/fix-3631-sdk-raw-flag-routers.md b/.changeset/fix-3631-sdk-raw-flag-routers.md new file mode 100644 index 000000000..e781bfd4c --- /dev/null +++ b/.changeset/fix-3631-sdk-raw-flag-routers.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3631 +--- +**SDK dispatch path in family routers now honours `--raw`** — `phase next-decimal --raw`, `roadmap get-phase --raw`, and other family-router commands that route through the SDK bridge now emit the same scalar string the CJS path emitted before #3577. Routers request `mode: 'raw'` from the bridge under `--raw`; the sync-bridge worker wires `formatNativeRaw` to `formatQueryRawOutput` so the bridge returns the per-command projection. Routers then pass the formatted string through `output()`'s rawValue branch instead of JSON-stringifying it. diff --git a/.changeset/fix-3632-lint-handsync-pair-fanout.md b/.changeset/fix-3632-lint-handsync-pair-fanout.md new file mode 100644 index 000000000..806eb4e21 --- /dev/null +++ b/.changeset/fix-3632-lint-handsync-pair-fanout.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3632 +--- +**`lint-shared-module-handsync` now reports unauthorized ts siblings even when a co-named sibling is allowlisted** — when a `bin/lib/.cjs` had two ts candidates on disk (e.g. `sdk/src/.ts` and `sdk/src/query/.ts`) and only one pair was in the allowlist, the `.some()` short-circuit silently skipped the unallowlisted sibling. Each ts candidate is now classified independently so partial-allowlist drift surfaces correctly. diff --git a/.changeset/sturdy-geese-glide.md b/.changeset/sturdy-geese-glide.md new file mode 100644 index 000000000..e7bdd234a --- /dev/null +++ b/.changeset/sturdy-geese-glide.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3577 +--- +**SDK validation errors no longer surface as native_failure** — the runtime-bridge-sync worker now unwraps GSDError causes wrapped in GSDToolsError. Empty/invalid command arguments produce errorKind: 'validation_error' (exit 10) as the SyncErrorKind taxonomy promises, instead of the misleading errorKind: 'native_failure'. Detected by new Phase 6 behavioral contract tests. diff --git a/.changeset/sturdy-pandas-rest.md b/.changeset/sturdy-pandas-rest.md new file mode 100644 index 000000000..7d2d0e06b --- /dev/null +++ b/.changeset/sturdy-pandas-rest.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 3577 +--- +**Shared Module hand-sync drift lint** — `scripts/lint-shared-module-handsync.cjs` runs in CI on every PR and fails when a new `bin/lib/.cjs` and `sdk/src/.ts` (or `sdk/src/query/.ts`) pair is introduced without an entry in `scripts/shared-module-handsync-allowlist.json`. The allowlist documents 14 legitimate cooperating-sibling pairs (Adapters over generated Modules, Readers over shared Builders, runtime-distinct routing) plus 8 known drift pairs (`config`, `decisions`, `intel`, `model-catalog`, `plan-scan`, `schema-detect`, `secrets`, `workstream-name-policy`) flagged as future Shared-Module migration backlog. Phase 6 of #3524 also adds path-specific CODEOWNERS rules requiring architecture-team review for source-of-truth files (`sdk/src//`, `sdk/shared/*.manifest.json`, `sdk/src/runtime-bridge-sync/`, the lint script itself), publishes `docs/agents/cjs-sdk-seam.md` mapping all 15 historical drift bugs (#1535 … #3523) to the enforcement layer that would have blocked each, and adds a contributor guide for adding new Shared Modules and new canonical commands. After this PR the CJS↔SDK seam migration (#3524) is feature-complete. Closes #3575. diff --git a/.githooks/pre-commit b/.githooks/pre-commit index e699feda5..dc81b8d71 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -20,3 +20,23 @@ fi if git diff --cached --name-only | grep -Eq "^sdk/src/project-root/|^get-shit-done/bin/lib/project-root\.generated\.cjs$|^sdk/scripts/gen-project-root\.mjs$|^sdk/scripts/check-project-root-fresh\.mjs$"; then npm run check:project-root-fresh fi + +if git diff --cached --name-only | grep -Eq "^sdk/src/query/plan-scan\.ts$|^get-shit-done/bin/lib/plan-scan\.generated\.cjs$|^sdk/scripts/gen-plan-scan\.mjs$|^sdk/scripts/check-plan-scan-fresh\.mjs$"; then + npm run check:plan-scan-fresh +fi + +if git diff --cached --name-only | grep -Eq "^sdk/src/query/secrets\.ts$|^get-shit-done/bin/lib/secrets\.generated\.cjs$|^sdk/scripts/gen-secrets\.mjs$|^sdk/scripts/check-secrets-fresh\.mjs$"; then + npm run check:secrets-fresh +fi + +if git diff --cached --name-only | grep -Eq "^sdk/src/query/schema-detect\.ts$|^get-shit-done/bin/lib/schema-detect\.generated\.cjs$|^sdk/scripts/gen-schema-detect\.mjs$|^sdk/scripts/check-schema-detect-fresh\.mjs$"; then + npm run check:schema-detect-fresh +fi + +if git diff --cached --name-only | grep -Eq "^sdk/src/query/decisions\.ts$|^get-shit-done/bin/lib/decisions\.generated\.cjs$|^sdk/scripts/gen-decisions\.mjs$|^sdk/scripts/check-decisions-fresh\.mjs$"; then + npm run check:decisions-fresh +fi + +if git diff --cached --name-only | grep -Eq "^sdk/src/workstream-name-policy\.ts$|^get-shit-done/bin/lib/workstream-name-policy\.generated\.cjs$|^sdk/scripts/gen-workstream-name-policy\.mjs$|^sdk/scripts/check-workstream-name-policy-fresh\.mjs$"; then + npm run check:workstream-name-policy-fresh +fi diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 67fc79c3b..018c809f9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,21 @@ # All changes require review from project owner * @glittercowboy + +# Phase 6 of #3524 — source-of-truth files require architecture-team review. +# See docs/agents/cjs-sdk-seam.md for context. +# The blanket rule above already covers everything; these specific rules make +# the architectural intent explicit and would still apply if the blanket rule +# is later relaxed. +/sdk/src/state-document/ @glittercowboy +/sdk/src/configuration/ @glittercowboy +/sdk/src/workstream-inventory/ @glittercowboy +/sdk/src/project-root/ @glittercowboy +/sdk/src/runtime-bridge-sync/ @glittercowboy +/sdk/shared/config-defaults.manifest.json @glittercowboy +/sdk/shared/config-schema.manifest.json @glittercowboy +/sdk/shared/model-catalog.json @glittercowboy +/sdk/src/query/query-runtime-bridge.ts @glittercowboy +/scripts/lint-shared-module-handsync.cjs @glittercowboy +/scripts/shared-module-handsync-allowlist.json @glittercowboy +/sdk/src/query/decisions.ts @glittercowboy +/sdk/src/workstream-name-policy.ts @glittercowboy diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5e28c91fd..d8a334ded 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -126,6 +126,38 @@ jobs: shell: bash run: node sdk/scripts/check-project-root-fresh.mjs + - name: SDK generated plan-scan artifact drift check + if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24 + shell: bash + run: node sdk/scripts/check-plan-scan-fresh.mjs + + - name: SDK generated secrets artifact drift check + if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24 + shell: bash + run: node sdk/scripts/check-secrets-fresh.mjs + + - name: SDK generated schema-detect artifact drift check + if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24 + shell: bash + run: node sdk/scripts/check-schema-detect-fresh.mjs + + - name: SDK generated decisions artifact drift check + if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24 + shell: bash + run: node sdk/scripts/check-decisions-fresh.mjs + + - name: SDK generated workstream-name-policy artifact drift check + if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24 + shell: bash + run: node sdk/scripts/check-workstream-name-policy-fresh.mjs + + - name: Shared Module hand-sync drift check + if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24 + shell: bash + run: node scripts/lint-shared-module-handsync.cjs + - name: Run tests with coverage shell: bash + env: + NODE_OPTIONS: --max-old-space-size=6144 run: npm run test:coverage diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b1f8e60d8..b3c473903 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -122,6 +122,8 @@ Contributor requirements (summary): - Do not rewrite maintainer intent in `CONTEXT.md`/ADRs as part of drive-by cleanup; propose focused updates tied to approved scope. - If using an AI assistant, prompt it to read `CONTEXT.md` and the relevant ADRs before writing any code or docs, and verify it used the correct vocabulary before opening the PR. +**CJS↔SDK seam.** When working on `bin/lib/*.cjs` or `sdk/src/**`, read [`docs/agents/cjs-sdk-seam.md`](docs/agents/cjs-sdk-seam.md). It documents the canonical pattern for Shared Modules (data manifest + source-of-truth file + generator + freshness check + Adapters) and the hand-sync pair lint that blocks new drift. New `.cjs` ↔ `.ts` pairs require either migration to a Shared Module or an explicit allowlist entry with justification in `scripts/shared-module-handsync-allowlist.json`. Adding an allowlist entry requires maintainer review via CODEOWNERS. + **Every PR must link to an approved issue.** PRs without a linked issue are closed without review, no exceptions. - **No draft PRs** — draft PRs are automatically closed. Only open a PR when it is complete, tested, and ready for review. If your work is not finished, keep it on your local branch until it is. diff --git a/agents/gsd-intel-updater.md b/agents/gsd-intel-updater.md index 54eb593b4..f51d6ad22 100644 --- a/agents/gsd-intel-updater.md +++ b/agents/gsd-intel-updater.md @@ -37,7 +37,7 @@ Write machine-parseable, evidence-based intelligence. Every claim references act - **Always include file paths.** Every claim must reference the actual code location. - **Write current state only.** No temporal language ("recently added", "will be changed"). - **Evidence-based.** Read the actual files. Do not guess from file names or directory structures. -- **Cross-platform.** Use Glob, Read, and Grep tools -- not Bash `ls`, `find`, or `cat`. Bash file commands fail on Windows. Only use Bash for `gsd-sdk query intel` CLI calls. +- **Cross-platform.** Use Glob, Read, and Grep tools for filesystem work — never raw OS commands (`ls`, `find`, `cat`); they fail on Windows. CLI invocations go through `gsd-tools intel `, which routes through the Shell Command Projection Module that formats per-OS automatically. - **ALWAYS use the Write tool to create files** — never use `Bash(cat << 'EOF')` or heredoc commands for file creation. @@ -123,7 +123,7 @@ All JSON files include a `_meta` object with `updated_at` (ISO timestamp) and `v } ``` -**exports constraint:** Array of ACTUAL exported symbol names extracted from `module.exports` or `export` statements. MUST be real identifiers (e.g., `"configLoad"`, `"stateUpdate"`), NOT descriptions (e.g., `"config operations"`). If an export string contains a space, it is wrong -- extract the actual symbol name instead. Use `gsd-sdk query intel.extract-exports ` to get accurate exports. +**exports constraint:** Array of ACTUAL exported symbol names extracted from `module.exports` or `export` statements. MUST be real identifiers (e.g., `"configLoad"`, `"stateUpdate"`), NOT descriptions (e.g., `"config operations"`). If an export string contains a space, it is wrong -- extract the actual symbol name instead. Use `gsd-tools intel extract-exports ` to get accurate exports. Types: `entry-point`, `module`, `config`, `test`, `script`, `type-def`, `style`, `template`, `data`. @@ -219,7 +219,7 @@ Glob for project structure indicators: Read package.json, configs, and build files. Write `stack.json`. Then patch its timestamp: ```bash -gsd-sdk query intel.patch-meta .planning/intel/stack.json --cwd +gsd-tools intel patch-meta .planning/intel/stack.json ``` ### Step 3: File Graph @@ -228,7 +228,7 @@ Glob source files (`**/*.ts`, `**/*.js`, `**/*.py`, etc., excluding node_modules Read key files (entry points, configs, core modules) for imports/exports. Write `files.json`. Then patch its timestamp: ```bash -gsd-sdk query intel.patch-meta .planning/intel/files.json --cwd +gsd-tools intel patch-meta .planning/intel/files.json ``` Focus on files that matter -- entry points, core modules, configs. Skip test files and generated code unless they reveal architecture. @@ -239,7 +239,7 @@ Grep for route definitions, endpoint declarations, CLI command registrations. Patterns to search: `app.get(`, `router.post(`, `@GetMapping`, `def route`, express route patterns. Write `apis.json`. If no API endpoints found, write an empty entries object. Then patch its timestamp: ```bash -gsd-sdk query intel.patch-meta .planning/intel/apis.json --cwd +gsd-tools intel patch-meta .planning/intel/apis.json ``` ### Step 5: Dependencies @@ -248,7 +248,7 @@ Read package.json (dependencies, devDependencies), requirements.txt, go.mod, Car Cross-reference with actual imports to populate `used_by`. Write `deps.json`. Then patch its timestamp: ```bash -gsd-sdk query intel.patch-meta .planning/intel/deps.json --cwd +gsd-tools intel patch-meta .planning/intel/deps.json ``` ### Step 6: Architecture @@ -258,7 +258,7 @@ Write `arch.md`. ### Step 6.5: Self-Check -Run: `gsd-sdk query intel.validate --cwd ` +Run: `gsd-tools intel validate` Review the output: @@ -270,7 +270,7 @@ This step is MANDATORY -- do not skip it. ### Step 7: Snapshot -Run: `gsd-sdk query intel.snapshot --cwd ` +Run: `gsd-tools intel snapshot` This writes `.last-refresh.json` with accurate timestamps and hashes. Do NOT write `.last-refresh.json` manually. diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 6a47aa7f8..7c662d9cd 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -264,6 +264,7 @@ "artifacts.cjs", "audit.cjs", "cjs-command-router-adapter.cjs", + "cjs-sdk-bridge.cjs", "clusters.cjs", "command-aliases.generated.cjs", "commands.cjs", @@ -273,6 +274,7 @@ "context-utilization.cjs", "core.cjs", "decisions.cjs", + "decisions.generated.cjs", "docs.cjs", "drift.cjs", "fallow-runner.cjs", @@ -295,6 +297,7 @@ "phase.cjs", "phases-command-router.cjs", "plan-scan.cjs", + "plan-scan.generated.cjs", "planning-workspace.cjs", "profile-output.cjs", "profile-pipeline.cjs", @@ -305,7 +308,9 @@ "runtime-homes.cjs", "runtime-slash.cjs", "schema-detect.cjs", + "schema-detect.generated.cjs", "secrets.cjs", + "secrets.generated.cjs", "security.cjs", "shell-command-projection.cjs", "state-command-router.cjs", @@ -321,6 +326,7 @@ "workstream-inventory-builder.generated.cjs", "workstream-inventory.cjs", "workstream-name-policy.cjs", + "workstream-name-policy.generated.cjs", "workstream.cjs", "worktree-safety.cjs" ], diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 181c2beeb..85dfd6d0e 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -361,7 +361,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (64 shipped) +## CLI Modules (70 shipped) Full listing: `get-shit-done/bin/lib/*.cjs`. @@ -372,6 +372,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `artifacts.cjs` | Canonical artifact registry — known `.planning/` root file names; used by `gsd-health` W019 lint | | `audit.cjs` | Audit dispatch, audit open sessions, audit storage helpers | | `cjs-command-router-adapter.cjs` | Shared compatibility adapter for manifest-backed CJS command-family routers | +| `cjs-sdk-bridge.cjs` | Shared SDK runtime-bridge loader (`tryLoadSdk`/`getExecuteForCjs`); consumed by every CJS router and `gsd-tools.cjs` to delegate canonical commands to the SDK in-process | | `clusters.cjs` | Skill cluster definitions for the runtime surface module (ADR-0011 Phase 2) | | `command-aliases.generated.cjs` | Generated CJS alias/subcommand metadata for manifest-backed family routers | | `commands.cjs` | Misc CLI commands (slug, timestamp, todos, scaffolding, stats) | @@ -380,7 +381,8 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `configuration.generated.cjs` | Generated Configuration Module — canonical config loading, legacy-key normalization, defaults merge, and explicit on-disk migration; source of truth for both SDK and CJS consumers | | `context-utilization.cjs` | Pure classifier for `gsd-health --context` — turns (tokensUsed, contextWindow) into a `{ percent, state }` triage result against the 60%/70% fracture-point thresholds (#2792) | | `core.cjs` | Error handling, output formatting, shared utilities, runtime fallbacks; compatibility re-exports for planning-workspace helpers | -| `decisions.cjs` | Shared parser for CONTEXT.md `` blocks (D-NN entries); used by `gap-checker.cjs` and intended for #2492 plan/verify decision gates | +| `decisions.cjs` | CJS shim adapter — re-exports from `decisions.generated.cjs` (Phase 6/#3575 Shared Module migration) | +| `decisions.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/query/decisions.ts` via `sdk/scripts/gen-decisions.mjs`; parses CONTEXT.md `` blocks, accepts numeric (D-42) and alphanumeric (D-INFRA-01) IDs, returns `{id, text, category, tags, trackable}`; do not edit directly | | `docs.cjs` | Docs-update workflow init, Markdown scanning, monorepo detection | | `drift.cjs` | Post-execute codebase structural drift detector (#2003): classifies file changes into new-dir/barrel/migration/route categories and round-trips `last_mapped_commit` frontmatter | | `fallow-runner.cjs` | Fallow audit adapter for `/gsd-code-review`: binary resolution (`PATH` then `node_modules/.bin`), actionable missing-binary errors, and structural findings normalization | @@ -402,7 +404,8 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `phase-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phase` | | `phase.cjs` | Phase directory operations, decimal numbering, plan indexing | | `phases-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phases` | -| `plan-scan.cjs` | Canonical phase-plan scanner — shared helper for detecting plan and summary files in flat and nested layouts (k014); consumed by state, roadmap, init, and workstream inventory paths | +| `plan-scan.cjs` | CJS shim adapter — re-exports from `plan-scan.generated.cjs` (Phase 6/#3575 Shared Module migration) | +| `plan-scan.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/query/plan-scan.ts` via `sdk/scripts/gen-plan-scan.mjs`; canonical phase-plan scanner for detecting plan and summary files in flat and nested layouts (k014); do not edit directly | | `planning-workspace.cjs` | Planning path/workstream seam (`planningDir`, `planningPaths`, active-workstream routing, `.planning/.lock` orchestration) | | `project-root.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/project-root/index.ts` via `sdk/scripts/gen-project-root.mjs`; resolves a project root from a starting directory using four heuristics (own `.planning/` guard, `sub_repos` config, `multiRepo` flag, `.git` heuristic); do not edit directly | | `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation | @@ -412,8 +415,10 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `roadmap.cjs` | ROADMAP.md parsing, phase extraction, plan progress | | `runtime-homes.cjs` | Canonical runtime → global config/skills directory mapping; first-class support for all 15 runtimes including Hermes nested layout and Cline rules-based exclusion (#3126) | | `runtime-slash.cjs` | Runtime-aware slash-command formatter — single source of truth for emitting `/gsd-` (skills-based runtimes) and `$gsd-` (codex) in user-facing output and persisted artifacts (#3584) | -| `schema-detect.cjs` | Schema-drift detection for ORM patterns (Prisma, Drizzle, etc.) | -| `secrets.cjs` | Secret-config masking convention (`****`) for integration keys managed by `/gsd-config --integrations` — keeps plaintext out of `config-set` output | +| `schema-detect.cjs` | CJS shim adapter — re-exports from `schema-detect.generated.cjs` (Phase 6/#3575 Shared Module migration) | +| `schema-detect.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/query/schema-detect.ts` via `sdk/scripts/gen-schema-detect.mjs`; schema-drift detection for ORM patterns (Prisma, Drizzle, Supabase, TypeORM, Payload); exports `detectSchemaFiles`, `detectSchemaOrm`, `checkSchemaDrift`, `SCHEMA_PATTERNS`, `ORM_INFO`; do not edit directly | +| `secrets.cjs` | CJS shim adapter — re-exports from `secrets.generated.cjs` (Phase 6/#3575 Shared Module migration) | +| `secrets.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/query/secrets.ts` via `sdk/scripts/gen-secrets.mjs`; secret-config masking convention (`****`) for integration keys; exports `SECRET_CONFIG_KEYS`, `isSecretKey`, `maskSecret`, `maskIfSecret`; do not edit directly | | `security.cjs` | Path traversal prevention, prompt injection detection, safe JSON/shell helpers | | `shell-command-projection.cjs` | Runtime-aware shell command projection for managed hook serialization: decides PowerShell call-operator usage by runtime/platform and normalizes Windows script path tokens | | `state-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools state` | @@ -428,7 +433,8 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `verify.cjs` | Plan structure, phase completeness, reference, commit validation | | `workstream-inventory-builder.generated.cjs` | GENERATED — pure workstream inventory projection builder; CJS artifact emitted from `sdk/src/workstream-inventory/builder.ts` via `sdk/scripts/gen-workstream-inventory-builder.mjs`; do not edit directly | | `workstream-inventory.cjs` | Shared workstream inventory projection: state fields, phase/plan/summary counts, roadmap phase count, and active marker — thin orchestrator that delegates pure projection to `workstream-inventory-builder.generated.cjs` | -| `workstream-name-policy.cjs` | Canonical workstream name validation (`isValidActiveWorkstreamName`) and slug normalization (`toWorkstreamSlug`); shared by all workstream callers | +| `workstream-name-policy.cjs` | CJS shim adapter — re-exports from `workstream-name-policy.generated.cjs` (Phase 6/#3575 Shared Module migration) | +| `workstream-name-policy.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/workstream-name-policy.ts` via `sdk/scripts/gen-workstream-name-policy.mjs`; canonical workstream name validation (`isValidActiveWorkstreamName`, `hasInvalidPathSegment`, `validateWorkstreamName`) and slug normalization (`toWorkstreamSlug`); do not edit directly | | `workstream.cjs` | Workstream CRUD, migration, session-scoped active pointer | | `worktree-safety.cjs` | Worktree-root resolution and non-destructive prune policy decisions; owns W017 health-check logic | diff --git a/docs/agents/cjs-sdk-seam.md b/docs/agents/cjs-sdk-seam.md new file mode 100644 index 000000000..3d29276cf --- /dev/null +++ b/docs/agents/cjs-sdk-seam.md @@ -0,0 +1,269 @@ +# CJS↔SDK Hard-Seam Migration: Complete Reference +## Issue #3575 (Parent: #3524) + +--- + +## Migration overview + +The CJS↔SDK hard-seam migration (#3524) eliminates a class of config-schema drift bugs by introducing single sources of truth at every decision point where CJS and SDK code previously diverged. The migration proceeded in six phases: + +| Phase | PR | Summary | +|-------|----|---------| +| Phase 1 | [#3531](https://github.com/gsd-build/get-shit-done/pull/3531) | `state-document` Shared Module — source-of-truth at `sdk/src/state-document/`, generator, freshness check, CJS Adapter (`state-document.generated.cjs`). Worked example for the pattern. | +| Phase 2 | [#3540](https://github.com/gsd-build/get-shit-done/pull/3540) | `configuration` Shared Module — `sdk/shared/config-schema.manifest.json` + `sdk/shared/config-defaults.manifest.json` as data manifests; generator + freshness check + CJS Adapter. | +| Phase 3 | [#3548](https://github.com/gsd-build/get-shit-done/pull/3548) | `workstream-inventory` Shared Module — source-of-truth at `sdk/src/workstream-inventory/`, builder, generator, freshness check, CJS Adapter. | +| Phase 4 | [#3554](https://github.com/gsd-build/get-shit-done/pull/3554) | `project-root` Shared Module — source-of-truth at `sdk/src/project-root/`, generator, freshness check, CJS Adapter. | +| Phase 5.0 | [#3558](https://github.com/gsd-build/get-shit-done/pull/3558) | `runtime-bridge-sync` worker — enables CJS-side execution of SDK native handlers; state.* family initial router delegation via `executeForCjs`. | +| Phase 5.1 | [#3574](https://github.com/gsd-build/get-shit-done/pull/3574) | `state.*` router delegation complete — all known state subcommands delegated via `executeForCjs`; Phase 5.0 worker bug fix. | +| Phase 6 | [#3577](https://github.com/gsd-build/get-shit-done/pull/3577) (closes [#3575](https://github.com/gsd-build/get-shit-done/issues/3575)) | Enforcement hardening + Final completion — hand-sync drift lint, CODEOWNERS, 6 family-router migrations, 5 Shared Module migrations (plan-scan, secrets, schema-detect, decisions, workstream-name-policy), workstream native support, parity fixes. Migration feature-complete: 22 cooperating siblings, 0 backlog pairs. | + +--- + +## Phase 6 Retrospective: 15 config-schema drift bugs + +This section captures 15 recurring config-schema drift bugs that motivated the migration. For each, we record what drifted, the surgical fix, and which Phase 6 enforcement layer would have prevented it. + +--- + +### #1535 — Silent failure on unrecognized config.json keys +- **Drifted:** `loadConfig` silently ignored any top-level key in `.planning/config.json` not in `VALID_CONFIG_KEYS`, giving users no feedback when hand-edited or external-tool-added keys had no effect. +- **Fix landed:** PR #1542 — added stderr warning listing unrecognized keys. +- **Would have been blocked by:** **handsync lint** — a seam-aware linter would forbid having parallel hand-authored config validators (CJS `config.cjs` and SDK `config-mutation.ts`) that could silently diverge. + +--- + +### #1542 — fix(config): warn on unrecognized keys in config.json instead of silent drop +- **Drifted:** No drift in this bug itself; it *fixed* #1535's silent-drop behavior by adding the warning. +- **Fix landed:** PR #1542 — merged as the direct fix for #1535. +- **Would have been blocked by:** **per-Module drift lint** (freshness check on config validation) — both CJS and SDK config paths would be regenerated from a single source-of-truth schema module, eliminating the silent-drop risk. + +--- + +### #2047 — bug: config-set rejects intel.enabled despite being a documented config key +- **Drifted:** `intel.enabled` was documented in workflows and gated in runtime code (`intel.cjs:58`), but missing from `VALID_CONFIG_KEYS` in `config.cjs`, so `config-set` rejected it. +- **Fix landed:** PR #2021 — added `intel.enabled` to `VALID_CONFIG_KEYS` in CJS. +- **Would have been blocked by:** **handsync lint** — linter would enforce that every config key gated in runtime code or documented in workflows must appear in the validator allowlist. + +--- + +### #2052 — fix(config): add intel.enabled to VALID_CONFIG_KEYS +- **Drifted:** Same as #2047 (missing from allowlist). +- **Fix landed:** PR #2021 (same PR as #2047 fix). +- **Would have been blocked by:** **handsync lint** — same as #2047. + +--- + +### #2638 — bug: loadConfig writes sub_repos to top-level, then warns it's unknown +- **Drifted:** After #2561 canonicalized `sub_repos` to `planning.sub_repos`, the legacy migration and filesystem auto-sync in `loadConfig` still wrote to top-level `parsed.sub_repos`, which was then flagged as unknown. +- **Fix landed:** PR #2668 — rewrote both paths to target `parsed.planning.sub_repos` and deleted stale top-level copy. +- **Would have been blocked by:** **per-Module drift lint** (freshness check for config shape) — the canonical location for `sub_repos` would be codified in a schema, and any code path writing to it would be verified against that schema at lint time. + +--- + +### #2655 — fix(core): write sub_repos to planning.sub_repos, not top-level +- **Drifted:** Same as #2638. +- **Fix landed:** PR #2668 (same as #2638 fix). +- **Would have been blocked by:** **per-Module drift lint** — same as #2638. + +--- + +### #2653 — bug: SDK config-set rejects documented config keys accepted by CJS config-set +- **Drifted:** SDK's `config-mutation.ts` had a hand-maintained `VALID_CONFIG_KEYS` set that had drifted **28 keys** behind CJS's `config-schema.cjs`, so documented commands like `gsd-sdk query config-set planning.sub_repos` were rejected. +- **Fix landed:** PR #2670 — extracted shared `sdk/src/query/config-schema.ts` module mirroring CJS exactly; added parity test to fail on future drift. +- **Would have been blocked by:** **manifest data isolation** — the config schema would live in one place (e.g., `sdk/shared/config.manifest.json`), and both CJS and SDK would read it, eliminating the possibility of independent drift. + +--- + +### #2670 — fix(#2653): eliminate SDK↔CJS config-schema drift +- **Drifted:** Same as #2653 (28-key drift). +- **Fix landed:** PR #2670 (same as #2653 fix). +- **Would have been blocked by:** **manifest data isolation** — same as #2653. + +--- + +### #2687 — bug: loadConfig warns on valid dynamic-pattern containers in .planning/config.json +- **Drifted:** Keys like `review.models.` were registered in `config-schema.cjs`'s `DYNAMIC_KEY_PATTERNS` but absent from the hand-maintained `KNOWN_TOP_LEVEL` set in `core.cjs`, causing false-positive "unknown key" warnings. +- **Fix landed:** PR #2706 — added `topLevel` field to `DYNAMIC_KEY_PATTERNS` entries; derived `KNOWN_TOP_LEVEL` from schema instead of maintaining it manually. +- **Would have been blocked by:** **per-Module drift lint** — the validator that builds `KNOWN_TOP_LEVEL` would be regenerated from the schema each run, not hand-maintained. + +--- + +### #2706 — fix(#2687): loadConfig no longer warns on valid dynamic-pattern containers +- **Drifted:** Same as #2687 (false warnings on valid dynamic keys). +- **Fix landed:** PR #2706 (same as #2687 fix). +- **Would have been blocked by:** **per-Module drift lint** — same as #2687. + +--- + +### #2798 — context_window missing from VALID_CONFIG_KEYS +- **Drifted:** `context_window` was documented in workflows and read in SDK runtime (`init.js:190`, `validate.js:575`), but missing from allowlists in both `config-mutation.ts` and `config-schema.cjs`, so writes were rejected. +- **Fix landed:** PR #2816 — added `context_window` to `VALID_CONFIG_KEYS` in both SDK and CJS. +- **Would have been blocked by:** **handsync lint** — linter would enforce that every key read at runtime must be in the allowlist. + +--- + +### #2816 — fix(#2798): add context_window to VALID_CONFIG_KEYS allowlist +- **Drifted:** Same as #2798 (missing from allowlists). +- **Fix landed:** PR #2816 (same as #2798 fix). +- **Would have been blocked by:** **handsync lint** — same as #2798. + +--- + +### #3055 — bug: top-level branching_strategy silently becomes "none" +- **Drifted:** `.planning/config.json` with top-level `branching_strategy: "phase"` was flagged as unknown and dropped by validator, causing `loadConfig` to fall back to the `"none"` default, so phase commits landed on the operator's current branch instead of creating `gsd/phase-{N}` branches. +- **Fix landed:** PR #3116 — SDK-side only; added legacy normalization in `mergeDefaults()` to graft top-level value into canonical `git.branching_strategy` slot before validation. +- **Would have been blocked by:** **per-Module drift lint** — the canonical location for `branching_strategy` would be codified in schema; validator would not strip the value before migrations had a chance to run, or CJS and SDK would share the same migration code. + +--- + +### #3116 — fix: normalize legacy top-level branching_strategy into git config +- **Drifted:** Same as #3055 (legacy top-level shape not normalized before validator strips it). +- **Fix landed:** PR #3116 (SDK-side normalization in `mergeDefaults()`). +- **Would have been blocked by:** **per-Module drift lint** — same as #3055, but SDK-side fix would be shared with CJS via seam layer instead of being ported separately. + +--- + +### #3523 — bug: CJS loadConfig warns top-level branching_strategy 'will be ignored', but actively reads it +- **Drifted:** After PR #3116 fixed the SDK side, the CJS path still emitted false "will be ignored" warnings on the same legacy top-level key, because `KNOWN_TOP_LEVEL` derivation extracted top-level names from `VALID_CONFIG_KEYS` (which contains `'git.branching_strategy'` but not `'branching_strategy'`), and the warning was factually incorrect — `core.cjs:485` does read the legacy value via fallback logic. +- **Fix landed:** PR #3527 — added `'branching_strategy'` to the `KNOWN_TOP_LEVEL` hand-maintained list under the deprecated-keys bucket, suppressing the false warning. +- **Would have been blocked by:** **runtime-bridge delegation** — if CJS and SDK config loading shared a common normalization routine (via `executeForCjs` or a shared seam module), the SDK fix in #3116 would automatically apply to CJS; no separate CJS-side warning would be possible. + +--- + +## Surprises + +None. All 15 bugs are genuine CJS↔SDK schema/validation drift, exactly the class the seam migration prevents. + +## Phase 6 Enforcement Summary + +The seam migration introduces these layers: + +1. **handsync lint** (`scripts/lint-shared-module-handsync.cjs`) — Forbids parallel hand-authored validator modules; catches #1535, #2047, #2798. +2. **freshness check** (`sdk/scripts/check--fresh.mjs`) — Regenerates config validators from schema each run; catches #2687, #3055. +3. **manifest data isolation** (`sdk/shared/*.manifest.json`) — Single source-of-truth for schema; catches #2653. +4. **per-Module drift lint** — Combination of freshness checks and schema-derived allowlists; catches #2638, #2687, #3055. +5. **runtime-bridge delegation** (`executeForCjs` + shared seam modules) — Eliminates parallel CJS/SDK implementations; catches #3523 by preventing separate CJS warning logic. + +Together, these layers eliminate the 15-bug class by enforcing single sources of truth at each decision point. + +--- + +## Guide: Adding a new Shared Module + +Use this when you want to extract a new piece of data or logic that both CJS and SDK currently duplicate hand-by-hand. Phase 1's `state-document` migration is the worked example. + +**Step 1 — Create the source-of-truth file** + +```text +sdk/src//index.ts +``` + +This is the canonical definition. It may export a schema, a set of keys, a type, or a data object. It must not import from CJS or from generated files. + +**Step 2 — Write the generator script** + +```text +sdk/scripts/gen-.mjs +``` + +The generator reads `sdk/src//index.ts` (or `sdk/shared/.manifest.json` for pure-data manifests), produces a generated output file (either `sdk/src/.generated.ts` or `get-shit-done/bin/lib/.generated.cjs`), and exits 0. It must be idempotent: running it twice produces the same output. + +**Step 3 — Write the freshness check** + +```text +sdk/scripts/check--fresh.mjs +``` + +The freshness check re-runs the generator into a temp location, diffs against the committed file, and exits 1 with a clear message if they diverge. This is what CI runs. + +**Step 4 — Write the parity test** (optional but recommended) + +```text +tests/-parity.test.cjs +``` + +Assert that the CJS Adapter and the SDK source-of-truth agree on every field that matters (key sets, defaults, schema shape). This test catches generator bugs that the freshness check cannot. + +**Step 5 — Wire CI** + +Add a step in `.github/workflows/test.yml` after the existing freshness-check block (before "Run tests with coverage"), gated on `matrix.os == 'ubuntu-latest' && matrix.node-version == 24`: + +```yaml +- name: SDK generated artifact drift check + if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24 + shell: bash + run: node sdk/scripts/check--fresh.mjs +``` + +**Step 6 — Run inventory regen** + +If the module affects `CONTEXT.md`'s module inventory, update that section. Also update `scripts/shared-module-handsync-allowlist.json`: move any matching entry from `migrateMeBacklog` to `cooperatingSiblings` (or remove it entirely if the CJS hand-copy is now deleted). + +**Step 7 — Update CODEOWNERS** + +Add the new source-of-truth path to `.github/CODEOWNERS` under the Phase 6 block to make the architectural ownership explicit. + +**Reference:** Phase 1 PR [#3531](https://github.com/gsd-build/get-shit-done/pull/3531) — `state-document` migration. + +--- + +## Guide: Adding a new canonical command + +Use this when adding a new `gsd-sdk query .` that should be handled natively in the SDK (not delegated to CJS). Phase 5.1's `state.update` migration (PR [#3574](https://github.com/gsd-build/get-shit-done/pull/3574)) is the worked example. + +**Step 1 — Declare in the command manifest** + +Add the command definition to `sdk/src/query/command-manifest..ts`. Include the full argument schema and a `handler` reference. + +**Step 2 — Implement the SDK handler** + +Write the handler in `sdk/src/query/.ts` (or inline in the manifest file for simple cases). The handler receives validated args and the runtime context; it must not shell out to CJS. + +**Step 3 — Add CJS router delegate (Phase 5.1+ pattern)** + +In the family's CJS command router (e.g. `get-shit-done/bin/lib/state-command-router.cjs`), add a delegate case that calls `executeForCjs(subcommand, args)` from `cjs-command-router-adapter.cjs`. This ensures the CJS binary dispatches to the SDK native handler rather than re-implementing the logic. + +**Step 4 — Add a golden parity test** + +Add a test in `tests/-command-router.test.cjs` (or a new file if the family has no test yet) that: +1. Invokes the command via the SDK query path. +2. Invokes the command via the CJS router path. +3. Asserts both produce identical output. + +This test enforces that the delegate and the native handler stay aligned. + +**Reference:** Phase 5.1 PR [#3574](https://github.com/gsd-build/get-shit-done/pull/3574) — `state.update` delegation. + +--- + +## Phase 6 Final Completion Summary + +Phase 6 (issue #3575, PR #3577) is feature-complete. The migration is done. + +**What shipped in Phase 6:** + +- **Shared Modules migrated (5 total in Phase 6):** `plan-scan`, `secrets`, `schema-detect`, `decisions`, `workstream-name-policy`. Each follows the full pattern: SDK source-of-truth, generator (`gen-.mjs`), freshness check (`check--fresh.mjs`), generated CJS artifact (`.generated.cjs`), CJS shim re-export, parity test, CI step, pre-commit hook, CODEOWNERS entry. +- **Workstream native support:** The sync bridge worker now correctly threads `workstream` through to `registry.dispatch()`. `GSDTransport` no longer forces subprocess for workstream-scoped requests. Workstream-scoped state commands execute natively. +- **State parity divergences resolved:** `state.record-metric` and `state.prune` SDK handlers now match CJS semantics exactly. +- **MIGRATE_ME pairs resolved:** `decisions` and `workstream-name-policy` migrated from `migrateMeBacklog` to `cooperatingSiblings` as ADAPTER-OVER-MODULE. +- **Lint final state:** 22 cooperating siblings, 0 backlog pairs. + +**Decisions migration specifics (B1):** +- SDK `decisions.ts` regex aligned to CJS: `D-([A-Za-z0-9_-]+)` (alphanumeric IDs like `D-INFRA-01` accepted). +- SDK returns richer `{id, text, category, tags, trackable}`; CJS callers using only `{id, text}` safely ignore extras. +- Parity test: `tests/decisions-generator.test.cjs` (15 tests covering numeric IDs, alphanumeric IDs, richer schema fields). + +**Workstream-name-policy migration specifics (B2):** +- Added `hasInvalidPathSegment` and `isValidActiveWorkstreamName` to SDK `workstream-name-policy.ts`. +- `validateWorkstreamName` is now an alias for `isValidActiveWorkstreamName` (consistent with CJS semantics). +- Parity test: `tests/workstream-name-policy-generator.test.cjs` (19 tests covering all four exports). + +--- + +## Open follow-ups + +No migration items remain. The following are future quality candidates, not defects: + +- **`config.cjs` / `sdk/src/config.ts`** — These files are CJS-CLI-ONLY (per allowlist classification). The `config.cjs` file contains only CLI command handlers that use sync CJS APIs; `sdk/src/config.ts` provides the async SDK layer. They serve disjoint surfaces. A future migration would require converting the CLI handlers to async + SDK patterns, which is a larger refactor out of scope for this migration cycle. +- **`intel.cjs` / `sdk/src/query/intel.ts`** — Intentional architectural divergence (different file naming conventions between CJS and SDK; documented in allowlist). A future migration would require reconciling INTEL_FILES naming, which is a breaking change for existing consumers. +- **`model-catalog.cjs` / `sdk/src/model-catalog.ts`** — Both sides read from `sdk/shared/model-catalog.json` independently (ADAPTER-OVER-MODULE pattern). This is intentional; the shared JSON is the source-of-truth. No duplication of logic between CJS and SDK consumers. diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 5a20db37b..b17708ebf 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -199,6 +199,82 @@ const { routePhasesCommand } = require('./lib/phases-command-router.cjs'); const { routeValidateCommand } = require('./lib/validate-command-router.cjs'); const { routeRoadmapCommand } = require('./lib/roadmap-command-router.cjs'); +// ─── SDK bridge (Phase 6 inline family / non-family delegation) ─────────────── +// For inline case blocks that have SDK counterparts (frontmatter, config, and +// non-family commands), we attempt to dispatch via executeForCjs (the sync +// bridge). CJS handlers are retained as fallback when SDK is unavailable. +// +// NOTE: migrate-config, detect-custom-files, config-path, and find-phase +// are CJS-native special cases; see comments inline. + +// Shared loader for the synchronous SDK runtime bridge; see +// `bin/lib/cjs-sdk-bridge.cjs`. All canonical-command CJS dispatchers (the +// per-family routers and the non-family helper below) consume the same loader +// so a change to the SDK-load contract lands in one place. +const { tryLoadSdk: _tryLoadSdkBridge, getExecuteForCjs } = require('./lib/cjs-sdk-bridge.cjs'); + +/** + * Attempt SDK dispatch for a non-family command. + * + * Returns true when the SDK was available and handled the command (success or + * typed error). Returns false when the SDK is unavailable, signalling the + * caller to fall through to the CJS handler. + * + * @param {object} opts + * @param {string} opts.registryCommand - canonical command name in the SDK registry + * @param {string[]} opts.registryArgs - args to pass to the SDK handler + * @param {string} opts.legacyCommand - original gsd-tools command name (for error messages) + * @param {string[]} opts.legacyArgs - original args (for error messages) + * @param {string} opts.cwd - project dir + * @param {boolean} opts.raw - raw output mode + * @param {Function} opts.error - error reporter + * @param {Function} opts.output - output emitter (core.output) + */ +function _dispatchNonFamily({ registryCommand, registryArgs, legacyCommand, legacyArgs, cwd, raw, error, output }) { + if (!_tryLoadSdkBridge()) return false; + const result = getExecuteForCjs()({ + registryCommand, + registryArgs, + legacyCommand, + legacyArgs, + // Always request typed JSON from the bridge; CJS `output(data, raw)` handles + // user-facing rendering. Passing `mode: 'raw'` would make the bridge + // pre-render result.data to a JSON string that the CJS output path then + // double-stringifies (returning a JSON string of a JSON string). + mode: 'json', + projectDir: cwd, + workstream: process.env.GSD_WORKSTREAM || undefined, + }); + if (!result.ok) { + const message = (result.errorDetails && result.errorDetails.message) + || `${legacyCommand} (${registryCommand}) failed (${result.errorKind})`; + // Propagate the structured reason code through to CJS `error()` so the + // `--json-errors` JSON-shaped stderr carries the typed reason (e.g. + // 'config_key_not_found') instead of the generic 'unknown'. Handlers + // tag the GSDError with `.reason` and the worker forwards it via + // errorDetails.reason. (Bugs #2943, #3086.) + const reason = result.errorDetails && result.errorDetails.reason; + if (reason) { + error(message, reason); + } else { + error(message); + } + return true; // handled (error reported) + } + // CJS parity for --raw output (config.cjs:525 `output(value, raw, String(value))`): + // when the caller asked for --raw and the SDK returned a scalar, pass that + // scalar through as `rawValue` so core.output() emits the bare string + // representation instead of JSON-stringifying it. Non-scalar shapes fall + // through to the structured JSON path, matching `output(obj, raw)`. + const data = result.data; + if (raw && (typeof data === 'string' || typeof data === 'number' || typeof data === 'boolean')) { + output(data, raw, String(data)); + } else { + output(data, raw); + } + return true; +} + // ─── Arg parsing helpers ────────────────────────────────────────────────────── /** @@ -524,7 +600,19 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand } case 'find-phase': { - phase.cmdFindPhase(cwd, args[1], raw); + // Phase 6 (#3575): dispatch via SDK executeForCjs when available. + // SDK handler: findPhase in sdk/src/query/phase.ts. + const handled = _dispatchNonFamily({ + registryCommand: 'find-phase', + registryArgs: args.slice(1), + legacyCommand: 'find-phase', + legacyArgs: args.slice(1), + cwd, + raw, + error, + output: core.output, + }); + if (!handled) phase.cmdFindPhase(cwd, args[1], raw); break; } @@ -590,8 +678,31 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand } case 'frontmatter': { + // Phase 6 (#3575): dispatch via SDK executeForCjs when available. + // SDK handler: sdk/src/query/frontmatter.ts + frontmatter-mutation.ts. + // CJS fallback: frontmatter.cjs (cooperating sibling). const subcommand = args[1]; const file = args[2]; + const FRONTMATTER_SDK_MAP = { + get: 'frontmatter.get', + set: 'frontmatter.set', + merge: 'frontmatter.merge', + validate: 'frontmatter.validate', + }; + if (subcommand in FRONTMATTER_SDK_MAP) { + const handled = _dispatchNonFamily({ + registryCommand: FRONTMATTER_SDK_MAP[subcommand], + registryArgs: args.slice(2), + legacyCommand: 'frontmatter', + legacyArgs: args.slice(1), + cwd, + raw, + error, + output: core.output, + }); + if (handled) break; + } + // CJS fallback (SDK unavailable or unknown subcommand) if (subcommand === 'get') { frontmatter.cmdFrontmatterGet(cwd, file, parseNamedArgs(args, ['field']).field, raw); } else if (subcommand === 'set') { @@ -619,12 +730,36 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand } case 'generate-slug': { - commands.cmdGenerateSlug(args[1], raw); + // Phase 6 (#3575): dispatch via SDK executeForCjs when available. + // SDK handler: generateSlug in sdk/src/query/utils.ts. + const handled = _dispatchNonFamily({ + registryCommand: 'generate-slug', + registryArgs: args.slice(1), + legacyCommand: 'generate-slug', + legacyArgs: args.slice(1), + cwd, + raw, + error, + output: core.output, + }); + if (!handled) commands.cmdGenerateSlug(args[1], raw); break; } case 'current-timestamp': { - commands.cmdCurrentTimestamp(args[1] || 'full', raw); + // Phase 6 (#3575): dispatch via SDK executeForCjs when available. + // SDK handler: currentTimestamp in sdk/src/query/utils.ts. + const handled = _dispatchNonFamily({ + registryCommand: 'current-timestamp', + registryArgs: args.slice(1), + legacyCommand: 'current-timestamp', + legacyArgs: args.slice(1), + cwd, + raw, + error, + output: core.output, + }); + if (!handled) commands.cmdCurrentTimestamp(args[1] || 'full', raw); break; } @@ -639,38 +774,112 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand } case 'config-ensure-section': { - config.cmdConfigEnsureSection(cwd, raw); + // Phase 6 (#3575): dispatch via SDK executeForCjs. The catalog rebinds + // 'config-ensure-section' to configNewProject in + // sdk/src/query/command-static-catalog-foundation.ts, restoring the + // legacy "no-arg full default init" contract on the SDK path + // (configEnsureSection itself stays available as an unbound single- + // section helper for future SDK callers). + const handled = _dispatchNonFamily({ + registryCommand: 'config-ensure-section', + registryArgs: args.slice(1), + legacyCommand: 'config-ensure-section', + legacyArgs: args.slice(1), + cwd, + raw, + error, + output: core.output, + }); + if (!handled) config.cmdConfigEnsureSection(cwd, raw); break; } case 'config-set': { - config.cmdConfigSet(cwd, args[1], args[2], raw); + // Phase 6 (#3575): dispatch via SDK executeForCjs when available. + const handled = _dispatchNonFamily({ + registryCommand: 'config-set', + registryArgs: args.slice(1), + legacyCommand: 'config-set', + legacyArgs: args.slice(1), + cwd, + raw, + error, + output: core.output, + }); + if (!handled) config.cmdConfigSet(cwd, args[1], args[2], raw); break; } case "config-set-model-profile": { - config.cmdConfigSetModelProfile(cwd, args[1], raw); + // Phase 6 (#3575): dispatch via SDK executeForCjs when available. + const handled = _dispatchNonFamily({ + registryCommand: 'config-set-model-profile', + registryArgs: args.slice(1), + legacyCommand: 'config-set-model-profile', + legacyArgs: args.slice(1), + cwd, + raw, + error, + output: core.output, + }); + if (!handled) config.cmdConfigSetModelProfile(cwd, args[1], raw); break; } case 'config-get': { - config.cmdConfigGet(cwd, args[1], raw, defaultValue); + // Phase 6 (#3575): dispatch via SDK executeForCjs when available. + // The SDK handler supports --default via the registry args (args.slice(1) + // contains the key; defaultValue is handled by the SDK via the --default + // flag which was already stripped from args and held in defaultValue). + // Pass the full original args.slice(1) so the SDK sees the key; the + // defaultValue from the flag is in the global defaultValue variable above. + // Since the SDK handler reads --default from registryArgs, re-inject it. + const configGetSdkArgs = defaultValue !== undefined + ? [args[1], '--default', defaultValue] + : args.slice(1); + const handled = _dispatchNonFamily({ + registryCommand: 'config-get', + registryArgs: configGetSdkArgs, + legacyCommand: 'config-get', + legacyArgs: args.slice(1), + cwd, + raw, + error, + output: core.output, + }); + if (!handled) config.cmdConfigGet(cwd, args[1], raw, defaultValue); break; } case 'config-new-project': { - config.cmdConfigNewProject(cwd, args[1], raw); + // Phase 6 (#3575): dispatch via SDK executeForCjs when available. + const handled = _dispatchNonFamily({ + registryCommand: 'config-new-project', + registryArgs: args.slice(1), + legacyCommand: 'config-new-project', + legacyArgs: args.slice(1), + cwd, + raw, + error, + output: core.output, + }); + if (!handled) config.cmdConfigNewProject(cwd, args[1], raw); break; } case 'config-path': { + // CJS-native: config-path returns the filesystem path to config.json. + // The SDK handler (configPath) also exists but requires a projectDir that + // is already resolved. Both produce identical output; keeping CJS here is + // simpler and avoids sync-bridge overhead for a trivial path lookup. config.cmdConfigPath(cwd, raw); break; } case 'migrate-config': { - // Explicit on-disk migration of legacy config keys to canonical shape (#3536). - // Wraps Configuration Module migrateOnDisk(); idempotent. async — must await. + // CJS-native: migrate-config wraps the Configuration Module migrateOnDisk() + // which is async and mutates the filesystem. No SDK counterpart exists in + // the command registry (it's a one-shot migration utility). Must await. await config.cmdMigrateConfig(cwd, raw); break; } @@ -1077,7 +1286,19 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand // ─── Documentation ──────────────────────────────────────────────────── case 'docs-init': { - docs.cmdDocsInit(cwd, raw); + // Phase 6 (#3575): dispatch via SDK executeForCjs when available. + // SDK handler: docsInit in sdk/src/query/docs-init.ts. + const handled = _dispatchNonFamily({ + registryCommand: 'docs-init', + registryArgs: args.slice(1), + legacyCommand: 'docs-init', + legacyArgs: args.slice(1), + cwd, + raw, + error, + output: core.output, + }); + if (!handled) docs.cmdDocsInit(cwd, raw); break; } @@ -1110,6 +1331,11 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand } // ─── detect-custom-files ─────────────────────────────────────────────── + // CJS-native: no SDK counterpart exists in the command registry. + // detect-custom-files reads a gsd-file-manifest.json against the + // live filesystem to identify user-added files. It is installer-specific + // logic that has no async query equivalent in the SDK. + // // Detect user-added files inside GSD-managed directories that are not // tracked in gsd-file-manifest.json. Used by the update workflow to back // up custom files before the installer wipes those directories. diff --git a/get-shit-done/bin/lib/cjs-sdk-bridge.cjs b/get-shit-done/bin/lib/cjs-sdk-bridge.cjs new file mode 100644 index 000000000..0e9ef003f --- /dev/null +++ b/get-shit-done/bin/lib/cjs-sdk-bridge.cjs @@ -0,0 +1,136 @@ +'use strict'; + +/** + * CJS↔SDK Sync Runtime Bridge Adapter — Phase 5/6 of #3524. + * + * Single shared loader for the synchronous SDK runtime bridge that every CJS + * command-router family file and `gsd-tools.cjs` non-family dispatcher + * delegates through. Centralizing the load prevents the seven-fold duplicated + * `tryLoadSdk` blocks that existed across the routers from drifting against + * each other (the exact anti-pattern the Phase 6 hand-sync lint is meant to + * stop, applied to the SDK-load logic itself). + * + * Load path policy: the bridge resolves the bundled SDK by package-relative + * filesystem path, NOT by the `@gsd-build/sdk` package name. The package name + * is not installed in the root `node_modules` (it lives as a sibling workspace + * package, not a dependency), and the SDK's public entry doesn't re-export + * `executeForCjs` or `formatStateLoadRawStdout` anyway. Using the relative + * path means the loader works identically in (a) the development checkout + * (`/sdk/dist/...`) and (b) the published package layout + * (`node_modules/get-shit-done-cc/sdk/dist/...`) because the `files` array in + * `package.json` keeps `sdk/dist` at the same path inside the published + * tarball. + * + * The previous implementation used `require('@gsd-build/sdk')`, which always + * failed because the package was unresolvable from the consumer location. + * That cached `_loadFailed = true` for the lifetime of the process and made + * every router silently fall through to CJS — defeating Phase 5/6's entire + * goal. The integration test at `tests/cjs-sdk-bridge-integration.test.cjs` + * locks the load-success invariant so this regression cannot recur. + * + * Usage: + * const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs'); + * if (tryLoadSdk()) { + * const result = getExecuteForCjs()({ ... }); + * } + * + * Plus `getFormatStateLoadRawStdout()` for the `state load --raw` adapter and + * `getSdkModule()` for routers that need the raw runtime-bridge-sync module. + */ + +const path = require('path'); + +// Computed once at module load. Resolves the bundled SDK relative to this +// file's on-disk location, so both dev and post-install layouts work. +// /get-shit-done/bin/lib/cjs-sdk-bridge.cjs +// /sdk/dist/runtime-bridge-sync/index.js +// /sdk/dist/query/state-project-load.js +const RUNTIME_BRIDGE_PATH = path.resolve( + __dirname, + '..', + '..', + '..', + 'sdk', + 'dist', + 'runtime-bridge-sync', + 'index.js', +); +const STATE_PROJECT_LOAD_PATH = path.resolve( + __dirname, + '..', + '..', + '..', + 'sdk', + 'dist', + 'query', + 'state-project-load.js', +); + +let _runtimeBridge = null; +let _formatStateLoadRawStdout = null; +let _loadFailed = false; + +/** + * Load the bundled SDK runtime bridge once and cache the result. Returns true + * on success, false if the dist artifacts are missing (e.g. `npm run + * build:sdk` has not been executed in a fresh dev checkout) or if the + * expected `executeForCjs` export is absent. Cached result is reused on + * subsequent calls. + */ +function tryLoadSdk() { + if (_runtimeBridge) return true; + if (_loadFailed) return false; + try { + // eslint-disable-next-line global-require + const bridge = require(RUNTIME_BRIDGE_PATH); + if (typeof bridge.executeForCjs !== 'function') { + _loadFailed = true; + return false; + } + // eslint-disable-next-line global-require + const stateProjectLoad = require(STATE_PROJECT_LOAD_PATH); + if (typeof stateProjectLoad.formatStateLoadRawStdout !== 'function') { + _loadFailed = true; + return false; + } + _runtimeBridge = bridge; + _formatStateLoadRawStdout = stateProjectLoad.formatStateLoadRawStdout; + return true; + } catch { + _loadFailed = true; + return false; + } +} + +/** + * Returns the cached `executeForCjs` function, or null if `tryLoadSdk()` has + * not been called or returned false. Callers must check `tryLoadSdk()` first. + */ +function getExecuteForCjs() { + return _runtimeBridge ? _runtimeBridge.executeForCjs : null; +} + +/** + * Returns the cached `formatStateLoadRawStdout` function, or null. Used by + * the state command router for the `state load --raw` adapter that projects + * SDK return data into the legacy key=value lines format. + */ +function getFormatStateLoadRawStdout() { + return _formatStateLoadRawStdout; +} + +/** + * Returns the cached runtime-bridge-sync module object after a successful + * `tryLoadSdk()`, or null. Provided for callers that need additional named + * exports beyond `executeForCjs`. + */ +function getSdkModule() { + return _runtimeBridge; +} + +module.exports = { + tryLoadSdk, + getExecuteForCjs, + getFormatStateLoadRawStdout, + getSdkModule, +}; diff --git a/get-shit-done/bin/lib/command-aliases.generated.cjs b/get-shit-done/bin/lib/command-aliases.generated.cjs index ce67b8146..7c48ef134 100644 --- a/get-shit-done/bin/lib/command-aliases.generated.cjs +++ b/get-shit-done/bin/lib/command-aliases.generated.cjs @@ -230,14 +230,6 @@ const VERIFY_COMMAND_ALIASES = [ ], "subcommand": "schema-drift", "mutation": false - }, - { - "canonical": "verify.codebase-drift", - "aliases": [ - "verify codebase-drift" - ], - "subcommand": "codebase-drift", - "mutation": false } ]; @@ -651,20 +643,6 @@ const NON_FAMILY_COMMAND_ALIASES = [ "aliases": [], "mutation": true }, - { - "canonical": "intel.patch-meta", - "aliases": [ - "intel patch-meta" - ], - "mutation": true - }, - { - "canonical": "intel.snapshot", - "aliases": [ - "intel snapshot" - ], - "mutation": true - }, { "canonical": "learnings.copy", "aliases": [ @@ -835,4 +813,4 @@ module.exports = { PHASES_SUBCOMMANDS, VALIDATE_SUBCOMMANDS, ROADMAP_SUBCOMMANDS, -}; +}; \ No newline at end of file diff --git a/get-shit-done/bin/lib/decisions.cjs b/get-shit-done/bin/lib/decisions.cjs index c71a6c2e4..68e3ee959 100644 --- a/get-shit-done/bin/lib/decisions.cjs +++ b/get-shit-done/bin/lib/decisions.cjs @@ -1,48 +1,19 @@ 'use strict'; /** - * Shared parser for CONTEXT.md `` blocks. + * Decisions Module — CJS adapter. * - * Used by: - * - gap-checker.cjs (#2493 post-planning gap analysis) - * - intended for #2492 (plan-phase decision gate, verify-phase decision validator) + * The implementation is generated from sdk/src/query/decisions.ts and + * lives in decisions.generated.cjs. This file is a thin re-export so + * that existing call sites (gap-checker.cjs, tests) can continue to + * require('./decisions') unchanged. * - * Format produced by discuss-phase.md: + * Exports (from generated file): + * - parseDecisions(content) — parse blocks, returns {id, text, category, tags, trackable}[] + * CJS callers using only {id, text} safely ignore the extra fields. + * Accepts both numeric (D-42) and alphanumeric (D-INFRA-01) IDs. * - * - * ## Implementation Decisions - * - * ### Category - * - **D-01:** Decision text - * - **D-02:** Another decision - * - * - * D-IDs outside the block are ignored. Missing block returns []. + * Regenerate: cd sdk && npm run gen:decisions */ -/** - * Parse the section of a CONTEXT.md string. - * - * @param {string|null|undefined} contextMd - File contents, may be empty/missing. - * @returns {Array<{id: string, text: string}>} - */ -function parseDecisions(contextMd) { - if (!contextMd || typeof contextMd !== 'string') return []; - const blockMatch = contextMd.match(/([\s\S]*?)<\/decisions>/); - if (!blockMatch) return []; - const block = blockMatch[1]; - - const decisionRe = /^\s*-\s*\*\*(D-[A-Za-z0-9_-]+):\*\*\s*(.+?)\s*$/gm; - const out = []; - const seen = new Set(); - let m; - while ((m = decisionRe.exec(block)) !== null) { - const id = m[1]; - if (seen.has(id)) continue; - seen.add(id); - out.push({ id, text: m[2] }); - } - return out; -} - -module.exports = { parseDecisions }; +module.exports = require('./decisions.generated.cjs'); diff --git a/get-shit-done/bin/lib/decisions.generated.cjs b/get-shit-done/bin/lib/decisions.generated.cjs new file mode 100644 index 000000000..efb2c3f13 --- /dev/null +++ b/get-shit-done/bin/lib/decisions.generated.cjs @@ -0,0 +1,121 @@ +'use strict'; + +/** + * GENERATED FILE — DO NOT EDIT. + * + * Source: sdk/src/query/decisions.ts + * Regenerate: cd sdk && npm run gen:decisions + * + * Shared parser for CONTEXT.md blocks. + * Accepts both numeric (D-42) and alphanumeric (D-INFRA-01) IDs. + * Returns {id, text, category, tags, trackable} per decision. + * CJS callers that only use {id, text} safely ignore the extra fields. + */ + +const DISCRETION_HEADINGS = new Set([ + "claude's discretion", + 'claudes discretion', + 'claude discretion', +]); +const NON_TRACKABLE_TAGS = new Set(['informational', 'folded', 'deferred']); +/** + * Strip fenced code blocks from `content` so example `` snippets + * inside ```` ``` ```` do not pollute the parser (review F11). + */ +function stripFencedCode(content) { + return content.replace(/```[\s\S]*?```/g, ' ').replace(/~~~[\s\S]*?~~~/g, ' '); +} +/** + * Extract the inner text of EVERY `...` block in + * order, concatenated by `\n\n`. Returns null when no block is present. + * + * CONTEXT.md may legitimately contain more than one block (for example, a + * "current decisions" block plus a "carry-over from prior phase" block); + * dropping all-but-the-first silently lost the second batch (review F13). + */ +function extractDecisionsBlock(content) { + const cleaned = stripFencedCode(content); + const matches = [...cleaned.matchAll(/([\s\S]*?)<\/decisions>/g)]; + if (matches.length === 0) + return null; + return matches.map((m) => m[1]).join('\n\n'); +} +/** + * Parse trackable decisions from CONTEXT.md content. + * + * Returns ALL D-NN decisions found inside `` (including + * non-trackable ones, with `trackable: false`). Callers that only want the + * gate-enforced decisions should filter `.filter(d => d.trackable)`. + */ +function parseDecisions(content) { + if (!content || typeof content !== 'string') + return []; + const block = extractDecisionsBlock(content); + if (block === null) + return []; + const lines = block.split(/\r?\n/); + const out = []; + let category = ''; + let inDiscretion = false; + // Bullet line: `- **D-NN[ [tags]]:** text` + // Phase 6 (#3575): aligned to CJS regex — accepts alphanumeric IDs (D-01, D-INFRA-01, D-FOO_BAR) + // in addition to numeric-only IDs (D-42). The first character after `D-` must + // be alphanumeric, so malformed shapes like `D--foo` or `D-_bar` are rejected. + // CJS callers consume {id, text} and ignore the optional extras. + const bulletRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?\s*:\*\*\s*(.*)$/; + let current = null; + const flush = () => { + if (current) { + current.text = current.text.trim(); + out.push(current); + current = null; + } + }; + for (const line of lines) { + const trimmed = line.trim(); + // Track category headings (`### Heading`) + const headingMatch = trimmed.match(/^###\s+(.+?)\s*$/); + if (headingMatch) { + flush(); + category = headingMatch[1]; + // Strip the full unicode-quote family so any rendering of "Claude's + // Discretion" (ASCII apostrophe, curly U+2019, U+2018, U+201A, U+201B, + // double-quote variants U+201C/D/E/F, etc.) collapses to the same key + // (review F20). + const normalized = category + .toLowerCase() + .replace(/[\u2018\u2019\u201A\u201B\u201C\u201D\u201E\u201F'"`]/g, '') + .trim(); + inDiscretion = DISCRETION_HEADINGS.has(normalized); + continue; + } + const bulletMatch = line.match(bulletRe); + if (bulletMatch) { + flush(); + const id = `D-${bulletMatch[1]}`; + const tags = bulletMatch[2] + ? bulletMatch[2] + .split(',') + .map((t) => t.trim().toLowerCase()) + .filter(Boolean) + : []; + const trackable = !inDiscretion && !tags.some((t) => NON_TRACKABLE_TAGS.has(t)); + current = { id, text: bulletMatch[3], category, tags, trackable }; + continue; + } + // Continuation line for current decision (indented with space OR tab, + // non-bullet, non-empty) — tab indentation must work too (review F12). + if (current && trimmed !== '' && !trimmed.startsWith('-') && /^[ \t]/.test(line)) { + current.text += ' ' + trimmed; + continue; + } + // Blank line or unrelated content terminates the current decision + if (trimmed === '') { + flush(); + } + } + flush(); + return out; +} + +module.exports = { parseDecisions }; diff --git a/get-shit-done/bin/lib/init-command-router.cjs b/get-shit-done/bin/lib/init-command-router.cjs index b756311e7..ec21ebfd3 100644 --- a/get-shit-done/bin/lib/init-command-router.cjs +++ b/get-shit-done/bin/lib/init-command-router.cjs @@ -1,68 +1,172 @@ 'use strict'; const { INIT_SUBCOMMANDS } = require('./command-aliases.generated.cjs'); +const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs'); +const { output } = require('./core.cjs'); +// ─── SDK bridge (Phase 6) — shared loader via cjs-sdk-bridge.cjs ────────────── +const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs'); + +/** + * Manifest-backed init subcommand router. + * Keeps gsd-tools.cjs thin while preserving existing command semantics. + * + * Phase 6: all init.* subcommands have SDK equivalents and are dispatched + * via executeForCjs (the sync bridge). CJS fallback retained when: + * - GSD_WORKSTREAM is active (workstream-scoped requests fall through to CJS). + * - SDK is unavailable (build not present). + * + * CJS-only subcommands: none. + * SDK-only (unsupported in CJS router): none. + */ function routeInitCommand({ init, args, cwd, raw, parseNamedArgs, error }) { - const workflow = args[1]; - switch (workflow) { - case 'execute-phase': { - const { validate: epValidate, tdd: epTdd } = parseNamedArgs(args, [], ['validate', 'tdd']); - init.cmdInitExecutePhase(cwd, args[2], raw, { validate: epValidate, tdd: epTdd }); - break; - } - case 'plan-phase': { - const { validate: ppValidate, tdd: ppTdd } = parseNamedArgs(args, [], ['validate', 'tdd']); - init.cmdInitPlanPhase(cwd, args[2], raw, { validate: ppValidate, tdd: ppTdd }); - break; - } - case 'new-project': - init.cmdInitNewProject(cwd, raw); - break; - case 'new-milestone': - init.cmdInitNewMilestone(cwd, raw); - break; - case 'quick': - init.cmdInitQuick(cwd, args.slice(2).join(' '), raw); - break; - case 'ingest-docs': - init.cmdInitIngestDocs(cwd, raw); - break; - case 'resume': - init.cmdInitResume(cwd, raw); - break; - case 'verify-work': - init.cmdInitVerifyWork(cwd, args[2], raw); - break; - case 'phase-op': - init.cmdInitPhaseOp(cwd, args[2], raw); - break; - case 'todos': - init.cmdInitTodos(cwd, args[2], raw); - break; - case 'milestone-op': - init.cmdInitMilestoneOp(cwd, raw); - break; - case 'map-codebase': - init.cmdInitMapCodebase(cwd, raw); - break; - case 'progress': - init.cmdInitProgress(cwd, raw); - break; - case 'manager': - init.cmdInitManager(cwd, raw); - break; - case 'new-workspace': - init.cmdInitNewWorkspace(cwd, raw); - break; - case 'list-workspaces': - init.cmdInitListWorkspaces(cwd, raw); - break; - case 'remove-workspace': - init.cmdInitRemoveWorkspace(cwd, args[2], raw); - break; - default: - error(`Unknown init workflow: ${workflow}\nAvailable: ${INIT_SUBCOMMANDS.join(', ')}`); + const activeWorkstream = process.env.GSD_WORKSTREAM; + const sdkAvailable = !activeWorkstream && tryLoadSdk(); + + function sdkHandler(registryCommand, registryArgs, legacyArgs, cjsFallback) { + if (!sdkAvailable) return cjsFallback; + return () => { + const result = getExecuteForCjs()({ + registryCommand, + registryArgs, + legacyCommand: 'init', + legacyArgs, + // #3631: under --raw, request mode:'raw' so the bridge runs the SDK's + // raw projection (formatQueryRawOutput) and returns the scalar string + // CJS callers used to print. We then bypass output()'s JSON-stringify + // path by passing rawValue (the third positional). With mode:'json', + // output() emits the JSON IR as before. + mode: raw ? 'raw' : 'json', + projectDir: cwd, + }); + if (!result.ok) { + error(result.errorDetails && result.errorDetails.message + ? result.errorDetails.message + : `init ${registryCommand} failed (${result.errorKind})`); + return; + } + if (raw) { + output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? '')); + } else { + output(result.data); + } + }; } + + routeCjsCommandFamily({ + args, + subcommands: INIT_SUBCOMMANDS, + unsupported: {}, + error, + unknownMessage: (_subcommand, available) => `Unknown init workflow: ${_subcommand}\nAvailable: ${available.join(', ')}`, + handlers: { + 'execute-phase': sdkHandler( + 'init.execute-phase', + args.slice(2), + args.slice(1), + () => { + const { validate: epValidate, tdd: epTdd } = parseNamedArgs(args, [], ['validate', 'tdd']); + init.cmdInitExecutePhase(cwd, args[2], raw, { validate: epValidate, tdd: epTdd }); + }, + ), + 'plan-phase': sdkHandler( + 'init.plan-phase', + args.slice(2), + args.slice(1), + () => { + const { validate: ppValidate, tdd: ppTdd } = parseNamedArgs(args, [], ['validate', 'tdd']); + init.cmdInitPlanPhase(cwd, args[2], raw, { validate: ppValidate, tdd: ppTdd }); + }, + ), + 'new-project': sdkHandler( + 'init.new-project', + args.slice(2), + args.slice(1), + () => init.cmdInitNewProject(cwd, raw), + ), + 'new-milestone': sdkHandler( + 'init.new-milestone', + args.slice(2), + args.slice(1), + () => init.cmdInitNewMilestone(cwd, raw), + ), + quick: sdkHandler( + 'init.quick', + args.slice(2), + args.slice(1), + () => init.cmdInitQuick(cwd, args.slice(2).join(' '), raw), + ), + 'ingest-docs': sdkHandler( + 'init.ingest-docs', + args.slice(2), + args.slice(1), + () => init.cmdInitIngestDocs(cwd, raw), + ), + resume: sdkHandler( + 'init.resume', + args.slice(2), + args.slice(1), + () => init.cmdInitResume(cwd, raw), + ), + 'verify-work': sdkHandler( + 'init.verify-work', + args.slice(2), + args.slice(1), + () => init.cmdInitVerifyWork(cwd, args[2], raw), + ), + 'phase-op': sdkHandler( + 'init.phase-op', + args.slice(2), + args.slice(1), + () => init.cmdInitPhaseOp(cwd, args[2], raw), + ), + todos: sdkHandler( + 'init.todos', + args.slice(2), + args.slice(1), + () => init.cmdInitTodos(cwd, args[2], raw), + ), + 'milestone-op': sdkHandler( + 'init.milestone-op', + args.slice(2), + args.slice(1), + () => init.cmdInitMilestoneOp(cwd, raw), + ), + 'map-codebase': sdkHandler( + 'init.map-codebase', + args.slice(2), + args.slice(1), + () => init.cmdInitMapCodebase(cwd, raw), + ), + progress: sdkHandler( + 'init.progress', + args.slice(2), + args.slice(1), + () => init.cmdInitProgress(cwd, raw), + ), + // Keep manager on CJS for now so runtime-specific command rendering + // (e.g. $gsd-* for codex) stays consistent with runtime-slash helpers. + manager: () => init.cmdInitManager(cwd, raw), + 'new-workspace': sdkHandler( + 'init.new-workspace', + args.slice(2), + args.slice(1), + () => init.cmdInitNewWorkspace(cwd, raw), + ), + 'list-workspaces': sdkHandler( + 'init.list-workspaces', + args.slice(2), + args.slice(1), + () => init.cmdInitListWorkspaces(cwd, raw), + ), + 'remove-workspace': sdkHandler( + 'init.remove-workspace', + args.slice(2), + args.slice(1), + () => init.cmdInitRemoveWorkspace(cwd, args[2], raw), + ), + }, + }); } module.exports = { diff --git a/get-shit-done/bin/lib/phase-command-router.cjs b/get-shit-done/bin/lib/phase-command-router.cjs index c3db5f14b..1330cf4bd 100644 --- a/get-shit-done/bin/lib/phase-command-router.cjs +++ b/get-shit-done/bin/lib/phase-command-router.cjs @@ -2,8 +2,61 @@ const { PHASE_SUBCOMMANDS } = require('./command-aliases.generated.cjs'); const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs'); +const { output } = require('./core.cjs'); +// ─── SDK bridge (Phase 6) — shared loader via cjs-sdk-bridge.cjs ────────────── +const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs'); + +/** + * Manifest-backed phase subcommand router. + * Keeps gsd-tools.cjs thin while preserving existing command semantics. + * + * Phase 6: all CJS-handled phase subcommands are dispatched via executeForCjs + * when the SDK is available. CJS fallback retained when: + * - GSD_WORKSTREAM is active (workstream-scoped requests fall through to CJS). + * - SDK is unavailable (build not present). + * + * SDK-only (unsupported in CJS router): + * - list-plans: SDK-only. + * - list-artifacts: SDK-only. + * - scaffold: routed through top-level scaffold command. + * + * CJS-only subcommands: none. + */ function routePhaseCommand({ phase, args, cwd, raw, error }) { + const activeWorkstream = process.env.GSD_WORKSTREAM; + const sdkAvailable = !activeWorkstream && tryLoadSdk(); + + function sdkHandler(registryCommand, registryArgs, legacyArgs, cjsFallback) { + if (!sdkAvailable) return cjsFallback; + return () => { + // #3631: under --raw, request mode:'raw' so the bridge runs the SDK's + // raw projection (formatQueryRawOutput) and returns the scalar string + // CJS callers used to print. We then bypass output()'s JSON-stringify + // path by passing rawValue (the third positional). With mode:'json', + // output() emits the JSON IR as before. + const result = getExecuteForCjs()({ + registryCommand, + registryArgs, + legacyCommand: 'phase', + legacyArgs, + mode: raw ? 'raw' : 'json', + projectDir: cwd, + }); + if (!result.ok) { + error(result.errorDetails && result.errorDetails.message + ? result.errorDetails.message + : `phase ${registryCommand} failed (${result.errorKind})`); + return; + } + if (raw) { + output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? '')); + } else { + output(result.data); + } + }; + } + routeCjsCommandFamily({ args, subcommands: PHASE_SUBCOMMANDS, @@ -16,77 +69,115 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) { unknownMessage: (_subcommand, available) => `Unknown phase subcommand. Available: ${available.join(', ')}`, handlers: { 'mvp-mode': () => phase.cmdPhaseMvpMode(cwd, args.slice(2), raw), - 'next-decimal': () => phase.cmdPhaseNextDecimal(cwd, args[2], raw), - add: () => { - let customId = null; - const descArgs = []; - for (let i = 2; i < args.length; i++) { - const token = args[i]; - if (token === '--raw') { - continue; - } - if (token === '--id') { - const id = args[i + 1]; - if (!id || id.startsWith('--')) { - error('--id requires a value'); + 'next-decimal': sdkHandler( + 'phase.next-decimal', + args.slice(2), + args.slice(1), + () => phase.cmdPhaseNextDecimal(cwd, args[2], raw), + ), + add: sdkHandler( + 'phase.add', + args.slice(2), + args.slice(1), + () => { + let customId = null; + const descArgs = []; + for (let i = 2; i < args.length; i++) { + const token = args[i]; + if (token === '--raw') { + continue; + } + if (token === '--id') { + const id = args[i + 1]; + if (!id || id.startsWith('--')) { + error('--id requires a value'); + return; + } + customId = id; + i++; + } else if (token.startsWith('--')) { + error(`phase add does not support ${token}`); + return; + } else { + descArgs.push(token); + } + } + phase.cmdPhaseAdd(cwd, descArgs.join(' '), raw, customId); + }, + ), + 'add-batch': sdkHandler( + 'phase.add-batch', + args.slice(2), + args.slice(1), + () => { + const descFlagIdx = args.indexOf('--descriptions'); + let descriptions; + if (descFlagIdx !== -1) { + const rawDescriptions = args[descFlagIdx + 1]; + if (!rawDescriptions || rawDescriptions.startsWith('--')) { + error('--descriptions must be a JSON array'); + return; + } + try { + descriptions = JSON.parse(rawDescriptions); + } catch { + error('--descriptions must be a JSON array'); + return; + } + if (!Array.isArray(descriptions)) { + error('--descriptions must be a JSON array'); + return; } - customId = id; - i++; - } else if (token.startsWith('--')) { - error(`phase add does not support ${token}`); } else { - descArgs.push(token); + descriptions = args.slice(2).filter(a => a !== '--raw'); } - } - phase.cmdPhaseAdd(cwd, descArgs.join(' '), raw, customId); - }, - 'add-batch': () => { - const descFlagIdx = args.indexOf('--descriptions'); - let descriptions; - if (descFlagIdx !== -1) { - const rawDescriptions = args[descFlagIdx + 1]; - if (!rawDescriptions || rawDescriptions.startsWith('--')) { - error('--descriptions must be a JSON array'); + phase.cmdPhaseAddBatch(cwd, descriptions, raw); + }, + ), + insert: sdkHandler( + 'phase.insert', + args.slice(2), + args.slice(1), + () => { + if (args.includes('--dry-run')) { + error('phase insert does not support --dry-run'); + return; } - try { - descriptions = JSON.parse(rawDescriptions); - } catch { - error('--descriptions must be a JSON array'); + phase.cmdPhaseInsert(cwd, args[2], args.slice(3).join(' '), raw); + }, + ), + remove: sdkHandler( + 'phase.remove', + args.slice(2), + args.slice(1), + () => { + const removeArgs = args.slice(2).filter(token => token !== '--raw'); + let forceFlag = false; + const positional = []; + for (const token of removeArgs) { + if (token === '--force') { + forceFlag = true; + continue; + } + if (token.startsWith('--')) { + error(`phase remove does not support ${token}`); + return; + } + positional.push(token); } - if (!Array.isArray(descriptions)) { - error('--descriptions must be a JSON array'); + if (positional.length !== 1) { + error('phase remove accepts exactly one phase number'); + return; } - } else { - descriptions = args.slice(2).filter(a => a !== '--raw'); - } - phase.cmdPhaseAddBatch(cwd, descriptions, raw); - }, - insert: () => { - if (args.includes('--dry-run')) { - error('phase insert does not support --dry-run'); - } - phase.cmdPhaseInsert(cwd, args[2], args.slice(3).join(' '), raw); - }, - remove: () => { - const removeArgs = args.slice(2).filter(token => token !== '--raw'); - let forceFlag = false; - const positional = []; - for (const token of removeArgs) { - if (token === '--force') { - forceFlag = true; - continue; - } - if (token.startsWith('--')) { - error(`phase remove does not support ${token}`); - } - positional.push(token); - } - if (positional.length > 1) { - error('phase remove accepts exactly one phase number'); - } - phase.cmdPhaseRemove(cwd, positional[0], { force: forceFlag }, raw); - }, - complete: () => phase.cmdPhaseComplete(cwd, args[2], raw), + phase.cmdPhaseRemove(cwd, positional[0], { force: forceFlag }, raw); + }, + ), + complete: sdkHandler( + 'phase.complete', + args.slice(2), + args.slice(1), + () => phase.cmdPhaseComplete(cwd, args[2], raw), + ), }, }); } diff --git a/get-shit-done/bin/lib/phases-command-router.cjs b/get-shit-done/bin/lib/phases-command-router.cjs index 724253ddc..84407869b 100644 --- a/get-shit-done/bin/lib/phases-command-router.cjs +++ b/get-shit-done/bin/lib/phases-command-router.cjs @@ -2,34 +2,92 @@ const { PHASES_SUBCOMMANDS } = require('./command-aliases.generated.cjs'); const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs'); +const { output } = require('./core.cjs'); + +// ─── SDK bridge (Phase 6) — shared loader via cjs-sdk-bridge.cjs ────────────── +const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs'); /** * Manifest-backed phases subcommand router. - * Keeps gsd-tools.cjs thin while preserving current CJS semantics: - * - list - * - clear + * Keeps gsd-tools.cjs thin while preserving current CJS semantics. * - * Note: `archive` is currently SDK-only (`phases.archive` handler in SDK query - * registry). CJS `gsd-tools phases` intentionally supports list/clear only. + * Phase 6: phases.list and phases.clear are dispatched via executeForCjs when + * the SDK is available. CJS fallback retained when: + * - GSD_WORKSTREAM is active (workstream-scoped requests fall through to CJS). + * - SDK is unavailable (build not present). + * + * SDK-only (not in CJS router, treated as unknown): + * - archive: `phases archive` is SDK-only (`phases.archive` handler in SDK + * query registry). CJS `gsd-tools phases` intentionally supports list/clear only. + * `archive` is excluded from the subcommands list so it falls through to the + * "unknown subcommand" error path (matching pre-Phase 6 behavior). + * + * CJS-only subcommands: none. */ function routePhasesCommand({ phase, milestone, args, cwd, raw, error }) { + const activeWorkstream = process.env.GSD_WORKSTREAM; + const sdkAvailable = !activeWorkstream && tryLoadSdk(); + + function sdkHandler(registryCommand, registryArgs, legacyArgs, cjsFallback) { + if (!sdkAvailable) return cjsFallback; + return () => { + const result = getExecuteForCjs()({ + registryCommand, + registryArgs, + legacyCommand: 'phases', + legacyArgs, + // #3631: under --raw, request mode:'raw' so the bridge runs the SDK's + // raw projection (formatQueryRawOutput) and returns the scalar string + // CJS callers used to print. We then bypass output()'s JSON-stringify + // path by passing rawValue (the third positional). With mode:'json', + // output() emits the JSON IR as before. + mode: raw ? 'raw' : 'json', + projectDir: cwd, + }); + if (!result.ok) { + error(result.errorDetails && result.errorDetails.message + ? result.errorDetails.message + : `phases ${registryCommand} failed (${result.errorKind})`); + return; + } + if (raw) { + output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? '')); + } else { + output(result.data); + } + }; + } + routeCjsCommandFamily({ args, + // Exclude 'archive' — it's SDK-only and not supported in CJS. Excluding + // from this list causes it to hit the unknownMessage path, preserving the + // pre-Phase 6 error message for callers that pass 'archive'. subcommands: PHASES_SUBCOMMANDS.filter((s) => s !== 'archive'), error, unknownMessage: (_subcommand, available) => `Unknown phases subcommand. Available: ${available.join(', ')}`, handlers: { - list: () => { - const typeIndex = args.indexOf('--type'); - const phaseIndex = args.indexOf('--phase'); - const options = { - type: typeIndex !== -1 ? args[typeIndex + 1] : null, - phase: phaseIndex !== -1 ? args[phaseIndex + 1] : null, - includeArchived: args.includes('--include-archived'), - }; - phase.cmdPhasesList(cwd, options, raw); - }, - clear: () => milestone.cmdPhasesClear(cwd, raw, args.slice(2)), + list: sdkHandler( + 'phases.list', + args.slice(2), + args.slice(1), + () => { + const typeIndex = args.indexOf('--type'); + const phaseIndex = args.indexOf('--phase'); + const options = { + type: typeIndex !== -1 ? args[typeIndex + 1] : null, + phase: phaseIndex !== -1 ? args[phaseIndex + 1] : null, + includeArchived: args.includes('--include-archived'), + }; + phase.cmdPhasesList(cwd, options, raw); + }, + ), + clear: sdkHandler( + 'phases.clear', + args.slice(2), + args.slice(1), + () => milestone.cmdPhasesClear(cwd, raw, args.slice(2)), + ), }, }); } diff --git a/get-shit-done/bin/lib/plan-scan.cjs b/get-shit-done/bin/lib/plan-scan.cjs index 6952f419e..ece997d85 100644 --- a/get-shit-done/bin/lib/plan-scan.cjs +++ b/get-shit-done/bin/lib/plan-scan.cjs @@ -1,138 +1,26 @@ 'use strict'; -/** - * plan-scan — canonical phase-plan scanner (k014) - * - * Single source of truth for detecting plan and summary files in a phase - * directory, replacing four divergent copies in state.cjs, roadmap.cjs, - * init.cjs, and phase.cjs (#3262). - * - * Layout support: - * Flat (pre-#3139): phases//*-PLAN.md, *-SUMMARY.md - * Nested (post-#3139): phases//plans/PLAN--*.md, SUMMARY--*.md - * - * @module plan-scan - */ - -const fs = require('fs'); -const path = require('path'); - -// Excluded derivative files — present alongside real plans but must not be -// counted. OUTLINE exclusion catches both flat (-PLAN-OUTLINE.md) and nested -// (PLAN-NN-OUTLINE.md) forms via a broad -OUTLINE.md$ pattern. The -// pre-bounce pattern is intentionally broad (matches any *.pre-bounce.md) so -// stale bounce files never inflate plan counts (#3257 regression root cause). -const PLAN_OUTLINE_RE = /-OUTLINE\.md$/i; -const PLAN_PRE_BOUNCE_RE = /\.pre-bounce\.md$/i; /** - * Determine whether a filename from the flat phase root is a plan file. + * Plan Scan Module — CJS adapter. * - * Accepts: - * - Bare PLAN.md - * - Canonical padded 01-01-PLAN.md - * - Extended layout 5-PLAN-01-setup.md (the format gsd-plan-phase writes; - * looksLikePlanFile in phase.cjs / isPlanFile in roadmap.cjs) + * The implementation is generated from sdk/src/query/plan-scan.ts and + * lives in plan-scan.generated.cjs. This file is a thin re-export so + * that existing call sites (state.cjs, roadmap.cjs, init.cjs, + * workstream-inventory.cjs, and tests) can continue to require('./plan-scan') + * unchanged. * - * Rejects: -PLAN-OUTLINE.md, *.pre-bounce.md - */ -function isRootPlanFile(f) { - if (PLAN_OUTLINE_RE.test(f)) return false; - if (PLAN_PRE_BOUNCE_RE.test(f)) return false; - // Canonical suffix or bare name - if (f.endsWith('-PLAN.md') || f === 'PLAN.md') return true; - // Extended layout: any .md that contains PLAN (case-insensitive) in the name - return /\.md$/i.test(f) && /PLAN/i.test(f); -} - -/** - * Determine whether a filename from the nested plans/ subdir is a plan file. + * Exports (from generated file): + * - scanPhasePlans(phaseDir) — canonical phase-plan scanner + * - isRootPlanFile(fileName) — extended filter including /PLAN/i slug layouts + * - isNestedPlanFile(fileName) — nested plans/ subdir filter + * - isRootSummaryFile(fileName) — flat summary file filter + * - isNestedSummaryFile(fileName) — nested summary file filter * - * Nested layout names: PLAN-NN-slug.md or N-PLAN-NN-slug.md. - * Excludes OUTLINE and pre-bounce suffixes. - */ -function isNestedPlanFile(f) { - if (PLAN_OUTLINE_RE.test(f)) return false; - if (PLAN_PRE_BOUNCE_RE.test(f)) return false; - return /^PLAN-\d+.*\.md$/i.test(f) || /-PLAN-\d+.*\.md$/i.test(f); -} - -/** - * Determine whether a filename from the flat phase root is a summary file. - */ -function isRootSummaryFile(f) { - return f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'; -} - -/** - * Determine whether a filename from the nested plans/ subdir is a summary. - */ -function isNestedSummaryFile(f) { - return /^SUMMARY-\d+.*\.md$/i.test(f) || /-SUMMARY-\d+.*\.md$/i.test(f); -} - -/** - * Scan a single phase directory for plan and summary files. + * The isRootPlanFile helper uses /PLAN/i to match the extended slug layout + * (e.g. 5-PLAN-01-setup-database.md) in addition to bare and canonical forms. + * This was the fix for bug #3128 (roadmap.cjs plan-count regression). * - * @param {string} phaseDir — absolute path to the phase directory - * @returns {{ - * planCount: number, - * summaryCount: number, - * completed: boolean, - * hasNestedPlans: boolean, - * planFiles: string[], - * summaryFiles: string[], - * }} + * Regenerate: cd sdk && npm run gen:plan-scan */ -function scanPhasePlans(phaseDir) { - let rootFiles; - try { - rootFiles = fs.readdirSync(phaseDir); - } catch { - return { - planCount: 0, - summaryCount: 0, - completed: false, - hasNestedPlans: false, - planFiles: [], - summaryFiles: [], - }; - } - const rootPlanFiles = rootFiles.filter(isRootPlanFile); - const rootSummaryFiles = rootFiles.filter(isRootSummaryFile); - - let nestedPlanFiles = []; - let nestedSummaryFiles = []; - let hasNestedPlans = false; - - const nestedDir = path.join(phaseDir, 'plans'); - if (fs.existsSync(nestedDir)) { - try { - const nested = fs.readdirSync(nestedDir); - nestedPlanFiles = nested.filter(isNestedPlanFile); - nestedSummaryFiles = nested.filter(isNestedSummaryFile); - hasNestedPlans = nestedPlanFiles.length > 0; - } catch { /* ignore if plans/ is not a readable directory */ } - } - - const planFiles = rootPlanFiles.concat(nestedPlanFiles); - const summaryFiles = rootSummaryFiles.concat(nestedSummaryFiles); - const planCount = planFiles.length; - const summaryCount = summaryFiles.length; - - return { - planCount, - summaryCount, - completed: planCount > 0 && summaryCount >= planCount, - hasNestedPlans, - planFiles, - summaryFiles, - }; -} - -module.exports = scanPhasePlans; -module.exports.scanPhasePlans = scanPhasePlans; -module.exports.isRootPlanFile = isRootPlanFile; -module.exports.isNestedPlanFile = isNestedPlanFile; -module.exports.isRootSummaryFile = isRootSummaryFile; -module.exports.isNestedSummaryFile = isNestedSummaryFile; +module.exports = require('./plan-scan.generated.cjs'); diff --git a/get-shit-done/bin/lib/plan-scan.generated.cjs b/get-shit-done/bin/lib/plan-scan.generated.cjs new file mode 100644 index 000000000..e58004a82 --- /dev/null +++ b/get-shit-done/bin/lib/plan-scan.generated.cjs @@ -0,0 +1,97 @@ +'use strict'; + +/** + * GENERATED FILE — DO NOT EDIT. + * + * Source: sdk/src/query/plan-scan.ts + * Regenerate: cd sdk && npm run gen:plan-scan + * + * Plan Scan Module — detects plan and summary files in a phase directory. + * Supports both flat (pre-#3139) and nested (post-#3139) layouts. + */ + +const { existsSync, readdirSync } = require('node:fs'); +const { join } = require('node:path'); + +// Excluded derivative files +const PLAN_OUTLINE_RE = /-OUTLINE\.md$/i; +const PLAN_PRE_BOUNCE_RE = /\.pre-bounce\.md$/i; + +function isRootPlanFile(fileName) { + if (PLAN_OUTLINE_RE.test(fileName)) + return false; + if (PLAN_PRE_BOUNCE_RE.test(fileName)) + return false; + if (fileName.endsWith('-PLAN.md') || fileName === 'PLAN.md') + return true; + return /\.md$/i.test(fileName) && /PLAN/i.test(fileName); +} + +function isNestedPlanFile(fileName) { + if (PLAN_OUTLINE_RE.test(fileName)) + return false; + if (PLAN_PRE_BOUNCE_RE.test(fileName)) + return false; + return /^PLAN-\d+.*\.md$/i.test(fileName) || /-PLAN-\d+.*\.md$/i.test(fileName); +} + +function isRootSummaryFile(fileName) { + return fileName.endsWith('-SUMMARY.md') || fileName === 'SUMMARY.md'; +} + +function isNestedSummaryFile(fileName) { + return /^SUMMARY-\d+.*\.md$/i.test(fileName) || /-SUMMARY-\d+.*\.md$/i.test(fileName); +} + +function scanPhasePlans(phaseDir) { + let rootFiles; + try { + rootFiles = readdirSync(phaseDir); + } + catch { + return { + planCount: 0, + summaryCount: 0, + completed: false, + hasNestedPlans: false, + planFiles: [], + summaryFiles: [], + }; + } + const rootPlanFiles = rootFiles.filter(isRootPlanFile); + const rootSummaryFiles = rootFiles.filter(isRootSummaryFile); + let nestedPlanFiles = []; + let nestedSummaryFiles = []; + let hasNestedPlans = false; + const nestedDir = join(phaseDir, 'plans'); + if (existsSync(nestedDir)) { + try { + const nestedFiles = readdirSync(nestedDir); + nestedPlanFiles = nestedFiles.filter(isNestedPlanFile); + nestedSummaryFiles = nestedFiles.filter(isNestedSummaryFile); + hasNestedPlans = nestedPlanFiles.length > 0; + } + catch { /* ignore unreadable nested layout */ } + } + const planFiles = rootPlanFiles.concat(nestedPlanFiles); + const summaryFiles = rootSummaryFiles.concat(nestedSummaryFiles); + const planCount = planFiles.length; + const summaryCount = summaryFiles.length; + return { + planCount, + summaryCount, + completed: planCount > 0 && summaryCount >= planCount, + hasNestedPlans, + planFiles, + summaryFiles, + }; +} + +// CJS callers do: const scanPhasePlans = require('./plan-scan.cjs') +// and also destructure named exports — support both call styles. +module.exports = scanPhasePlans; +module.exports.scanPhasePlans = scanPhasePlans; +module.exports.isRootPlanFile = isRootPlanFile; +module.exports.isNestedPlanFile = isNestedPlanFile; +module.exports.isRootSummaryFile = isRootSummaryFile; +module.exports.isNestedSummaryFile = isNestedSummaryFile; diff --git a/get-shit-done/bin/lib/roadmap-command-router.cjs b/get-shit-done/bin/lib/roadmap-command-router.cjs index 060443bcb..7f8427f3c 100644 --- a/get-shit-done/bin/lib/roadmap-command-router.cjs +++ b/get-shit-done/bin/lib/roadmap-command-router.cjs @@ -1,21 +1,97 @@ 'use strict'; const { ROADMAP_SUBCOMMANDS } = require('./command-aliases.generated.cjs'); +const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs'); +const { output } = require('./core.cjs'); +// ─── SDK bridge (Phase 6) — shared loader via cjs-sdk-bridge.cjs ────────────── +const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs'); + +/** + * Manifest-backed roadmap subcommand router. + * Keeps gsd-tools.cjs thin while preserving existing command semantics. + * + * Phase 6: all roadmap.* subcommands have SDK equivalents and are dispatched + * via executeForCjs (the sync bridge). CJS fallback retained when: + * - GSD_WORKSTREAM is active (workstream-scoped requests fall through to CJS). + * - SDK is unavailable (build not present). + * + * CJS-only subcommands: none. + * SDK-only (unsupported in CJS router): none. + */ function routeRoadmapCommand({ roadmap, args, cwd, raw, error }) { - const subcommand = args[1]; + const activeWorkstream = process.env.GSD_WORKSTREAM; + // GSD_SDK_NESTED is set by SDK handlers that spawn gsd-tools.cjs as a + // child process (e.g. roadmapAnnotateDependencies). Without this guard + // the child process re-dispatches through the SDK bridge, which spawns + // again, ad infinitum until the synckit 15s timeout fires. Bug #3537 + // annotate-dependencies parity. + const nested = process.env.GSD_SDK_NESTED === '1'; + const sdkAvailable = !activeWorkstream && !nested && tryLoadSdk(); - if (subcommand === 'get-phase') { - roadmap.cmdRoadmapGetPhase(cwd, args[2], raw); - } else if (subcommand === 'analyze') { - roadmap.cmdRoadmapAnalyze(cwd, raw); - } else if (subcommand === 'update-plan-progress') { - roadmap.cmdRoadmapUpdatePlanProgress(cwd, args[2], raw); - } else if (subcommand === 'annotate-dependencies') { - roadmap.cmdRoadmapAnnotateDependencies(cwd, args[2], raw); - } else { - error(`Unknown roadmap subcommand. Available: ${ROADMAP_SUBCOMMANDS.join(', ')}`); + function sdkHandler(registryCommand, registryArgs, legacyArgs, cjsFallback) { + if (!sdkAvailable) return cjsFallback; + return () => { + const result = getExecuteForCjs()({ + registryCommand, + registryArgs, + legacyCommand: 'roadmap', + legacyArgs, + // #3631: under --raw, request mode:'raw' so the bridge runs the SDK's + // raw projection (formatQueryRawOutput) and returns the scalar string + // CJS callers used to print. We then bypass output()'s JSON-stringify + // path by passing rawValue (the third positional). With mode:'json', + // output() emits the JSON IR as before. + mode: raw ? 'raw' : 'json', + projectDir: cwd, + }); + if (!result.ok) { + error(result.errorDetails && result.errorDetails.message + ? result.errorDetails.message + : `roadmap ${registryCommand} failed (${result.errorKind})`); + return; + } + if (raw) { + output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? '')); + } else { + output(result.data); + } + }; } + + routeCjsCommandFamily({ + args, + subcommands: ROADMAP_SUBCOMMANDS, + unsupported: {}, + error, + unknownMessage: (_subcommand, available) => `Unknown roadmap subcommand. Available: ${available.join(', ')}`, + handlers: { + 'get-phase': sdkHandler( + 'roadmap.get-phase', + args.slice(2), + args.slice(1), + () => roadmap.cmdRoadmapGetPhase(cwd, args[2], raw), + ), + analyze: sdkHandler( + 'roadmap.analyze', + args.slice(2), + args.slice(1), + () => roadmap.cmdRoadmapAnalyze(cwd, raw), + ), + 'update-plan-progress': sdkHandler( + 'roadmap.update-plan-progress', + args.slice(2), + args.slice(1), + () => roadmap.cmdRoadmapUpdatePlanProgress(cwd, args[2], raw), + ), + 'annotate-dependencies': sdkHandler( + 'roadmap.annotate-dependencies', + args.slice(2), + args.slice(1), + () => roadmap.cmdRoadmapAnnotateDependencies(cwd, args[2], raw), + ), + }, + }); } module.exports = { diff --git a/get-shit-done/bin/lib/schema-detect.cjs b/get-shit-done/bin/lib/schema-detect.cjs index 40d800eb6..27cca4b16 100644 --- a/get-shit-done/bin/lib/schema-detect.cjs +++ b/get-shit-done/bin/lib/schema-detect.cjs @@ -1,238 +1,21 @@ -/** - * Schema Drift Detection — Detects schema-relevant file changes and verifies - * that the appropriate database push command was executed during a phase. - * - * Prevents false-positive verification when schema files change but no push - * occurs — TypeScript types come from config, not the live database, so - * build/types pass on a broken state. - */ - 'use strict'; -// ─── ORM Patterns ──────────────────────────────────────────────────────────── -// -// Each entry maps a glob-like pattern to an ORM name. Patterns use forward -// slashes internally — Windows backslash paths are normalized before matching. - -const SCHEMA_PATTERNS = [ - // Payload CMS - { pattern: /^src\/collections\/.*\.ts$/, orm: 'payload' }, - { pattern: /^src\/globals\/.*\.ts$/, orm: 'payload' }, - - // Prisma - { pattern: /^prisma\/schema\.prisma$/, orm: 'prisma' }, - { pattern: /^prisma\/schema\/.*\.prisma$/, orm: 'prisma' }, - - // Drizzle - { pattern: /^drizzle\/schema\.ts$/, orm: 'drizzle' }, - { pattern: /^src\/db\/schema\.ts$/, orm: 'drizzle' }, - { pattern: /^drizzle\/.*\.ts$/, orm: 'drizzle' }, - - // Supabase - { pattern: /^supabase\/migrations\/.*\.sql$/, orm: 'supabase' }, - - // TypeORM - { pattern: /^src\/entities\/.*\.ts$/, orm: 'typeorm' }, - { pattern: /^src\/migrations\/.*\.ts$/, orm: 'typeorm' }, -]; - -// ─── Push Commands & Evidence Patterns ─────────────────────────────────────── -// -// For each ORM, the push command that agents should run, plus regex patterns -// that indicate the push was actually executed (matched against execution logs, -// SUMMARY.md content, and git commit messages). - -const ORM_INFO = { - payload: { - pushCommand: 'npx payload migrate', - envHint: 'CI=true PAYLOAD_MIGRATING=true npx payload migrate', - interactiveWarning: 'Payload migrate may require interactive prompts — use CI=true PAYLOAD_MIGRATING=true to suppress', - evidencePatterns: [ - /payload\s+migrate/i, - /PAYLOAD_MIGRATING/, - ], - }, - prisma: { - pushCommand: 'npx prisma db push', - envHint: 'npx prisma db push --accept-data-loss (if destructive changes are intended)', - interactiveWarning: 'Prisma db push may prompt for confirmation on destructive changes — use --accept-data-loss to bypass', - evidencePatterns: [ - /prisma\s+db\s+push/i, - /prisma\s+migrate\s+deploy/i, - /prisma\s+migrate\s+dev/i, - ], - }, - drizzle: { - pushCommand: 'npx drizzle-kit push', - envHint: 'npx drizzle-kit push', - interactiveWarning: null, - evidencePatterns: [ - /drizzle-kit\s+push/i, - /drizzle-kit\s+migrate/i, - ], - }, - supabase: { - pushCommand: 'supabase db push', - envHint: 'supabase db push', - interactiveWarning: 'Supabase db push may require authentication — ensure SUPABASE_ACCESS_TOKEN is set', - evidencePatterns: [ - /supabase\s+db\s+push/i, - /supabase\s+migration\s+up/i, - ], - }, - typeorm: { - pushCommand: 'npx typeorm migration:run', - envHint: 'npx typeorm migration:run -d src/data-source.ts', - interactiveWarning: null, - evidencePatterns: [ - /typeorm\s+migration:run/i, - /typeorm\s+schema:sync/i, - ], - }, -}; - -// ─── Public API ────────────────────────────────────────────────────────────── - /** - * Detect schema-relevant files in a list of file paths. + * Schema Detect Module — CJS adapter. * - * @param {string[]} files - List of file paths (relative to project root) - * @returns {{ detected: boolean, matches: string[], orms: string[] }} - */ -function detectSchemaFiles(files) { - const matches = []; - const orms = new Set(); - - for (const rawFile of files) { - // Normalize Windows backslash paths - const file = rawFile.replace(/\\/g, '/'); - - for (const { pattern, orm } of SCHEMA_PATTERNS) { - if (pattern.test(file)) { - matches.push(rawFile); - orms.add(orm); - break; // One match per file is enough - } - } - } - - return { - detected: matches.length > 0, - matches, - orms: Array.from(orms), - }; -} - -/** - * Get ORM-specific push command info. + * The implementation is generated from sdk/src/query/schema-detect.ts and + * lives in schema-detect.generated.cjs. This file is a thin re-export so + * that existing call sites (verify.cjs and tests) can continue to + * require('./schema-detect') unchanged. * - * @param {string} ormName - ORM identifier (payload, prisma, drizzle, supabase, typeorm) - * @returns {{ pushCommand: string, envHint: string, interactiveWarning: string|null, evidencePatterns: RegExp[] } | null} - */ -function detectSchemaOrm(ormName) { - return ORM_INFO[ormName] || null; -} - -/** - * Check for schema drift: schema files changed but no push evidence found. + * Exports (from generated file): + * - SCHEMA_PATTERNS — ORM file pattern list + * - ORM_INFO — ORM push commands and evidence patterns + * - detectSchemaFiles(files) — detect schema-relevant files + * - detectSchemaOrm(ormName) — get ORM-specific push command info + * - checkSchemaDrift(changedFiles, executionLog, options) — check for drift * - * @param {string[]} changedFiles - Files changed during the phase - * @param {string} executionLog - Combined text from SUMMARY.md, commit messages, and execution logs - * @param {{ skipCheck?: boolean }} [options] - Options - * @returns {{ driftDetected: boolean, blocking: boolean, schemaFiles: string[], orms: string[], unpushedOrms: string[], message: string, skipped?: boolean }} + * Regenerate: cd sdk && npm run gen:schema-detect */ -function checkSchemaDrift(changedFiles, executionLog, options = {}) { - const { skipCheck = false } = options; - const detection = detectSchemaFiles(changedFiles); - - if (!detection.detected) { - return { - driftDetected: false, - blocking: false, - schemaFiles: [], - orms: [], - unpushedOrms: [], - message: '', - }; - } - - // Check which ORMs have push evidence in the execution log - const pushedOrms = new Set(); - const unpushedOrms = []; - - for (const orm of detection.orms) { - const info = ORM_INFO[orm]; - if (!info) continue; - - const hasPushEvidence = info.evidencePatterns.some(p => p.test(executionLog)); - if (hasPushEvidence) { - pushedOrms.add(orm); - } else { - unpushedOrms.push(orm); - } - } - - const driftDetected = unpushedOrms.length > 0; - - if (!driftDetected) { - return { - driftDetected: false, - blocking: false, - schemaFiles: detection.matches, - orms: detection.orms, - unpushedOrms: [], - message: '', - }; - } - - // Build actionable message - const pushCommands = unpushedOrms - .map(orm => { - const info = ORM_INFO[orm]; - return info ? ` ${orm}: ${info.envHint || info.pushCommand}` : null; - }) - .filter(Boolean) - .join('\n'); - - const message = [ - 'Schema drift detected: schema-relevant files changed but no database push was executed.', - '', - `Schema files changed: ${detection.matches.join(', ')}`, - `ORMs requiring push: ${unpushedOrms.join(', ')}`, - '', - 'Required push commands:', - pushCommands, - '', - 'Run the appropriate push command, or set GSD_SKIP_SCHEMA_CHECK=true to bypass this gate.', - ].join('\n'); - - if (skipCheck) { - return { - driftDetected: true, - blocking: false, - skipped: true, - schemaFiles: detection.matches, - orms: detection.orms, - unpushedOrms, - message: 'Schema drift detected but check was skipped (GSD_SKIP_SCHEMA_CHECK=true).', - }; - } - - return { - driftDetected: true, - blocking: true, - schemaFiles: detection.matches, - orms: detection.orms, - unpushedOrms, - message, - }; -} - -module.exports = { - SCHEMA_PATTERNS, - ORM_INFO, - detectSchemaFiles, - detectSchemaOrm, - checkSchemaDrift, -}; +module.exports = require('./schema-detect.generated.cjs'); diff --git a/get-shit-done/bin/lib/schema-detect.generated.cjs b/get-shit-done/bin/lib/schema-detect.generated.cjs new file mode 100644 index 000000000..b1652a6c9 --- /dev/null +++ b/get-shit-done/bin/lib/schema-detect.generated.cjs @@ -0,0 +1,170 @@ +'use strict'; + +/** + * GENERATED FILE — DO NOT EDIT. + * + * Source: sdk/src/query/schema-detect.ts + * Regenerate: cd sdk && npm run gen:schema-detect + * + * Schema Drift Detection — detects schema-relevant file changes and verifies + * that the appropriate database push command was executed during a phase. + * This module does not read the filesystem directly. + */ + +// ─── ORM Patterns ─────────────────────────────────────────────────────────── +const SCHEMA_PATTERNS = [ + { pattern: /^src\/collections\/.*\.ts$/, orm: 'payload' }, + { pattern: /^src\/globals\/.*\.ts$/, orm: 'payload' }, + { pattern: /^prisma\/schema\.prisma$/, orm: 'prisma' }, + { pattern: /^prisma\/schema\/.*\.prisma$/, orm: 'prisma' }, + { pattern: /^drizzle\/schema\.ts$/, orm: 'drizzle' }, + { pattern: /^src\/db\/schema\.ts$/, orm: 'drizzle' }, + { pattern: /^drizzle\/.*\.ts$/, orm: 'drizzle' }, + { pattern: /^supabase\/migrations\/.*\.sql$/, orm: 'supabase' }, + { pattern: /^src\/entities\/.*\.ts$/, orm: 'typeorm' }, + { pattern: /^src\/migrations\/.*\.ts$/, orm: 'typeorm' }, +]; + +// ─── Push Commands & Evidence Patterns ────────────────────────────────────── +const ORM_INFO = { + payload: { + pushCommand: 'npx payload migrate', + envHint: 'CI=true PAYLOAD_MIGRATING=true npx payload migrate', + interactiveWarning: 'Payload migrate may require interactive prompts — use CI=true PAYLOAD_MIGRATING=true to suppress', + evidencePatterns: [/payload\s+migrate/i, /PAYLOAD_MIGRATING/], + }, + prisma: { + pushCommand: 'npx prisma db push', + envHint: 'npx prisma db push --accept-data-loss (if destructive changes are intended)', + interactiveWarning: 'Prisma db push may prompt for confirmation on destructive changes — use --accept-data-loss to bypass', + evidencePatterns: [/prisma\s+db\s+push/i, /prisma\s+migrate\s+deploy/i, /prisma\s+migrate\s+dev/i], + }, + drizzle: { + pushCommand: 'npx drizzle-kit push', + envHint: 'npx drizzle-kit push', + interactiveWarning: null, + evidencePatterns: [/drizzle-kit\s+push/i, /drizzle-kit\s+migrate/i], + }, + supabase: { + pushCommand: 'supabase db push', + envHint: 'supabase db push', + interactiveWarning: 'Supabase db push may require authentication — ensure SUPABASE_ACCESS_TOKEN is set', + evidencePatterns: [/supabase\s+db\s+push/i, /supabase\s+migration\s+up/i], + }, + typeorm: { + pushCommand: 'npx typeorm migration:run', + envHint: 'npx typeorm migration:run -d src/data-source.ts', + interactiveWarning: null, + evidencePatterns: [/typeorm\s+migration:run/i, /typeorm\s+schema:sync/i], + }, +}; + +// ─── Public API ────────────────────────────────────────────────────────────── +function detectSchemaFiles(files) { + const matches = []; + const orms = new Set(); + for (const rawFile of files) { + const file = rawFile.replace(/\\/g, '/'); + for (const { pattern, orm } of SCHEMA_PATTERNS) { + if (pattern.test(file)) { + matches.push(rawFile); + orms.add(orm); + break; + } + } + } + return { + detected: matches.length > 0, + matches, + orms: [...orms], + }; +} + +function detectSchemaOrm(ormName) { + return ORM_INFO[ormName] || null; +} + +function checkSchemaDrift(changedFiles, executionLog, options = {}) { + const { skipCheck = false } = options; + const detection = detectSchemaFiles(changedFiles); + if (!detection.detected) { + return { + driftDetected: false, + blocking: false, + schemaFiles: [], + orms: [], + unpushedOrms: [], + message: '', + }; + } + const pushedOrms = new Set(); + const unpushedOrms = []; + for (const orm of detection.orms) { + const info = ORM_INFO[orm]; + if (!info) + continue; + const hasPushEvidence = info.evidencePatterns.some(p => p.test(executionLog)); + if (hasPushEvidence) { + pushedOrms.add(orm); + } + else { + unpushedOrms.push(orm); + } + } + const driftDetected = unpushedOrms.length > 0; + if (!driftDetected) { + return { + driftDetected: false, + blocking: false, + schemaFiles: detection.matches, + orms: detection.orms, + unpushedOrms: [], + message: '', + }; + } + const pushCommands = unpushedOrms + .map(orm => { + const info = ORM_INFO[orm]; + return info ? ` ${orm}: ${info.envHint || info.pushCommand}` : null; + }) + .filter(Boolean) + .join('\n'); + const message = [ + 'Schema drift detected: schema-relevant files changed but no database push was executed.', + '', + `Schema files changed: ${detection.matches.join(', ')}`, + `ORMs requiring push: ${unpushedOrms.join(', ')}`, + '', + 'Required push commands:', + pushCommands, + '', + 'Run the appropriate push command, or set GSD_SKIP_SCHEMA_CHECK=true to bypass this gate.', + ].join('\n'); + if (skipCheck) { + return { + driftDetected: true, + blocking: false, + skipped: true, + schemaFiles: detection.matches, + orms: detection.orms, + unpushedOrms, + message: 'Schema drift detected but check was skipped (GSD_SKIP_SCHEMA_CHECK=true).', + }; + } + return { + driftDetected: true, + blocking: true, + schemaFiles: detection.matches, + orms: detection.orms, + unpushedOrms, + message, + }; +} + +module.exports = { + SCHEMA_PATTERNS, + ORM_INFO, + detectSchemaFiles, + detectSchemaOrm, + checkSchemaDrift, +}; diff --git a/get-shit-done/bin/lib/secrets.cjs b/get-shit-done/bin/lib/secrets.cjs index 0c1704251..7e28d4bc3 100644 --- a/get-shit-done/bin/lib/secrets.cjs +++ b/get-shit-done/bin/lib/secrets.cjs @@ -1,33 +1,20 @@ 'use strict'; /** - * Secrets handling — masking convention for API keys and other - * credentials managed via /gsd-settings-integrations. + * Secrets Module — CJS adapter. * - * Convention: strings 8+ chars long render as `****`; shorter - * strings render as `****` with no tail (to avoid leaking a meaningful - * fraction of a short secret). null/empty renders as `(unset)`. + * The implementation is generated from sdk/src/query/secrets.ts and + * lives in secrets.generated.cjs. This file is a thin re-export so + * that existing call sites (config.cjs, init.cjs, and tests) can + * continue to require('./secrets') unchanged. * - * Keys considered sensitive are listed in SECRET_CONFIG_KEYS and matched - * at the exact key-path level. The list is intentionally narrow — these - * are the fields documented as secrets in docs/CONFIGURATION.md. + * Exports (from generated file): + * - SECRET_CONFIG_KEYS — Set of secret key paths + * - isSecretKey(keyPath) — returns true if keyPath is a secret + * - maskSecret(value) — masks a secret value + * - maskIfSecret(keyPath, value) — masks value only if keyPath is secret + * + * Regenerate: cd sdk && npm run gen:secrets */ -const SECRET_CONFIG_KEYS = new Set([ - 'brave_search', - 'firecrawl', - 'exa_search', -]); - -function isSecretKey(keyPath) { - return SECRET_CONFIG_KEYS.has(keyPath); -} - -function maskSecret(value) { - if (value === null || value === undefined || value === '') return '(unset)'; - const s = String(value); - if (s.length < 8) return '****'; - return '****' + s.slice(-4); -} - -module.exports = { SECRET_CONFIG_KEYS, isSecretKey, maskSecret }; +module.exports = require('./secrets.generated.cjs'); diff --git a/get-shit-done/bin/lib/secrets.generated.cjs b/get-shit-done/bin/lib/secrets.generated.cjs new file mode 100644 index 000000000..af6ed35c2 --- /dev/null +++ b/get-shit-done/bin/lib/secrets.generated.cjs @@ -0,0 +1,37 @@ +'use strict'; + +/** + * GENERATED FILE — DO NOT EDIT. + * + * Source: sdk/src/query/secrets.ts + * Regenerate: cd sdk && npm run gen:secrets + * + * Secrets handling — masking convention for API keys and other + * credentials managed via /gsd-settings-integrations. + * This module does not read the filesystem. + */ + +const SECRET_CONFIG_KEYS = new Set([ + 'brave_search', + 'firecrawl', + 'exa_search', +]); + +function isSecretKey(keyPath) { + return SECRET_CONFIG_KEYS.has(keyPath); +} + +function maskSecret(value) { + if (value === null || value === undefined || value === '') + return '(unset)'; + const s = String(value); + if (s.length < 8) + return '****'; + return '****' + s.slice(-4); +} + +function maskIfSecret(keyPath, value) { + return isSecretKey(keyPath) ? maskSecret(value) : value; +} + +module.exports = { SECRET_CONFIG_KEYS, isSecretKey, maskSecret, maskIfSecret }; diff --git a/get-shit-done/bin/lib/state-command-router.cjs b/get-shit-done/bin/lib/state-command-router.cjs index 0eadad42a..caca7376d 100644 --- a/get-shit-done/bin/lib/state-command-router.cjs +++ b/get-shit-done/bin/lib/state-command-router.cjs @@ -3,29 +3,25 @@ const { STATE_SUBCOMMANDS } = require('./command-aliases.generated.cjs'); const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs'); const { output } = require('./core.cjs'); +const { + tryLoadSdk, + getExecuteForCjs, + getFormatStateLoadRawStdout, +} = require('./cjs-sdk-bridge.cjs'); -// ─── SDK bridge (Phase 5.1) ───────────────────────────────────────────────── -// executeForCjs is loaded lazily from the SDK public package export so this -// router does not rely on private dist subpaths that are not exported. -let _executeForCjs = null; -let _formatStateLoadRawStdout = null; +// Subcommands whose CJS contract is exit-non-zero (stderr) ONLY when the +// underlying STATE.md is missing — not for in-state errors like +// "field not found". CJS `cmdStateGet` calls `error('STATE.md not found')` → +// exit 1 for the missing-file case but `output({ error: 'Section or field +// "X" not found' }, raw)` → exit 0 for the missing-field case. Mutation +// commands always use output() (exit 0) even when STATE.md is missing, so +// they are absent from this set entirely. +const EXIT_ON_STATE_MD_MISSING = new Set(['state.get']); +const STATE_MD_MISSING_MESSAGE = 'STATE.md not found'; -function tryLoadSdk() { - if (_executeForCjs !== null) return true; - try { - const sdkModule = require('@gsd-build/sdk'); - _executeForCjs = sdkModule.executeForCjs; - _formatStateLoadRawStdout = sdkModule.formatStateLoadRawStdout; - if (typeof _executeForCjs !== 'function' || typeof _formatStateLoadRawStdout !== 'function') { - _executeForCjs = null; - _formatStateLoadRawStdout = null; - return false; - } - return true; - } catch { - return false; - } -} +// The bridge loader verifies both `executeForCjs` and `formatStateLoadRawStdout` +// are present before returning success, so this router can call `tryLoadSdk()` +// directly without an additional capability check. /** * Dispatch a subcommand via the SDK sync bridge. @@ -44,16 +40,25 @@ function tryLoadSdk() { function dispatchViaSdk(registryCommand, registryArgs, legacyArgs, cwd, raw, error, rawFormatter) { if (!tryLoadSdk()) return false; - const result = _executeForCjs({ + // When a CJS-side rawFormatter is supplied (e.g. state.load --raw → key=value + // lines), always request 'json' from the bridge so the SDK returns the typed + // data object. Passing mode: 'raw' would make the bridge pre-render to a + // string and the formatter would no-op. For subcommands without a rawFormatter, + // honor the user's --raw flag and let the bridge do default rendering. + const bridgeMode = rawFormatter ? 'json' : (raw ? 'raw' : 'json'); + + const result = getExecuteForCjs()({ registryCommand, registryArgs, legacyCommand: 'state', legacyArgs, - mode: raw ? 'raw' : 'json', + mode: bridgeMode, projectDir: cwd, - // workstream: not threaded here — GSDTransport forces subprocess for workstream - // requests and subprocess is disabled in the worker. Workstream commands fall - // back to the CJS path (see routeStateCommand guard below). + // Phase 6 fix: workstream is now threaded through to the native handler. + // GSDTransport no longer forces subprocess for workstream-scoped requests — + // the worker's dispatchNative closure correctly passes workstream to + // registry.dispatch() (Phase 5.1 fix), enabling native workstream dispatch. + workstream: process.env.GSD_WORKSTREAM || undefined, }); if (!result.ok) { @@ -63,10 +68,31 @@ function dispatchViaSdk(registryCommand, registryArgs, legacyArgs, cwd, raw, err return true; // handled (error was reported) } + // Surface STATE.md-missing as a CJS-style fatal error (exit non-zero, + // stderr) for the specific subcommands whose CJS contract uses error() not + // output() for that case. The exact "STATE.md not found" message is the + // canonical signal both CJS and SDK use — other "error" shapes (e.g. + // "Section or field X not found" from state.get with present STATE.md) + // stay as exit-0 JSON output so shell-script consumers JSON.parse the + // output and branch on the error field without process-exit handling. + if ( + EXIT_ON_STATE_MD_MISSING.has(registryCommand) + && result.data + && typeof result.data === 'object' + && result.data.error === STATE_MD_MISSING_MESSAGE + ) { + error(result.data.error); + return true; + } + if (raw && rawFormatter) { const rawText = rawFormatter(result.data); const fs = require('fs'); fs.writeSync(1, rawText); + } else if (raw) { + // #3631: bridge was called with mode:'raw', so result.data is the scalar + // string the CJS path would have printed. Bypass output()'s JSON path. + output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? '')); } else { output(result.data); } @@ -94,12 +120,10 @@ function routeStateCommand({ state, args, cwd, raw, parseNamedArgs, error }) { return parsedPlans; }; - // Workstream guard: if GSD_WORKSTREAM is set, the sync bridge worker cannot - // handle the request (GSDTransport.subprocessReason returns 'workstream_forced' - // and subprocess is disabled in the worker). Fall back to CJS path for all - // workstream-scoped state commands. - const activeWorkstream = process.env.GSD_WORKSTREAM; - const sdkAvailable = !activeWorkstream && tryLoadSdk(); + // Phase 6 fix: workstream commands are now handled natively in the sync bridge + // worker. GSDTransport no longer forces subprocess for workstream-scoped requests; + // the worker threads workstream through to registry.dispatch() correctly. + const sdkAvailable = tryLoadSdk(); // Helper: build SDK-backed handler that falls through to CJS on SDK failure. // cjsFallback is called when SDK is unavailable or when the subcommand has no @@ -128,7 +152,11 @@ function routeStateCommand({ state, args, cwd, raw, parseNamedArgs, error }) { 'state.load', [], args.slice(1), - _formatStateLoadRawStdout, + // Resolved lazily — the formatter getter returns null until + // tryLoadSdk() runs inside dispatchViaSdk. sdkHandler only invokes + // this formatter when SDK dispatch succeeds, so by then the bridge + // has cached the formatter and the getter returns the real function. + (...formatterArgs) => getFormatStateLoadRawStdout()(...formatterArgs), () => state.cmdStateLoad(cwd, raw), ), json: sdkHandler( diff --git a/get-shit-done/bin/lib/validate-command-router.cjs b/get-shit-done/bin/lib/validate-command-router.cjs index f97c8e9c1..e38bd8333 100644 --- a/get-shit-done/bin/lib/validate-command-router.cjs +++ b/get-shit-done/bin/lib/validate-command-router.cjs @@ -2,54 +2,126 @@ const { VALIDATE_SUBCOMMANDS } = require('./command-aliases.generated.cjs'); const { formatGsdSlash, resolveRuntime } = require('./runtime-slash.cjs'); +const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs'); +const { output } = require('./core.cjs'); -function routeValidateCommand({ verify, args, cwd, raw, parseNamedArgs, output, error }) { - const subcommand = args[1]; +// ─── SDK bridge (Phase 6) — shared loader via cjs-sdk-bridge.cjs ────────────── +const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs'); - if (subcommand === 'consistency') { - verify.cmdValidateConsistency(cwd, raw); - } else if (subcommand === 'health') { - const repairFlag = args.includes('--repair'); - const backfillFlag = args.includes('--backfill'); - verify.cmdValidateHealth(cwd, { repair: repairFlag, backfill: backfillFlag }, raw); - } else if (subcommand === 'agents') { - verify.cmdValidateAgents(cwd, raw); - } else if (subcommand === 'context') { - const opts = parseNamedArgs(args, ['tokens-used', 'context-window']); - if (opts['tokens-used'] === null) { - error('--tokens-used is required for `validate context`'); - return; - } - if (opts['context-window'] === null) { - error('--context-window is required for `validate context`'); - return; - } - const { classifyContextUtilization, STATES } = require('./context-utilization.cjs'); - const threadCmd = formatGsdSlash('thread', resolveRuntime(cwd)); - const RECOMMENDATIONS = { - [STATES.HEALTHY]: null, - [STATES.WARNING]: `Context is approaching the fracture zone — consider ${threadCmd} to continue in a fresh window.`, - [STATES.CRITICAL]: `Reasoning quality may degrade past 70% utilization (fracture point). Run ${threadCmd} now to preserve output quality.`, +/** + * Manifest-backed validate subcommand router. + * Keeps gsd-tools.cjs thin while preserving existing command semantics. + * + * Phase 6: validate.consistency, validate.health, validate.agents are + * dispatched via executeForCjs when the SDK is available. CJS fallback + * retained when: + * - GSD_WORKSTREAM is active (workstream-scoped requests fall through to CJS). + * - SDK is unavailable (build not present). + * + * CJS-only subcommands: + * - context: complex inline logic using classifyContextUtilization and + * output formatting that has no direct SDK counterpart. Remains CJS-native. + * + * SDK-only (unsupported in CJS router): none. + */ +function routeValidateCommand({ verify, args, cwd, raw, parseNamedArgs, output: outputFn, error }) { + const activeWorkstream = process.env.GSD_WORKSTREAM; + const sdkAvailable = !activeWorkstream && tryLoadSdk(); + + function sdkHandler(registryCommand, registryArgs, legacyArgs, cjsFallback) { + if (!sdkAvailable) return cjsFallback; + return () => { + const result = getExecuteForCjs()({ + registryCommand, + registryArgs, + legacyCommand: 'validate', + legacyArgs, + // #3631: under --raw, request mode:'raw' so the bridge runs the SDK's + // raw projection (formatQueryRawOutput) and returns the scalar string + // CJS callers used to print. We then bypass output()'s JSON-stringify + // path by passing rawValue (the third positional). With mode:'json', + // output() emits the JSON IR as before. + mode: raw ? 'raw' : 'json', + projectDir: cwd, + }); + if (!result.ok) { + error(result.errorDetails && result.errorDetails.message + ? result.errorDetails.message + : `validate ${registryCommand} failed (${result.errorKind})`); + return; + } + if (raw) { + output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? '')); + } else { + output(result.data); + } }; - let classified; - try { - classified = classifyContextUtilization(Number(opts['tokens-used']), Number(opts['context-window'])); - } catch (e) { - const flag = /tokensUsed/.test(e.message) ? '--tokens-used' : '--context-window'; - error(`${flag} must be a non-negative integer (window > 0), got the values supplied`); - return; - } - const result = { ...classified, recommendation: RECOMMENDATIONS[classified.state] }; - if (args.includes('--json')) { - output(result, raw); - } else { - const lines = [`Context utilization: ${result.percent}% (${result.state})`]; - if (result.recommendation) lines.push(result.recommendation); - output(result, true, lines.join('\n')); - } - } else { - error(`Unknown validate subcommand. Available: ${VALIDATE_SUBCOMMANDS.join(', ')}`); } + + routeCjsCommandFamily({ + args, + subcommands: VALIDATE_SUBCOMMANDS, + unsupported: {}, + error, + unknownMessage: (_subcommand, available) => `Unknown validate subcommand. Available: ${available.join(', ')}`, + handlers: { + consistency: sdkHandler( + 'validate.consistency', + args.slice(2), + args.slice(1), + () => verify.cmdValidateConsistency(cwd, raw), + ), + // Keep health on CJS for now so fix hints are rendered via runtime-slash + // helpers (codex expects $gsd-* command shape). + health: () => { + const repairFlag = args.includes('--repair'); + const backfillFlag = args.includes('--backfill'); + verify.cmdValidateHealth(cwd, { repair: repairFlag, backfill: backfillFlag }, raw); + }, + agents: sdkHandler( + 'validate.agents', + args.slice(2), + args.slice(1), + () => verify.cmdValidateAgents(cwd, raw), + ), + // context: CJS-only — complex inline logic using classifyContextUtilization + // with custom output formatting that has no direct SDK counterpart. + context: () => { + const opts = parseNamedArgs(args, ['tokens-used', 'context-window']); + if (opts['tokens-used'] === null) { + error('--tokens-used is required for `validate context`'); + return; + } + if (opts['context-window'] === null) { + error('--context-window is required for `validate context`'); + return; + } + const { classifyContextUtilization, STATES } = require('./context-utilization.cjs'); + const threadCmd = formatGsdSlash('thread', resolveRuntime(cwd)); + const RECOMMENDATIONS = { + [STATES.HEALTHY]: null, + [STATES.WARNING]: `Context is approaching the fracture zone — consider ${threadCmd} to continue in a fresh window.`, + [STATES.CRITICAL]: `Reasoning quality may degrade past 70% utilization (fracture point). Run ${threadCmd} now to preserve output quality.`, + }; + let classified; + try { + classified = classifyContextUtilization(Number(opts['tokens-used']), Number(opts['context-window'])); + } catch (e) { + const flag = /tokensUsed/.test(e.message) ? '--tokens-used' : '--context-window'; + error(`${flag} must be a non-negative integer (window > 0), got the values supplied`); + return; + } + const result = { ...classified, recommendation: RECOMMENDATIONS[classified.state] }; + if (args.includes('--json')) { + outputFn(result, raw); + } else { + const lines = [`Context utilization: ${result.percent}% (${result.state})`]; + if (result.recommendation) lines.push(result.recommendation); + outputFn(result, true, lines.join('\n')); + } + }, + }, + }); } module.exports = { diff --git a/get-shit-done/bin/lib/verify-command-router.cjs b/get-shit-done/bin/lib/verify-command-router.cjs index 806b2ddd0..e42809f54 100644 --- a/get-shit-done/bin/lib/verify-command-router.cjs +++ b/get-shit-done/bin/lib/verify-command-router.cjs @@ -1,32 +1,120 @@ 'use strict'; const { VERIFY_SUBCOMMANDS } = require('./command-aliases.generated.cjs'); +const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs'); +const { output } = require('./core.cjs'); +// ─── SDK bridge (Phase 6) — shared loader via cjs-sdk-bridge.cjs ────────────── +const { tryLoadSdk, getExecuteForCjs } = require('./cjs-sdk-bridge.cjs'); + +/** + * Manifest-backed verify subcommand router. + * Keeps gsd-tools.cjs thin while preserving existing command semantics. + * + * Phase 6: all verify.* subcommands have SDK equivalents and are dispatched + * via executeForCjs (the sync bridge). CJS fallback retained when: + * - GSD_WORKSTREAM is active (workstream-scoped requests fall through to CJS). + * - SDK is unavailable (build not present). + * + * CJS-only subcommands: none. + * SDK-only (unsupported in CJS router): none. + */ function routeVerifyCommand({ verify, args, cwd, raw, error }) { - const subcommand = args[1]; + const activeWorkstream = process.env.GSD_WORKSTREAM; + const sdkAvailable = !activeWorkstream && tryLoadSdk(); - if (subcommand === 'plan-structure') { - verify.cmdVerifyPlanStructure(cwd, args[2], raw); - } else if (subcommand === 'phase-completeness') { - verify.cmdVerifyPhaseCompleteness(cwd, args[2], raw); - } else if (subcommand === 'references') { - verify.cmdVerifyReferences(cwd, args[2], raw); - } else if (subcommand === 'commits') { - verify.cmdVerifyCommits(cwd, args.slice(2), raw); - } else if (subcommand === 'artifacts') { - verify.cmdVerifyArtifacts(cwd, args[2], raw); - } else if (subcommand === 'key-links') { - verify.cmdVerifyKeyLinks(cwd, args[2], raw); - } else if (subcommand === 'schema-drift') { - const rest = args.slice(2); - const skipFlag = rest.includes('--skip'); - const phaseArg = rest.find((arg) => !arg.startsWith('-')); - verify.cmdVerifySchemaDrift(cwd, phaseArg, skipFlag, raw); - } else if (subcommand === 'codebase-drift') { - verify.cmdVerifyCodebaseDrift(cwd, raw); - } else { - error(`Unknown verify subcommand. Available: ${VERIFY_SUBCOMMANDS.join(', ')}`); + function sdkHandler(registryCommand, registryArgs, legacyArgs, cjsFallback) { + if (!sdkAvailable) return cjsFallback; + return () => { + const result = getExecuteForCjs()({ + registryCommand, + registryArgs, + legacyCommand: 'verify', + legacyArgs, + // #3631: under --raw, request mode:'raw' so the bridge runs the SDK's + // raw projection (formatQueryRawOutput) and returns the scalar string + // CJS callers used to print. We then bypass output()'s JSON-stringify + // path by passing rawValue (the third positional). With mode:'json', + // output() emits the JSON IR as before. + mode: raw ? 'raw' : 'json', + projectDir: cwd, + }); + if (!result.ok) { + error(result.errorDetails && result.errorDetails.message + ? result.errorDetails.message + : `verify ${registryCommand} failed (${result.errorKind})`); + return; + } + if (raw) { + output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? '')); + } else { + output(result.data); + } + }; } + + routeCjsCommandFamily({ + args, + subcommands: VERIFY_SUBCOMMANDS, + unsupported: {}, + error, + unknownMessage: (_subcommand, available) => `Unknown verify subcommand. Available: ${available.join(', ')}`, + handlers: { + 'plan-structure': sdkHandler( + 'verify.plan-structure', + args.slice(2), + args.slice(1), + () => verify.cmdVerifyPlanStructure(cwd, args[2], raw), + ), + 'phase-completeness': sdkHandler( + 'verify.phase-completeness', + args.slice(2), + args.slice(1), + () => verify.cmdVerifyPhaseCompleteness(cwd, args[2], raw), + ), + references: sdkHandler( + 'verify.references', + args.slice(2), + args.slice(1), + () => verify.cmdVerifyReferences(cwd, args[2], raw), + ), + commits: sdkHandler( + 'verify.commits', + args.slice(2), + args.slice(1), + () => verify.cmdVerifyCommits(cwd, args.slice(2), raw), + ), + artifacts: sdkHandler( + 'verify.artifacts', + args.slice(2), + args.slice(1), + () => verify.cmdVerifyArtifacts(cwd, args[2], raw), + ), + 'key-links': sdkHandler( + 'verify.key-links', + args.slice(2), + args.slice(1), + () => verify.cmdVerifyKeyLinks(cwd, args[2], raw), + ), + 'schema-drift': sdkHandler( + 'verify.schema-drift', + args.slice(2), + args.slice(1), + () => { + const rest = args.slice(2); + const skipFlag = rest.includes('--skip'); + const phaseArg = rest.find((arg) => !arg.startsWith('-')); + verify.cmdVerifySchemaDrift(cwd, phaseArg, skipFlag, raw); + }, + ), + // verify codebase-drift dispatches direct to CJS — drift is out-of-seam + // per ADR/PRD 3524 §3 / L160 (CJS-only by design). Routing through + // sdkHandler would re-enter the SDK bridge, and Phase 6's removed + // verifyCodebaseDrift stub used to execFileSync back to the CLI, + // creating an infinite spawn loop. + 'codebase-drift': () => verify.cmdVerifyCodebaseDrift(cwd, raw), + }, + }); } module.exports = { diff --git a/get-shit-done/bin/lib/workstream-name-policy.cjs b/get-shit-done/bin/lib/workstream-name-policy.cjs index 7cc4cf20e..61c58e7e8 100644 --- a/get-shit-done/bin/lib/workstream-name-policy.cjs +++ b/get-shit-done/bin/lib/workstream-name-policy.cjs @@ -1,33 +1,19 @@ /** - * Workstream Name Policy Module + * Workstream Name Policy Module — CJS adapter. * - * Owns canonical name validation and slug normalization used by workstream and - * active-pointer callers. + * The implementation is generated from sdk/src/workstream-name-policy.ts and + * lives in workstream-name-policy.generated.cjs. This file is a thin re-export + * so that existing call sites (active-workstream-store.cjs, + * planning-workspace.cjs, workstream.cjs, and tests) can continue to + * require('./workstream-name-policy') unchanged. + * + * Exports (from generated file): + * - toWorkstreamSlug(name) — normalize to URL/filesystem slug + * - hasInvalidPathSegment(name) — true if name has slashes or dot-dot + * - isValidActiveWorkstreamName(name) — true if name passes all policy rules + * - validateWorkstreamName(name) — SDK alias for isValidActiveWorkstreamName + * + * Regenerate: cd sdk && npm run gen:workstream-name-policy */ -const ACTIVE_WORKSTREAM_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; - -function toWorkstreamSlug(name) { - return String(name || '') - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); -} - -function hasInvalidPathSegment(name) { - const value = String(name || ''); - return /[/\\]/.test(value) || value === '.' || value === '..' || value.includes('..'); -} - -function isValidActiveWorkstreamName(name) { - const value = String(name || ''); - if (value === '..' || value.startsWith('../') || value.includes('..')) return false; - return ACTIVE_WORKSTREAM_RE.test(value); -} - -module.exports = { - toWorkstreamSlug, - hasInvalidPathSegment, - isValidActiveWorkstreamName, -}; - +module.exports = require('./workstream-name-policy.generated.cjs'); diff --git a/get-shit-done/bin/lib/workstream-name-policy.generated.cjs b/get-shit-done/bin/lib/workstream-name-policy.generated.cjs new file mode 100644 index 000000000..27f1ec23e --- /dev/null +++ b/get-shit-done/bin/lib/workstream-name-policy.generated.cjs @@ -0,0 +1,61 @@ +'use strict'; + +/** + * GENERATED FILE — DO NOT EDIT. + * + * Source: sdk/src/workstream-name-policy.ts + * Regenerate: cd sdk && npm run gen:workstream-name-policy + * + * Canonical workstream name validation and slug normalization. + * Used by active-workstream-store.cjs, planning-workspace.cjs, workstream.cjs. + */ + +const ACTIVE_WORKSTREAM_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; +/** + * Validate a workstream name. + * Allowed: alphanumeric, hyphens, underscores, dots. + * Disallowed: empty, spaces, slashes, special chars, path traversal. + * + * Alias for isValidActiveWorkstreamName; provided for SDK-layer callers. + */ +function validateWorkstreamName(name) { + return isValidActiveWorkstreamName(name); +} +/** + * Convert a display name to a URL/filesystem-safe workstream slug. + * Lowercases, collapses non-alphanumeric runs to hyphens, strips leading/trailing hyphens. + */ +function toWorkstreamSlug(name) { + return String(name || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} +/** + * Returns true when `name` contains a path separator, a bare dot, or a + * dot-dot sequence — any of which would make the name unsafe for use as a + * filesystem path segment. + */ +function hasInvalidPathSegment(name) { + const value = String(name || ''); + return /[/\\]/.test(value) || value === '.' || value === '..' || value.includes('..'); +} +/** + * Returns true when `name` is a valid active workstream name: + * - Must start with alphanumeric + * - May contain alphanumeric, dots, underscores, hyphens + * - Must not contain path traversal sequences (..) + */ +function isValidActiveWorkstreamName(name) { + const value = String(name || ''); + if (value === '..' || value.startsWith('../') || value.includes('..')) + return false; + return ACTIVE_WORKSTREAM_RE.test(value); +} + +module.exports = { + validateWorkstreamName, + toWorkstreamSlug, + hasInvalidPathSegment, + isValidActiveWorkstreamName, +}; diff --git a/get-shit-done/workflows/execute-phase/steps/codebase-drift-gate.md b/get-shit-done/workflows/execute-phase/steps/codebase-drift-gate.md index 2081502e2..bb4066e01 100644 --- a/get-shit-done/workflows/execute-phase/steps/codebase-drift-gate.md +++ b/get-shit-done/workflows/execute-phase/steps/codebase-drift-gate.md @@ -6,7 +6,7 @@ error here MUST fall through and continue to `verify_phase_goal`. The phase is never failed by this gate. ```bash -DRIFT=$(gsd-sdk query verify.codebase-drift 2>/dev/null || echo '{"skipped":true,"reason":"sdk-failed"}') +DRIFT=$(gsd-tools verify codebase-drift 2>/dev/null || echo '{"skipped":true,"reason":"sdk-failed"}') ``` Parse JSON for: `skipped`, `reason`, `action_required`, `directive`, diff --git a/package.json b/package.json index 6443d2056..1dfe75318 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,11 @@ "check:configuration-fresh": "cd sdk && npm run check:configuration-fresh", "check:workstream-inventory-builder-fresh": "cd sdk && npm run check:workstream-inventory-builder-fresh", "check:project-root-fresh": "cd sdk && npm run check:project-root-fresh", + "check:plan-scan-fresh": "cd sdk && npm run check:plan-scan-fresh", + "check:secrets-fresh": "cd sdk && npm run check:secrets-fresh", + "check:schema-detect-fresh": "cd sdk && npm run check:schema-detect-fresh", + "check:decisions-fresh": "cd sdk && npm run check:decisions-fresh", + "check:workstream-name-policy-fresh": "cd sdk && npm run check:workstream-name-policy-fresh", "prepublishOnly": "npm run build:hooks && npm run build:sdk", "pretest": "npm run build:sdk && npm run lint:skill-deps", "pretest:coverage": "npm run build:sdk", diff --git a/scripts/lint-shared-module-handsync.cjs b/scripts/lint-shared-module-handsync.cjs new file mode 100644 index 000000000..174bde6d9 --- /dev/null +++ b/scripts/lint-shared-module-handsync.cjs @@ -0,0 +1,331 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Shared Module hand-sync drift lint — Phase 6 of #3524 (#3575). + * + * Scans get-shit-done/bin/lib/ for .cjs files and checks whether a matching + * TypeScript file exists in sdk/src/.ts, sdk/src/query/.ts, or + * sdk/src//index.ts (excluding *.generated.ts and *.test.ts). + * + * Allowlist entries are keyed by the (cjs, ts) PAIR. An entry with cjs + * `bin/lib/foo.cjs` and ts `sdk/src/foo.ts` only allow-throughs that exact + * pair — a sibling at `sdk/src/query/foo.ts` is still flagged. + * + * If a pair is found: + * - cooperatingSiblings (matching cjs + ts): accepted silently (exit 0). + * - migrateMeBacklog (matching cjs + ts): emits a WARNING only when + * --warn-all is set; otherwise the pair passes silently. Backlog + * pairs never fail CI. + * - Unlisted pairs (cjs or ts not on either list): ERROR — exit 1. + * + * Usage: + * node scripts/lint-shared-module-handsync.cjs + * node scripts/lint-shared-module-handsync.cjs --root /path/to/repo + * node scripts/lint-shared-module-handsync.cjs --warn-all + * node scripts/lint-shared-module-handsync.cjs --cjs-dir custom/bin/lib --sdk-src custom/sdk/src + */ + +const fs = require('fs'); +const path = require('path'); + +// --------------------------------------------------------------------------- +// Argument parsing +// --------------------------------------------------------------------------- +const args = process.argv.slice(2); +let ROOT = path.resolve(__dirname, '..'); +let CJS_DIR = null; // resolved below +let SDK_SRC = null; // resolved below +let ALLOWLIST_OVERRIDE = null; // resolved below +let WARN_ALL = false; +let JSON_OUTPUT = false; + +for (let i = 0; i < args.length; i++) { + if (args[i] === '--root' && args[i + 1]) { + ROOT = path.resolve(args[++i]); + } else if (args[i] === '--cjs-dir' && args[i + 1]) { + CJS_DIR = path.resolve(args[++i]); + } else if (args[i] === '--sdk-src' && args[i + 1]) { + SDK_SRC = path.resolve(args[++i]); + } else if (args[i] === '--allowlist' && args[i + 1]) { + ALLOWLIST_OVERRIDE = path.resolve(args[++i]); + } else if (args[i] === '--warn-all') { + WARN_ALL = true; + } else if (args[i] === '--json') { + JSON_OUTPUT = true; + } +} + +if (!CJS_DIR) CJS_DIR = path.join(ROOT, 'get-shit-done', 'bin', 'lib'); +if (!SDK_SRC) SDK_SRC = path.join(ROOT, 'sdk', 'src'); + +// --------------------------------------------------------------------------- +// Load allowlist +// When --root is given (e.g. in tests), prefer /scripts/allowlist.json +// so fixture trees can supply their own allowlist. Fall back to the copy +// co-located with this script (default production path). +// --------------------------------------------------------------------------- +const ALLOWLIST_PATH = ALLOWLIST_OVERRIDE + ? ALLOWLIST_OVERRIDE + : fs.existsSync(path.join(ROOT, 'scripts', 'shared-module-handsync-allowlist.json')) + ? path.join(ROOT, 'scripts', 'shared-module-handsync-allowlist.json') + : path.join(__dirname, 'shared-module-handsync-allowlist.json'); +let allowlist; +try { + allowlist = JSON.parse(fs.readFileSync(ALLOWLIST_PATH, 'utf8')); +} catch (err) { + process.stderr.write( + `lint-shared-module-handsync: failed to read allowlist at ${ALLOWLIST_PATH}: ${err.message}\n` + ); + process.exit(1); +} + +/** + * Pair identity = `${cjs}::${ts}`. Keying on the pair (not just cjs) + * prevents an allowlisted entry from silently passing an unintended + * sibling at a different ts path with the same basename. + * + * @type {Set} pair identities in cooperatingSiblings + */ +const cooperatingPairs = new Set( + (allowlist.cooperatingSiblings || []).map((e) => `${e.cjs}::${e.ts}`) +); + +/** @type {Map} pair identity -> entry for migrateMeBacklog */ +const migrateMap = new Map( + (allowlist.migrateMeBacklog || []).map((e) => [`${e.cjs}::${e.ts}`, e]) +); + +// --------------------------------------------------------------------------- +// Build SDK name index: name -> array of absolute TS paths +// (excludes *.generated.ts and *.test.ts) +// --------------------------------------------------------------------------- +function buildSdkIndex(sdkSrc) { + const index = new Map(); // name -> [absPath, ...] + + function addEntry(name, absPath) { + if (!index.has(name)) index.set(name, []); + index.get(name).push(absPath); + } + + function walk(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (_) { + return; + } + for (const ent of entries) { + const abs = path.join(dir, ent.name); + if (ent.isDirectory()) { + walk(abs); + } else if (ent.isFile() && ent.name.endsWith('.ts') && + !ent.name.endsWith('.generated.ts') && + !ent.name.endsWith('.test.ts')) { + const rel = path.relative(sdkSrc, abs); + const parts = rel.split(path.sep); + + // sdk/src/.ts (direct child, not in a subdir) + if (parts.length === 1) { + const name = parts[0].slice(0, -3); // strip .ts + addEntry(name, abs); + } + // sdk/src//index.ts (one subdir deep, file is index.ts) + else if (parts.length === 2 && parts[1] === 'index.ts') { + const name = parts[0]; + addEntry(name, abs); + } + // sdk/src/query/.ts (exactly: query/.ts) + else if (parts.length === 2 && parts[0] === 'query' && parts[1] !== 'index.ts') { + const name = parts[1].slice(0, -3); // strip .ts + addEntry(name, abs); + } + } + } + } + + walk(sdkSrc); + return index; +} + +// --------------------------------------------------------------------------- +// Scan CJS files (direct children only; exclude *.generated.cjs) +// --------------------------------------------------------------------------- +function scanCjsFiles(cjsDir) { + let entries; + try { + entries = fs.readdirSync(cjsDir, { withFileTypes: true }); + } catch (err) { + process.stderr.write( + `lint-shared-module-handsync: cannot read CJS dir ${cjsDir}: ${err.message}\n` + ); + process.exit(1); + } + return entries + .filter( + (e) => + e.isFile() && + e.name.endsWith('.cjs') && + !e.name.endsWith('.generated.cjs') + ) + .map((e) => ({ + name: e.name.slice(0, -4), // strip .cjs + absPath: path.join(cjsDir, e.name), + })); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- +function emitJson(payload) { + process.stdout.write(JSON.stringify(payload) + '\n'); +} + +function main() { + // Check that the directories exist + if (!fs.existsSync(CJS_DIR)) { + if (JSON_OUTPUT) { + emitJson({ ok: false, reason: 'cjs_dir_missing', path: CJS_DIR }); + } else { + process.stderr.write( + `lint-shared-module-handsync: CJS dir not found: ${CJS_DIR}\n` + + ` Pass --root or --cjs-dir to override.\n` + ); + } + process.exit(1); + } + if (!fs.existsSync(SDK_SRC)) { + if (JSON_OUTPUT) { + emitJson({ ok: false, reason: 'sdk_src_missing', path: SDK_SRC }); + } else { + process.stderr.write( + `lint-shared-module-handsync: SDK src dir not found: ${SDK_SRC}\n` + + ` Pass --root or --sdk-src to override.\n` + ); + } + process.exit(1); + } + + const sdkIndex = buildSdkIndex(SDK_SRC); + const cjsFiles = scanCjsFiles(CJS_DIR); + + const errors = []; + const warnings = []; + + for (const { name, absPath } of cjsFiles) { + // Is there a matching TS file? + if (!sdkIndex.has(name)) continue; + + // Compute the relative paths the allowlist uses + const relCjs = path.relative(ROOT, absPath).replace(/\\/g, '/'); + const tsPaths = sdkIndex.get(name).map((p) => path.relative(ROOT, p).replace(/\\/g, '/')); + + // Pair-aware matching, per ts sibling. Each ts candidate is classified + // independently against the allowlist so a partially-allowlisted set of + // siblings still surfaces the unauthorized ones. See #3632. + const unauthorizedTs = []; + const backlogTsForCjs = []; + for (const relTs of tsPaths) { + const pairKey = `${relCjs}::${relTs}`; + if (cooperatingPairs.has(pairKey)) continue; + if (migrateMap.has(pairKey)) { + backlogTsForCjs.push(relTs); + continue; + } + unauthorizedTs.push(relTs); + } + + if (unauthorizedTs.length > 0) { + errors.push({ relCjs, tsPaths: unauthorizedTs }); + } + if (backlogTsForCjs.length > 0) { + const entry = migrateMap.get(`${relCjs}::${backlogTsForCjs[0]}`); + warnings.push({ relCjs, tsPaths: backlogTsForCjs, entry }); + } + } + + // Count cjs files whose pair identity (cjs+ts) is on cooperatingSiblings. + // A file with multiple ts candidates is counted once if any pair matches. + const cooperatingCount = cjsFiles.filter((f) => { + if (!sdkIndex.has(f.name)) return false; + const relCjs = path.relative(ROOT, f.absPath).replace(/\\/g, '/'); + return sdkIndex.get(f.name).some((tsAbs) => { + const relTs = path.relative(ROOT, tsAbs).replace(/\\/g, '/'); + return cooperatingPairs.has(`${relCjs}::${relTs}`); + }); + }).length; + + // ------------------------------------------------------------------------- + // Report errors (exit 1) + // ------------------------------------------------------------------------- + if (errors.length > 0) { + if (JSON_OUTPUT) { + emitJson({ + ok: false, + reason: 'unauthorized_pairs', + errors, + warnings, + cooperatingCount, + }); + } else { + process.stderr.write( + `\nERROR lint-shared-module-handsync: ${errors.length} unauthorized hand-sync pair(s) found.\n\n` + ); + for (const { relCjs, tsPaths } of errors) { + process.stderr.write(` CJS: ${relCjs}\n`); + for (const ts of tsPaths) { + process.stderr.write(` TS: ${ts}\n`); + } + process.stderr.write('\n'); + } + process.stderr.write( + 'To resolve, choose one of:\n' + + ' 1. Migrate to a Shared Module (preferred): create sdk/src//index.ts as the\n' + + ' source-of-truth, write a generator script (sdk/scripts/gen-.mjs), add a\n' + + ' freshness check, and update CI. See docs/agents/cjs-sdk-seam.md for the pattern.\n' + + ' 2. Add an explicit allowlist entry to scripts/shared-module-handsync-allowlist.json\n' + + ' with a justification explaining why this pair is a legitimate cooperating sibling\n' + + ' rather than a drift anti-pattern. Requires maintainer review via CODEOWNERS.\n\n' + ); + } + process.exit(1); + } + + // ------------------------------------------------------------------------- + // Report warnings (no exit code change) + // ------------------------------------------------------------------------- + if (warnings.length > 0 && WARN_ALL && !JSON_OUTPUT) { + process.stderr.write( + `\nWARNING lint-shared-module-handsync: ${warnings.length} known drift anti-pattern pair(s) in migrateMeBacklog.\n` + + `These are tracked for future Shared Module migration but do not block CI.\n\n` + ); + for (const { relCjs, tsPaths, entry } of warnings) { + process.stderr.write(` CJS: ${relCjs}\n`); + for (const ts of tsPaths) { + process.stderr.write(` TS: ${ts}\n`); + } + process.stderr.write(` Tracked: ${entry.trackedIn}\n`); + process.stderr.write(` Hint: ${entry.justification}\n\n`); + } + } + + // ------------------------------------------------------------------------- + // Success + // ------------------------------------------------------------------------- + if (JSON_OUTPUT) { + emitJson({ + ok: true, + cooperatingCount, + backlogCount: warnings.length, + warnings, + }); + } else { + process.stdout.write( + `ok lint-shared-module-handsync: no unauthorized hand-sync pairs found` + + ` (${cooperatingCount} cooperating sibling(s), ${warnings.length} backlog pair(s))\n` + ); + } + process.exit(0); +} + +main(); diff --git a/scripts/shared-module-handsync-allowlist.json b/scripts/shared-module-handsync-allowlist.json new file mode 100644 index 000000000..a5829603d --- /dev/null +++ b/scripts/shared-module-handsync-allowlist.json @@ -0,0 +1,139 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "_comment": "Allowlist for scripts/lint-shared-module-handsync.cjs — Phase 6 of #3524 (#3575). Two categories: cooperatingSiblings (legitimate pairs, lint accepts silently) and migrateMeBacklog (known drift anti-patterns, lint warns but does not fail). All entries require cjs + ts path + classification + justification.", + "cooperatingSiblings": [ + { + "cjs": "get-shit-done/bin/lib/active-workstream-store.cjs", + "ts": "sdk/src/query/active-workstream-store.ts", + "classification": "cooperating-sibling", + "justification": "CJS manages filesystem-backed workstream store; SDK layer wraps via Adapter for query dispatch. Different responsibilities, not drift." + }, + { + "cjs": "get-shit-done/bin/lib/config-schema.cjs", + "ts": "sdk/src/query/config-schema.ts", + "classification": "cooperating-sibling", + "justification": "SDK config-schema.ts is the generated source-of-truth derived from sdk/shared/config-schema.manifest.json (Phase 2/#3540). CJS config-schema.cjs is the Adapter that reads from that manifest. Not a hand-sync pair; freshness check enforces alignment." + }, + { + "cjs": "get-shit-done/bin/lib/frontmatter.cjs", + "ts": "sdk/src/query/frontmatter.ts", + "classification": "cooperating-sibling", + "justification": "CJS implements full frontmatter parsing/mutation; SDK frontmatter.ts is the native SDK query handler delegating to the CJS runtime via the seam bridge. Not duplicating logic." + }, + { + "cjs": "get-shit-done/bin/lib/init.cjs", + "ts": "sdk/src/query/init.ts", + "classification": "cooperating-sibling", + "justification": "CJS init.cjs is the authoritative initializer; SDK init.ts provides the native handler layer for the SDK query seam. Phase 5.2+ will migrate remaining subcommands, but current architecture is intentional." + }, + { + "cjs": "get-shit-done/bin/lib/phase.cjs", + "ts": "sdk/src/query/phase.ts", + "classification": "cooperating-sibling", + "justification": "CJS phase.cjs is the full phase lifecycle implementation; SDK phase.ts provides the native query handler. The SDK delegates to CJS for most subcommands. Phase 5.2+ candidate for further migration." + }, + { + "cjs": "get-shit-done/bin/lib/profile-output.cjs", + "ts": "sdk/src/query/profile-output.ts", + "classification": "cooperating-sibling", + "justification": "CJS profile-output.cjs handles profiling output rendering; SDK profile-output.ts is the corresponding SDK query handler. Separate responsibilities across the seam." + }, + { + "cjs": "get-shit-done/bin/lib/roadmap.cjs", + "ts": "sdk/src/query/roadmap.ts", + "classification": "cooperating-sibling", + "justification": "CJS roadmap.cjs is the full roadmap implementation; SDK roadmap.ts provides the native handler for SDK query dispatch. Phase 5.2+ candidate." + }, + { + "cjs": "get-shit-done/bin/lib/state.cjs", + "ts": "sdk/src/query/state.ts", + "classification": "cooperating-sibling", + "justification": "CJS state.cjs is the full state implementation; SDK state.ts routes known subcommands via executeForCjs (Phase 5.0/#3558, Phase 5.1/#3574). Intentional seam delegation pattern." + }, + { + "cjs": "get-shit-done/bin/lib/state-document.cjs", + "ts": "sdk/src/query/state-document.ts", + "classification": "cooperating-sibling", + "justification": "CJS state-document.cjs is the generated Adapter reading from sdk/src/state-document/ Shared Module (Phase 1/#3531). SDK state-document.ts is the corresponding source-of-truth query handler. Freshness check enforces alignment." + }, + { + "cjs": "get-shit-done/bin/lib/template.cjs", + "ts": "sdk/src/query/template.ts", + "classification": "cooperating-sibling", + "justification": "CJS template.cjs handles template operations; SDK template.ts is the corresponding SDK native handler. Separate responsibilities across the seam." + }, + { + "cjs": "get-shit-done/bin/lib/uat.cjs", + "ts": "sdk/src/query/uat.ts", + "classification": "cooperating-sibling", + "justification": "CJS uat.cjs implements UAT workflows; SDK uat.ts provides the SDK query handler layer. Separate responsibilities." + }, + { + "cjs": "get-shit-done/bin/lib/verify.cjs", + "ts": "sdk/src/query/verify.ts", + "classification": "cooperating-sibling", + "justification": "CJS verify.cjs is the full verify implementation; SDK verify.ts provides the native handler. Phase 5.2+ candidate for further delegation." + }, + { + "cjs": "get-shit-done/bin/lib/workstream.cjs", + "ts": "sdk/src/query/workstream.ts", + "classification": "cooperating-sibling", + "justification": "CJS workstream.cjs handles workstream management; SDK workstream.ts provides the SDK query handler. Workstream support inside sync bridge is an open follow-up item." + }, + { + "cjs": "get-shit-done/bin/lib/workstream-inventory.cjs", + "ts": "sdk/src/query/workstream-inventory.ts", + "classification": "cooperating-sibling", + "justification": "CJS workstream-inventory.cjs is the generated Adapter for the workstream-inventory Shared Module (Phase 3/#3548). SDK workstream-inventory.ts is the source-of-truth query handler. Freshness check enforces alignment." + }, + { + "cjs": "get-shit-done/bin/lib/config.cjs", + "ts": "sdk/src/config.ts", + "classification": "CJS-CLI-ONLY", + "justification": "Phase 2 (#3536) already migrated CONFIG_DEFAULTS and loadConfig/mergeDefaults to the Configuration Module and sdk/src/config.ts. What remains in config.cjs is exclusively CLI command handlers (cmdConfigGet, cmdConfigSet, cmdConfigNewProject, cmdConfigEnsureSection, cmdConfigSetModelProfile, cmdConfigPath, cmdMigrateConfig, buildNewProjectConfig, setConfigValue, ensureConfigFile) that depend on CJS-only APIs (withPlanningLock, platformWriteSync/ReadSync/EnsureDir, sync fs ops, process.exit). sdk/src/config.ts provides only the async loadConfig/mergeDefaults SDK layer. The two files serve disjoint surfaces with no logical overlap — not a hand-sync drift anti-pattern." + }, + { + "cjs": "get-shit-done/bin/lib/intel.cjs", + "ts": "sdk/src/query/intel.ts", + "classification": "cooperating-sibling", + "justification": "CJS intel.cjs is the synchronous runtime implementation used by gsd-tools.cjs; sdk/src/query/intel.ts is the async QueryHandler port for the SDK query seam (explicitly documented as a port in its file header). The two files intentionally diverge on INTEL_FILES naming (CJS: file-roles.json/api-map.json/dependency-graph.json/arch-decisions.json; SDK: files.json/apis.json/deps.json/arch.md) — existing CJS tests are locked to the old naming. Not a hand-sync drift pattern; separate runtime responsibilities across the seam." + }, + { + "cjs": "get-shit-done/bin/lib/model-catalog.cjs", + "ts": "sdk/src/model-catalog.ts", + "classification": "ADAPTER-OVER-MODULE", + "justification": "Both files read from sdk/shared/model-catalog.json (ADR-0003 precedent) as independent consumers of the shared manifest. CJS exposes VALID_AGENT_TIERS, MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT, nextTier, formatAgentToModelMapAsTable for core.cjs and model-profiles.cjs consumers. SDK exposes resolveRuntimeTierDefault, runtimesWithReasoningEffort for session-runner.ts and query handlers. The shared JSON is the single source-of-truth; both adapters derive their exports from it without duplicating any logic between themselves." + }, + { + "cjs": "get-shit-done/bin/lib/plan-scan.cjs", + "ts": "sdk/src/query/plan-scan.ts", + "classification": "ADAPTER-OVER-MODULE", + "justification": "CJS plan-scan.cjs is the generated Adapter reading from sdk/src/query/plan-scan.ts Shared Module (Phase 6/#3575). SDK plan-scan.ts is the source-of-truth. Freshness check (check-plan-scan-fresh.mjs) enforces alignment." + }, + { + "cjs": "get-shit-done/bin/lib/secrets.cjs", + "ts": "sdk/src/query/secrets.ts", + "classification": "ADAPTER-OVER-MODULE", + "justification": "CJS secrets.cjs is the generated Adapter reading from sdk/src/query/secrets.ts Shared Module (Phase 6/#3575). SDK secrets.ts is the source-of-truth. Freshness check (check-secrets-fresh.mjs) enforces alignment." + }, + { + "cjs": "get-shit-done/bin/lib/schema-detect.cjs", + "ts": "sdk/src/query/schema-detect.ts", + "classification": "ADAPTER-OVER-MODULE", + "justification": "CJS schema-detect.cjs is the generated Adapter reading from sdk/src/query/schema-detect.ts Shared Module (Phase 6/#3575). SDK schema-detect.ts is the source-of-truth. Generated CJS adds detectSchemaOrm compat export (not in SDK) and exports SCHEMA_PATTERNS/ORM_INFO for backward compatibility. Freshness check (check-schema-detect-fresh.mjs) enforces alignment." + }, + { + "cjs": "get-shit-done/bin/lib/decisions.cjs", + "ts": "sdk/src/query/decisions.ts", + "classification": "ADAPTER-OVER-MODULE", + "justification": "Phase 6 (#3575): CJS decisions.cjs is the generated Adapter reading from sdk/src/query/decisions.ts Shared Module. SDK source-of-truth; regex aligned to accept alphanumeric IDs (D-INFRA-01). CJS callers (gap-checker.cjs) use {id, text} subset; extra fields {category, tags, trackable} are present but ignored. Freshness check (check-decisions-fresh.mjs) enforces alignment." + }, + { + "cjs": "get-shit-done/bin/lib/workstream-name-policy.cjs", + "ts": "sdk/src/workstream-name-policy.ts", + "classification": "ADAPTER-OVER-MODULE", + "justification": "Phase 6 (#3575): CJS workstream-name-policy.cjs is the generated Adapter reading from sdk/src/workstream-name-policy.ts Shared Module. SDK source-of-truth now exports all three functions used by CJS callers (toWorkstreamSlug, hasInvalidPathSegment, isValidActiveWorkstreamName) plus validateWorkstreamName alias. Freshness check (check-workstream-name-policy-fresh.mjs) enforces alignment." + } + ], + "migrateMeBacklog": [] +} diff --git a/sdk/package.json b/sdk/package.json index 9bd6c29b0..78814f24e 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -44,6 +44,16 @@ "check:workstream-inventory-builder-fresh": "npm run build && node scripts/check-workstream-inventory-builder-fresh.mjs", "gen:project-root": "npm run build && node scripts/gen-project-root.mjs", "check:project-root-fresh": "npm run build && node scripts/check-project-root-fresh.mjs", + "gen:plan-scan": "npm run build && node scripts/gen-plan-scan.mjs", + "check:plan-scan-fresh": "npm run build && node scripts/check-plan-scan-fresh.mjs", + "gen:secrets": "npm run build && node scripts/gen-secrets.mjs", + "check:secrets-fresh": "npm run build && node scripts/check-secrets-fresh.mjs", + "gen:schema-detect": "npm run build && node scripts/gen-schema-detect.mjs", + "check:schema-detect-fresh": "npm run build && node scripts/check-schema-detect-fresh.mjs", + "gen:decisions": "npm run build && node scripts/gen-decisions.mjs", + "check:decisions-fresh": "npm run build && node scripts/check-decisions-fresh.mjs", + "gen:workstream-name-policy": "npm run build && node scripts/gen-workstream-name-policy.mjs", + "check:workstream-name-policy-fresh": "npm run build && node scripts/check-workstream-name-policy-fresh.mjs", "prepublishOnly": "rm -rf dist && tsc && chmod +x dist/cli.js", "test": "vitest run", "test:unit": "vitest run --project unit", diff --git a/sdk/scripts/check-decisions-fresh.mjs b/sdk/scripts/check-decisions-fresh.mjs new file mode 100644 index 000000000..338dc37ae --- /dev/null +++ b/sdk/scripts/check-decisions-fresh.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +/** + * Freshness check for decisions.generated.cjs. + * + * Regenerates the expected CJS content in-memory (without writing to disk) and + * compares it to the committed file. Exits 0 if they match, 1 if stale. + * + * Run: node sdk/scripts/check-decisions-fresh.mjs + * (Requires sdk/dist to be built first — `npm run build` in sdk/.) + */ + +import { readFile } from 'node:fs/promises'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { buildDecisionsCjs } from './gen-decisions.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); + +const expected = await buildDecisionsCjs(); + +const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'decisions.generated.cjs'); +const committed = await readFile(committedPath, 'utf-8'); + +if (expected === committed) { + console.log('decisions.generated.cjs is fresh'); + process.exit(0); +} else { + console.error('decisions.generated.cjs is STALE.'); + console.error('Regenerate: cd sdk && npm run gen:decisions'); + process.exit(1); +} diff --git a/sdk/scripts/check-plan-scan-fresh.mjs b/sdk/scripts/check-plan-scan-fresh.mjs new file mode 100644 index 000000000..4f01d2e15 --- /dev/null +++ b/sdk/scripts/check-plan-scan-fresh.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +/** + * Freshness check for plan-scan.generated.cjs. + * + * Regenerates the expected CJS content in-memory (without writing to disk) and + * compares it to the committed file. Exits 0 if they match, 1 if stale. + * + * Run: node sdk/scripts/check-plan-scan-fresh.mjs + * (Requires sdk/dist to be built first — `npm run build` in sdk/.) + */ + +import { readFile } from 'node:fs/promises'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { buildPlanScanCjs } from './gen-plan-scan.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); + +const expected = await buildPlanScanCjs(); + +const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'plan-scan.generated.cjs'); +const committed = await readFile(committedPath, 'utf-8'); + +if (expected === committed) { + console.log('plan-scan.generated.cjs is fresh'); + process.exit(0); +} else { + console.error('plan-scan.generated.cjs is STALE.'); + console.error('Regenerate: cd sdk && npm run gen:plan-scan'); + process.exit(1); +} diff --git a/sdk/scripts/check-schema-detect-fresh.mjs b/sdk/scripts/check-schema-detect-fresh.mjs new file mode 100644 index 000000000..7d53d3a03 --- /dev/null +++ b/sdk/scripts/check-schema-detect-fresh.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +/** + * Freshness check for schema-detect.generated.cjs. + * + * Regenerates the expected CJS content in-memory (without writing to disk) and + * compares it to the committed file. Exits 0 if they match, 1 if stale. + * + * Run: node sdk/scripts/check-schema-detect-fresh.mjs + * (Requires sdk/dist to be built first — `npm run build` in sdk/.) + */ + +import { readFile } from 'node:fs/promises'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { buildSchemaDetectCjs } from './gen-schema-detect.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); + +const expected = await buildSchemaDetectCjs(); + +const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'schema-detect.generated.cjs'); +const committed = await readFile(committedPath, 'utf-8'); + +if (expected === committed) { + console.log('schema-detect.generated.cjs is fresh'); + process.exit(0); +} else { + console.error('schema-detect.generated.cjs is STALE.'); + console.error('Regenerate: cd sdk && npm run gen:schema-detect'); + process.exit(1); +} diff --git a/sdk/scripts/check-secrets-fresh.mjs b/sdk/scripts/check-secrets-fresh.mjs new file mode 100644 index 000000000..1e82977ea --- /dev/null +++ b/sdk/scripts/check-secrets-fresh.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +/** + * Freshness check for secrets.generated.cjs. + * + * Regenerates the expected CJS content in-memory (without writing to disk) and + * compares it to the committed file. Exits 0 if they match, 1 if stale. + * + * Run: node sdk/scripts/check-secrets-fresh.mjs + * (Requires sdk/dist to be built first — `npm run build` in sdk/.) + */ + +import { readFile } from 'node:fs/promises'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { buildSecretsCjs } from './gen-secrets.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); + +const expected = await buildSecretsCjs(); + +const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'secrets.generated.cjs'); +const committed = await readFile(committedPath, 'utf-8'); + +if (expected === committed) { + console.log('secrets.generated.cjs is fresh'); + process.exit(0); +} else { + console.error('secrets.generated.cjs is STALE.'); + console.error('Regenerate: cd sdk && npm run gen:secrets'); + process.exit(1); +} diff --git a/sdk/scripts/check-workstream-name-policy-fresh.mjs b/sdk/scripts/check-workstream-name-policy-fresh.mjs new file mode 100644 index 000000000..2db5d6475 --- /dev/null +++ b/sdk/scripts/check-workstream-name-policy-fresh.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +/** + * Freshness check for workstream-name-policy.generated.cjs. + * + * Regenerates the expected CJS content in-memory (without writing to disk) and + * compares it to the committed file. Exits 0 if they match, 1 if stale. + * + * Run: node sdk/scripts/check-workstream-name-policy-fresh.mjs + * (Requires sdk/dist to be built first — `npm run build` in sdk/.) + */ + +import { readFile } from 'node:fs/promises'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { buildWorkstreamNamePolicyCjs } from './gen-workstream-name-policy.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); + +const expected = await buildWorkstreamNamePolicyCjs(); + +const committedPath = resolve(here, '..', '..', 'get-shit-done', 'bin', 'lib', 'workstream-name-policy.generated.cjs'); +const committed = await readFile(committedPath, 'utf-8'); + +if (expected === committed) { + console.log('workstream-name-policy.generated.cjs is fresh'); + process.exit(0); +} else { + console.error('workstream-name-policy.generated.cjs is STALE.'); + console.error('Regenerate: cd sdk && npm run gen:workstream-name-policy'); + process.exit(1); +} diff --git a/sdk/scripts/gen-decisions.mjs b/sdk/scripts/gen-decisions.mjs new file mode 100644 index 000000000..f3e83e54e --- /dev/null +++ b/sdk/scripts/gen-decisions.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * Generator for the Decisions CJS artifact. + * + * Reads the compiled ESM output from sdk/dist/query/decisions.js, + * extracts the relevant function bodies via text transformation, + * then emits get-shit-done/bin/lib/decisions.generated.cjs. + * + * Source-of-truth: sdk/src/query/decisions.ts + * + * Run: cd sdk && npm run gen:decisions + * Freshness check: node sdk/scripts/check-decisions-fresh.mjs + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +export const BANNER = `'use strict'; + +/** + * GENERATED FILE — DO NOT EDIT. + * + * Source: sdk/src/query/decisions.ts + * Regenerate: cd sdk && npm run gen:decisions + * + * Shared parser for CONTEXT.md blocks. + * Accepts both numeric (D-42) and alphanumeric (D-INFRA-01) IDs. + * Returns {id, text, category, tags, trackable} per decision. + * CJS callers that only use {id, text} safely ignore the extra fields. + */ + +`; + +export async function buildDecisionsCjs() { + // Read the compiled ESM source and transform to CJS. + // We extract only the pure logic (no Node.js imports, no query handler). + const distPath = fileURLToPath(new URL('../dist/query/decisions.js', import.meta.url)); + const src = await readFile(distPath, 'utf-8'); + + // Strip the ESM-specific header lines (import statements, jsdoc at top) + // and the query handler (which uses Node async fs — not needed in CJS shim). + // We keep: DISCRETION_HEADINGS, NON_TRACKABLE_TAGS, stripFencedCode, + // extractDecisionsBlock, parseDecisions. + + // Extract the module body between the imports and the query handler. + // Strategy: strip the leading imports and the trailing export const decisionsParse block. + let body = src; + + // Remove leading import statements + body = body.replace(/^import\s+.*?;[\r\n]*/gm, ''); + + // Remove trailing query handler (from the `export const decisionsParse` line to end) + const handlerStart = body.indexOf('// ─── Query handler'); + if (handlerStart !== -1) { + body = body.slice(0, handlerStart); + } + + // Remove ESM export keywords (keep the function/const declarations) + body = body.replace(/^export (function|const) /gm, '$1 '); + + // Remove source map comment + body = body.replace(/\/\/# sourceMappingURL=.*$/gm, ''); + + // Remove leading file-level jsdoc comment (keep module-level logic only) + body = body.replace(/^\/\*\*[\s\S]*?\*\/\n/m, ''); + + // Trim extra blank lines at start/end + body = body.trim(); + + const parts = [ + BANNER.trimEnd(), + '', + body, + '', + 'module.exports = { parseDecisions };', + '', + ]; + + return parts.join('\n'); +} + +async function main() { + const content = await buildDecisionsCjs(); + const outPath = fileURLToPath( + new URL('../../get-shit-done/bin/lib/decisions.generated.cjs', import.meta.url), + ); + await writeFile(outPath, content, 'utf-8'); + console.log(`Written: ${outPath}`); +} + +// Only run main() when this file is the entry point, not when imported. +// process.argv[1] is already an absolute filesystem path on every platform Node +// supports; comparing directly avoids the Windows URL-parsing bug where +// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`. +if (fileURLToPath(import.meta.url) === process.argv[1]) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/sdk/scripts/gen-plan-scan.mjs b/sdk/scripts/gen-plan-scan.mjs new file mode 100644 index 000000000..7955288d6 --- /dev/null +++ b/sdk/scripts/gen-plan-scan.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * Generator for the Plan Scan CJS artifact. + * + * Reads the compiled ESM output from sdk/dist/query/plan-scan.js, + * extracts function source via Function.prototype.toString() for exports, + * then emits get-shit-done/bin/lib/plan-scan.generated.cjs. + * + * Run: cd sdk && npm run gen:plan-scan + * Freshness check: node sdk/scripts/check-plan-scan-fresh.mjs + */ + +import { writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +export const BANNER = `'use strict'; + +/** + * GENERATED FILE — DO NOT EDIT. + * + * Source: sdk/src/query/plan-scan.ts + * Regenerate: cd sdk && npm run gen:plan-scan + * + * Plan Scan Module — detects plan and summary files in a phase directory. + * Supports both flat (pre-#3139) and nested (post-#3139) layouts. + */ + +`; + +export async function buildPlanScanCjs() { + // Load the compiled ESM module to get exports via Function.prototype.toString() + const distUrl = new URL('../dist/query/plan-scan.js', import.meta.url); + const { + isRootPlanFile, + isNestedPlanFile, + isRootSummaryFile, + isNestedSummaryFile, + scanPhasePlans, + } = await import(distUrl.href); + + // Get exported function bodies via Function.prototype.toString() + const isRootPlanFileBody = isRootPlanFile.toString(); + const isNestedPlanFileBody = isNestedPlanFile.toString(); + const isRootSummaryFileBody = isRootSummaryFile.toString(); + const isNestedSummaryFileBody = isNestedSummaryFile.toString(); + const scanPhasePlansBody = scanPhasePlans.toString(); + + const parts = [ + BANNER.trimEnd(), + '', + "const { existsSync, readdirSync } = require('node:fs');", + "const { join } = require('node:path');", + '', + '// Excluded derivative files', + 'const PLAN_OUTLINE_RE = /-OUTLINE\\.md$/i;', + 'const PLAN_PRE_BOUNCE_RE = /\\.pre-bounce\\.md$/i;', + '', + isRootPlanFileBody, + '', + isNestedPlanFileBody, + '', + isRootSummaryFileBody, + '', + isNestedSummaryFileBody, + '', + scanPhasePlansBody, + '', + '// CJS callers do: const scanPhasePlans = require(\'./plan-scan.cjs\')', + '// and also destructure named exports — support both call styles.', + 'module.exports = scanPhasePlans;', + 'module.exports.scanPhasePlans = scanPhasePlans;', + 'module.exports.isRootPlanFile = isRootPlanFile;', + 'module.exports.isNestedPlanFile = isNestedPlanFile;', + 'module.exports.isRootSummaryFile = isRootSummaryFile;', + 'module.exports.isNestedSummaryFile = isNestedSummaryFile;', + '', + ]; + + return parts.join('\n'); +} + +async function main() { + const content = await buildPlanScanCjs(); + const outPath = fileURLToPath( + new URL('../../get-shit-done/bin/lib/plan-scan.generated.cjs', import.meta.url), + ); + await writeFile(outPath, content, 'utf-8'); + console.log(`Written: ${outPath}`); +} + +// Only run main() when this file is the entry point, not when imported. +// process.argv[1] is already an absolute filesystem path on every platform Node +// supports; comparing directly avoids the Windows URL-parsing bug where +// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`. +if (fileURLToPath(import.meta.url) === process.argv[1]) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/sdk/scripts/gen-project-root.mjs b/sdk/scripts/gen-project-root.mjs index 2967ef5dc..13968ee8e 100644 --- a/sdk/scripts/gen-project-root.mjs +++ b/sdk/scripts/gen-project-root.mjs @@ -85,9 +85,10 @@ async function main() { } // Only run main() when this file is the entry point, not when imported. -const scriptPath = fileURLToPath(import.meta.url); -const entryPath = process.argv[1] ? new URL(process.argv[1], 'file://').pathname : ''; -if (scriptPath === entryPath || process.argv[1] === scriptPath) { +// process.argv[1] is already an absolute filesystem path on every platform Node +// supports; comparing directly avoids the Windows URL-parsing bug where +// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`. +if (fileURLToPath(import.meta.url) === process.argv[1]) { main().catch((err) => { console.error(err); process.exit(1); diff --git a/sdk/scripts/gen-schema-detect.mjs b/sdk/scripts/gen-schema-detect.mjs new file mode 100644 index 000000000..9fef117ce --- /dev/null +++ b/sdk/scripts/gen-schema-detect.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node +/** + * Generator for the Schema Detect CJS artifact. + * + * Reads the compiled ESM output from sdk/dist/query/schema-detect.js, + * extracts function source via Function.prototype.toString() for exports + * and via source-text extraction for internal constants, then emits + * get-shit-done/bin/lib/schema-detect.generated.cjs. + * + * Run: cd sdk && npm run gen:schema-detect + * Freshness check: node sdk/scripts/check-schema-detect-fresh.mjs + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +export const BANNER = `'use strict'; + +/** + * GENERATED FILE — DO NOT EDIT. + * + * Source: sdk/src/query/schema-detect.ts + * Regenerate: cd sdk && npm run gen:schema-detect + * + * Schema Drift Detection — detects schema-relevant file changes and verifies + * that the appropriate database push command was executed during a phase. + * This module does not read the filesystem directly. + */ + +`; + +/** + * Extract a top-level const declaration block (array or object literal) + * from a JS source string. Scans for `const = [` or `const = {` + * and captures through the balanced closing brace/bracket. + */ +function extractConstFromSource(source, name) { + // Try array form: const NAME = [ + let arrayMarker = `const ${name} = [`; + let start = source.indexOf(arrayMarker); + let openChar = '['; + let closeChar = ']'; + + if (start === -1) { + // Try object form: const NAME = { + const objectMarker = `const ${name} = {`; + start = source.indexOf(objectMarker); + openChar = '{'; + closeChar = '}'; + if (start === -1) { + throw new Error(`Could not find const ${name} in compiled source`); + } + } + + const braceOpen = source.indexOf(openChar, start); + if (braceOpen === -1) throw new Error(`Could not find opening ${openChar} for const ${name}`); + + let depth = 0; + let i = braceOpen; + for (; i < source.length; i++) { + if (source[i] === openChar) depth++; + else if (source[i] === closeChar) { + depth--; + if (depth === 0) break; + } + } + if (depth !== 0) throw new Error(`Could not find closing ${closeChar} for const ${name}`); + + // Return the full `const NAME = [...];` or `const NAME = {...};` + // Find the semicolon after the closing bracket + const afterClose = source.indexOf(';', i); + const end = afterClose !== -1 ? afterClose + 1 : i + 1; + return source.slice(start, end); +} + +export async function buildSchemaDetectCjs() { + const distUrl = new URL('../dist/query/schema-detect.js', import.meta.url); + const { + detectSchemaFiles, + checkSchemaDrift, + } = await import(distUrl.href); + + const compiledSource = await readFile(fileURLToPath(distUrl), 'utf-8'); + + // Extract non-exported constants from source text + const schemaPatternsDecl = extractConstFromSource(compiledSource, 'SCHEMA_PATTERNS'); + const ormInfoDecl = extractConstFromSource(compiledSource, 'ORM_INFO'); + + // Get exported function bodies via Function.prototype.toString() + const detectSchemaFilesBody = detectSchemaFiles.toString(); + const checkSchemaDriftBody = checkSchemaDrift.toString(); + + // detectSchemaOrm is not in the SDK but CJS callers may use it. + // Reconstruct it as a simple ORM_INFO lookup (same as original secrets.cjs). + const detectSchemaOrmBody = `function detectSchemaOrm(ormName) { + return ORM_INFO[ormName] || null; +}`; + + const parts = [ + BANNER.trimEnd(), + '', + '// ─── ORM Patterns ───────────────────────────────────────────────────────────', + schemaPatternsDecl, + '', + '// ─── Push Commands & Evidence Patterns ──────────────────────────────────────', + ormInfoDecl, + '', + '// ─── Public API ──────────────────────────────────────────────────────────────', + detectSchemaFilesBody, + '', + detectSchemaOrmBody, + '', + checkSchemaDriftBody, + '', + 'module.exports = {', + ' SCHEMA_PATTERNS,', + ' ORM_INFO,', + ' detectSchemaFiles,', + ' detectSchemaOrm,', + ' checkSchemaDrift,', + '};', + '', + ]; + + return parts.join('\n'); +} + +async function main() { + const content = await buildSchemaDetectCjs(); + const outPath = fileURLToPath( + new URL('../../get-shit-done/bin/lib/schema-detect.generated.cjs', import.meta.url), + ); + await writeFile(outPath, content, 'utf-8'); + console.log(`Written: ${outPath}`); +} + +// Only run main() when this file is the entry point, not when imported. +// process.argv[1] is already an absolute filesystem path on every platform Node +// supports; comparing directly avoids the Windows URL-parsing bug where +// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`. +if (fileURLToPath(import.meta.url) === process.argv[1]) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/sdk/scripts/gen-secrets.mjs b/sdk/scripts/gen-secrets.mjs new file mode 100644 index 000000000..cabd38e79 --- /dev/null +++ b/sdk/scripts/gen-secrets.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * Generator for the Secrets CJS artifact. + * + * Reads the compiled ESM output from sdk/dist/query/secrets.js, + * extracts function source via Function.prototype.toString() for exports, + * then emits get-shit-done/bin/lib/secrets.generated.cjs. + * + * Run: cd sdk && npm run gen:secrets + * Freshness check: node sdk/scripts/check-secrets-fresh.mjs + */ + +import { writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +export const BANNER = `'use strict'; + +/** + * GENERATED FILE — DO NOT EDIT. + * + * Source: sdk/src/query/secrets.ts + * Regenerate: cd sdk && npm run gen:secrets + * + * Secrets handling — masking convention for API keys and other + * credentials managed via /gsd-settings-integrations. + * This module does not read the filesystem. + */ + +`; + +export async function buildSecretsCjs() { + // Load the compiled ESM module to get exports via Function.prototype.toString() + const distUrl = new URL('../dist/query/secrets.js', import.meta.url); + const { + SECRET_CONFIG_KEYS, + isSecretKey, + maskSecret, + maskIfSecret, + } = await import(distUrl.href); + + // Get exported function bodies via Function.prototype.toString() + const isSecretKeyBody = isSecretKey.toString(); + const maskSecretBody = maskSecret.toString(); + const maskIfSecretBody = maskIfSecret.toString(); + + // SECRET_CONFIG_KEYS is a Set — reconstruct it as a constant declaration + const secretKeys = [...SECRET_CONFIG_KEYS]; + const secretKeysLiteral = secretKeys.map(k => ` '${k}',`).join('\n'); + + const parts = [ + BANNER.trimEnd(), + '', + 'const SECRET_CONFIG_KEYS = new Set([', + secretKeysLiteral, + ']);', + '', + isSecretKeyBody, + '', + maskSecretBody, + '', + maskIfSecretBody, + '', + 'module.exports = { SECRET_CONFIG_KEYS, isSecretKey, maskSecret, maskIfSecret };', + '', + ]; + + return parts.join('\n'); +} + +async function main() { + const content = await buildSecretsCjs(); + const outPath = fileURLToPath( + new URL('../../get-shit-done/bin/lib/secrets.generated.cjs', import.meta.url), + ); + await writeFile(outPath, content, 'utf-8'); + console.log(`Written: ${outPath}`); +} + +// Only run main() when this file is the entry point, not when imported. +// process.argv[1] is already an absolute filesystem path on every platform Node +// supports; comparing directly avoids the Windows URL-parsing bug where +// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`. +if (fileURLToPath(import.meta.url) === process.argv[1]) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/sdk/scripts/gen-state-document.ts b/sdk/scripts/gen-state-document.ts index 874d23090..0f09855c0 100644 --- a/sdk/scripts/gen-state-document.ts +++ b/sdk/scripts/gen-state-document.ts @@ -132,9 +132,10 @@ async function main(): Promise { } // Only run main() when this file is the entry point, not when imported. -const scriptPath = fileURLToPath(import.meta.url); -const entryPath = process.argv[1] ? new URL(process.argv[1], 'file://').pathname : ''; -if (scriptPath === entryPath || process.argv[1] === scriptPath) { +// process.argv[1] is already an absolute filesystem path on every platform Node +// supports; comparing directly avoids the Windows URL-parsing bug where +// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`. +if (fileURLToPath(import.meta.url) === process.argv[1]) { main().catch((err) => { console.error(err); process.exit(1); diff --git a/sdk/scripts/gen-workstream-inventory-builder.mjs b/sdk/scripts/gen-workstream-inventory-builder.mjs index 26b8da3a5..0c8f1fdad 100644 --- a/sdk/scripts/gen-workstream-inventory-builder.mjs +++ b/sdk/scripts/gen-workstream-inventory-builder.mjs @@ -109,9 +109,10 @@ async function main() { } // Only run main() when this file is the entry point, not when imported. -const scriptPath = fileURLToPath(import.meta.url); -const entryPath = process.argv[1] ? new URL(process.argv[1], 'file://').pathname : ''; -if (scriptPath === entryPath || process.argv[1] === scriptPath) { +// process.argv[1] is already an absolute filesystem path on every platform Node +// supports; comparing directly avoids the Windows URL-parsing bug where +// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`. +if (fileURLToPath(import.meta.url) === process.argv[1]) { main().catch((err) => { console.error(err); process.exit(1); diff --git a/sdk/scripts/gen-workstream-name-policy.mjs b/sdk/scripts/gen-workstream-name-policy.mjs new file mode 100644 index 000000000..d531f53b5 --- /dev/null +++ b/sdk/scripts/gen-workstream-name-policy.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +/** + * Generator for the Workstream Name Policy CJS artifact. + * + * Reads the compiled ESM output from sdk/dist/workstream-name-policy.js, + * extracts function source via text transformation, + * then emits get-shit-done/bin/lib/workstream-name-policy.generated.cjs. + * + * Source-of-truth: sdk/src/workstream-name-policy.ts + * + * Run: cd sdk && npm run gen:workstream-name-policy + * Freshness check: node sdk/scripts/check-workstream-name-policy-fresh.mjs + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +export const BANNER = `'use strict'; + +/** + * GENERATED FILE — DO NOT EDIT. + * + * Source: sdk/src/workstream-name-policy.ts + * Regenerate: cd sdk && npm run gen:workstream-name-policy + * + * Canonical workstream name validation and slug normalization. + * Used by active-workstream-store.cjs, planning-workspace.cjs, workstream.cjs. + */ + +`; + +export async function buildWorkstreamNamePolicyCjs() { + // Read the compiled ESM source and transform to CJS. + const distPath = fileURLToPath(new URL('../dist/workstream-name-policy.js', import.meta.url)); + const src = await readFile(distPath, 'utf-8'); + + // Transform ESM to CJS: + // 1. Remove import statements (none expected in this file) + // 2. Remove ESM export keywords + // 3. Remove source map comment + // 4. Remove leading jsdoc comment + // 5. Add module.exports at end + + let body = src; + + // Remove leading import statements (if any) + body = body.replace(/^import\s+.*?;[\r\n]*/gm, ''); + + // Remove ESM export keywords (keep function/const declarations) + body = body.replace(/^export (function|const) /gm, '$1 '); + + // Remove source map comment + body = body.replace(/\/\/# sourceMappingURL=.*$/gm, ''); + + // Remove leading file-level jsdoc comment (keep module-level logic only) + body = body.replace(/^\/\*\*[\s\S]*?\*\/\n/m, ''); + + // Trim extra blank lines at start/end + body = body.trim(); + + const parts = [ + BANNER.trimEnd(), + '', + body, + '', + 'module.exports = {', + ' validateWorkstreamName,', + ' toWorkstreamSlug,', + ' hasInvalidPathSegment,', + ' isValidActiveWorkstreamName,', + '};', + '', + ]; + + return parts.join('\n'); +} + +async function main() { + const content = await buildWorkstreamNamePolicyCjs(); + const outPath = fileURLToPath( + new URL('../../get-shit-done/bin/lib/workstream-name-policy.generated.cjs', import.meta.url), + ); + await writeFile(outPath, content, 'utf-8'); + console.log(`Written: ${outPath}`); +} + +// Only run main() when this file is the entry point, not when imported. +// process.argv[1] is already an absolute filesystem path on every platform Node +// supports; comparing directly avoids the Windows URL-parsing bug where +// `C:\\…\\gen-*.mjs` is misread as scheme "c:" by `new URL(...)`. +if (fileURLToPath(import.meta.url) === process.argv[1]) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/sdk/src/golden/golden.integration.test.ts b/sdk/src/golden/golden.integration.test.ts index 47216490d..a319a5e99 100644 --- a/sdk/src/golden/golden.integration.test.ts +++ b/sdk/src/golden/golden.integration.test.ts @@ -476,16 +476,12 @@ describe('Golden file tests', () => { }); it('state.prune dry-run matches gsd-tools.cjs', async () => { - // Prune needs a parseable current_phase. Use fresh dirs with a STATE.md - // whose frontmatter includes current_phase so both CJS and SDK agree. - // CJS extracts current phase from disk-counted phases (result: 0 phases → "Only 0 phases..."), - // SDK extracts from frontmatter current_phase field. - // Use only 2 keepRecent phases, leaving phases dir empty so CJS reports "Only 0 phases" - // and SDK also bails early (current_phase=10, cutoff=7, but no phases to scan → same reason). - // Align via a fixture that has current_phase in frontmatter AND no phases on disk. + // Both CJS and SDK read `Current Phase` from the STATE.md body text + // (CJS: stateExtractField(content, 'Current Phase'), SDK: same). + // MINIMAL_STATE has no `Current Phase:` field → both default to 0 → + // cutoff = 0 - 3 = -3 ≤ 0 → "Only 0 phases — nothing to prune with --keep-recent 3". const gsdDir2 = join(tmpdir(), `gsd-golden-prune-gsd-${Date.now()}`); const sdkDir2 = join(tmpdir(), `gsd-golden-prune-sdk-${Date.now()}`); - // Minimal state — no phases on disk, prune returns "Only N phases — nothing to prune" try { await setupMinimalStateProject(gsdDir2); await setupMinimalStateProject(sdkDir2); @@ -493,41 +489,28 @@ describe('Golden file tests', () => { const gsdOutput = await captureGsdToolsOutput('state', argv, gsdDir2); const registry = createRegistry(); const sdkResult = await registry.dispatch('state.prune', ['--keep-recent', '3', '--dry-run'], sdkDir2); - // Both should return pruned:false. Exact reason may differ (CJS: phase count from disk; - // SDK: phase count from frontmatter). Compare just the structural result. - const sdkData = sdkResult.data as Record; - const gsdData = gsdOutput as Record; - expect(sdkData.pruned).toBe(false); - expect(gsdData.pruned).toBe(false); - expect(typeof sdkData.reason).toBe('string'); - expect(typeof gsdData.reason).toBe('string'); + // Exact equality — both CJS and SDK now use the same phase extraction logic. + expect(sdkResult.data).toEqual(gsdOutput); } finally { await rm(gsdDir2, { recursive: true, force: true }); await rm(sdkDir2, { recursive: true, force: true }); } }); - it('state.record-metric matches gsd-tools.cjs (no-metrics-section → divergence documented)', async () => { - // Divergence: CJS auto-creates the Performance Metrics section when absent; - // SDK returns { recorded: false, reason: '...' }. We test both via fresh dirs - // and add a metrics section to align behavior for parity. + it('state.record-metric matches gsd-tools.cjs (no-metrics-section → SDK auto-creates like CJS)', async () => { + // SDK now auto-creates the ## Performance Metrics section when absent, + // matching CJS DWIM behavior. Test with no pre-seeded section to exercise + // the auto-create path on both sides. const gsdDir2 = join(tmpdir(), `gsd-golden-state-metric-gsd-${Date.now()}`); const sdkDir2 = join(tmpdir(), `gsd-golden-state-metric-sdk-${Date.now()}`); try { - const metricsState = MINIMAL_STATE + [ - '', - '## Performance Metrics', - '', - '| Phase | Plan | Duration | Notes |', - '|-------|------|----------|-------|', - '', - ].join('\n'); + // Use MINIMAL_STATE (no metrics section) — both sides should auto-create it. await mkdir(join(gsdDir2, '.planning', 'phases'), { recursive: true }); - await writeFile(join(gsdDir2, '.planning', 'STATE.md'), metricsState, 'utf-8'); + await writeFile(join(gsdDir2, '.planning', 'STATE.md'), MINIMAL_STATE, 'utf-8'); await writeFile(join(gsdDir2, '.planning', 'ROADMAP.md'), '# Roadmap\n', 'utf-8'); await writeFile(join(gsdDir2, '.planning', 'config.json'), '{"model_profile":"balanced"}', 'utf-8'); await mkdir(join(sdkDir2, '.planning', 'phases'), { recursive: true }); - await writeFile(join(sdkDir2, '.planning', 'STATE.md'), metricsState, 'utf-8'); + await writeFile(join(sdkDir2, '.planning', 'STATE.md'), MINIMAL_STATE, 'utf-8'); await writeFile(join(sdkDir2, '.planning', 'ROADMAP.md'), '# Roadmap\n', 'utf-8'); await writeFile(join(sdkDir2, '.planning', 'config.json'), '{"model_profile":"balanced"}', 'utf-8'); @@ -535,6 +518,7 @@ describe('Golden file tests', () => { const gsdOutput = await captureGsdToolsOutput('state', argv, gsdDir2); const registry = createRegistry(); const sdkResult = await registry.dispatch('state.record-metric', ['--phase', '10', '--plan', '1', '--duration', '45m', '--tasks', '12', '--files', '8'], sdkDir2); + // Exact equality — SDK now auto-creates Performance Metrics section like CJS. expect(sdkResult.data).toEqual(gsdOutput); } finally { await rm(gsdDir2, { recursive: true, force: true }); @@ -903,4 +887,145 @@ describe('Golden file tests', () => { expect(sdkResult.data).toEqual(gsdOutput); }); }); + + // ─── Phase 6: verify.* parity tests ──────────────────────────────────────── + + describe('verify.references', () => { + it('SDK JSON matches gsd-tools.cjs', async () => { + const testPhase = '9'; + const gsdOutput = await captureGsdToolsOutput('verify', ['references', testPhase], REPO_ROOT); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('verify.references', [testPhase], REPO_ROOT); + expect(sdkResult.data).toEqual(gsdOutput); + }); + }); + + describe('verify.commits', () => { + it('SDK JSON matches gsd-tools.cjs', async () => { + const testPhase = '9'; + const gsdOutput = await captureGsdToolsOutput('verify', ['commits', testPhase], REPO_ROOT); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('verify.commits', [testPhase], REPO_ROOT); + expect(sdkResult.data).toEqual(gsdOutput); + }); + }); + + describe('verify.artifacts', () => { + it('SDK JSON matches gsd-tools.cjs', async () => { + const testPhase = '9'; + const gsdOutput = await captureGsdToolsOutput('verify', ['artifacts', testPhase], REPO_ROOT); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('verify.artifacts', [testPhase], REPO_ROOT); + expect(sdkResult.data).toEqual(gsdOutput); + }); + }); + + describe('verify.key-links', () => { + it('SDK JSON matches gsd-tools.cjs', async () => { + const testPhase = '9'; + const gsdOutput = await captureGsdToolsOutput('verify', ['key-links', testPhase], REPO_ROOT); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('verify.key-links', [testPhase], REPO_ROOT); + expect(sdkResult.data).toEqual(gsdOutput); + }); + }); + + describe('verify.schema-drift', () => { + it('SDK JSON matches gsd-tools.cjs', async () => { + const testPhase = '9'; + const gsdOutput = await captureGsdToolsOutput('verify', ['schema-drift', testPhase], REPO_ROOT); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('verify.schema-drift', [testPhase], REPO_ROOT); + expect(sdkResult.data).toEqual(gsdOutput); + }); + }); + + describe('verify.codebase-drift', () => { + it('SDK JSON matches gsd-tools.cjs', async () => { + const gsdOutput = await captureGsdToolsOutput('verify', ['codebase-drift'], REPO_ROOT); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('verify.codebase-drift', [], REPO_ROOT); + expect(sdkResult.data).toEqual(gsdOutput); + }); + }); + + // ─── Phase 6: roadmap.* parity tests ─────────────────────────────────────── + + describe('roadmap.annotate-dependencies', () => { + it('roadmap.annotate-dependencies matches gsd-tools.cjs on fixture', async () => { + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const gsdDir = join(tmpdir(), `gsd-golden-roadmap-annotate-gsd-${suffix}`); + const sdkDir = join(tmpdir(), `gsd-golden-roadmap-annotate-sdk-${suffix}`); + try { + await setupPhasesFixture(gsdDir); + await setupPhasesFixture(sdkDir); + const gsdOutput = await captureGsdToolsOutput('roadmap', ['annotate-dependencies', '10'], gsdDir); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('roadmap.annotate-dependencies', ['10'], sdkDir); + expect(sdkResult.data).toEqual(gsdOutput); + } finally { + await rm(gsdDir, { recursive: true, force: true }); + await rm(sdkDir, { recursive: true, force: true }); + } + }); + }); + + // ─── Phase 6: phase.* parity tests ──────────────────────────────────────── + + describe('phase.next-decimal', () => { + it('phase.next-decimal matches gsd-tools.cjs on fixture', async () => { + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const gsdDir = join(tmpdir(), `gsd-golden-phase-nd-gsd-${suffix}`); + const sdkDir = join(tmpdir(), `gsd-golden-phase-nd-sdk-${suffix}`); + try { + await setupMinimalStateProject(gsdDir); + await setupMinimalStateProject(sdkDir); + const gsdOutput = await captureGsdToolsOutput('phase', ['next-decimal', '10'], gsdDir); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('phase.next-decimal', ['10'], sdkDir); + expect(sdkResult.data).toEqual(gsdOutput); + } finally { + await rm(gsdDir, { recursive: true, force: true }); + await rm(sdkDir, { recursive: true, force: true }); + } + }); + }); + + describe('phase.remove and phase.complete', () => { + it('phase.remove matches gsd-tools.cjs on fixture', async () => { + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const gsdDir = join(tmpdir(), `gsd-golden-phase-rm-gsd-${suffix}`); + const sdkDir = join(tmpdir(), `gsd-golden-phase-rm-sdk-${suffix}`); + try { + await setupPhasesFixture(gsdDir); + await setupPhasesFixture(sdkDir); + // Both remove phase 11 (complete in fixture, safe to remove with --force) + const gsdOutput = await captureGsdToolsOutput('phase', ['remove', '11', '--force'], gsdDir); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('phase.remove', ['11', '--force'], sdkDir); + expect(sdkResult.data).toEqual(gsdOutput); + } finally { + await rm(gsdDir, { recursive: true, force: true }); + await rm(sdkDir, { recursive: true, force: true }); + } + }); + + it('phase.complete matches gsd-tools.cjs on fixture', async () => { + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const gsdDir = join(tmpdir(), `gsd-golden-phase-complete-gsd-${suffix}`); + const sdkDir = join(tmpdir(), `gsd-golden-phase-complete-sdk-${suffix}`); + try { + await setupPhasesFixture(gsdDir); + await setupPhasesFixture(sdkDir); + // Both complete phase 10 (which is in the fixture ROADMAP) + const gsdOutput = await captureGsdToolsOutput('phase', ['complete', '10'], gsdDir); + const registry = createRegistry(); + const sdkResult = await registry.dispatch('phase.complete', ['10'], sdkDir); + expect(sdkResult.data).toEqual(gsdOutput); + } finally { + await rm(gsdDir, { recursive: true, force: true }); + await rm(sdkDir, { recursive: true, force: true }); + } + }); + }); }); diff --git a/sdk/src/gsd-transport.test.ts b/sdk/src/gsd-transport.test.ts index 2a0f3a2b0..2d6095198 100644 --- a/sdk/src/gsd-transport.test.ts +++ b/sdk/src/gsd-transport.test.ts @@ -205,7 +205,10 @@ describe('GSDTransport', () => { expect(result).toBe(''); expect(adapters.execSubprocessRaw).not.toHaveBeenCalled(); }); - it('forces subprocess when workstream present', async () => { + it('routes natively when workstream present (Phase 6 fix)', async () => { + // Phase 6 fix: GSDTransport no longer forces subprocess for workstream-scoped + // requests. The per-request dispatchNative closure (Phase 5.1) correctly + // threads workstream to registry.dispatch(), so native dispatch is used. const registry = new QueryRegistry(); registry.register('state.load', async () => ({ data: { ok: true } })); @@ -229,9 +232,10 @@ describe('GSDTransport', () => { allowFallbackToSubprocess: true, }); - expect(result).toEqual({ ok: 'ws-subprocess' }); - expect(adapters.dispatchNative).not.toHaveBeenCalled(); - expect(adapters.execSubprocessJson).toHaveBeenCalledOnce(); + // Native dispatch is used — subprocess is NOT called. + expect(result).toEqual({ ok: true }); + expect(adapters.dispatchNative).toHaveBeenCalledOnce(); + expect(adapters.execSubprocessJson).not.toHaveBeenCalled(); }); it('fails when command is unregistered and subprocess fallback is disabled', async () => { @@ -260,7 +264,9 @@ describe('GSDTransport', () => { expect(adapters.execSubprocessJson).not.toHaveBeenCalled(); }); - it('forces raw subprocess path when workstream present and mode is raw', async () => { + it('routes natively when workstream present and mode is raw (Phase 6 fix)', async () => { + // Phase 6 fix: workstream no longer forces subprocess. Native dispatch is used + // even in raw mode — formatNativeRaw (if set) handles the output projection. const registry = new QueryRegistry(); registry.register('commit', async () => ({ data: { hash: 'abc' } })); @@ -284,9 +290,10 @@ describe('GSDTransport', () => { allowFallbackToSubprocess: true, }); - expect(result).toBe('raw-subprocess'); - expect(adapters.dispatchNative).not.toHaveBeenCalled(); - expect(adapters.execSubprocessRaw).toHaveBeenCalledOnce(); + // Native dispatch is used — toRaw serializes data to JSON. + expect(typeof result).toBe('string'); + expect(adapters.dispatchNative).toHaveBeenCalledOnce(); + expect(adapters.execSubprocessRaw).not.toHaveBeenCalled(); expect(adapters.execSubprocessJson).not.toHaveBeenCalled(); }); }); diff --git a/sdk/src/gsd-transport.ts b/sdk/src/gsd-transport.ts index 26f436b66..d944e3ac5 100644 --- a/sdk/src/gsd-transport.ts +++ b/sdk/src/gsd-transport.ts @@ -28,7 +28,7 @@ export interface TransportPolicyLike { export interface TransportDecision { dispatchMode: 'native' | 'subprocess'; - reason?: 'workstream_forced' | 'native_not_preferred' | 'native_unregistered' | 'native_failure_fallback'; + reason?: 'native_not_preferred' | 'native_unregistered' | 'native_failure_fallback'; } export class GSDTransport { @@ -69,17 +69,18 @@ export class GSDTransport { } private shouldUseNative(request: TransportRequest, policy: TransportPolicyLike): boolean { - const forceSubprocess = Boolean(request.workstream); - return !forceSubprocess && policy.preferNative && this.registry.has(request.registryCommand); + // Phase 5.0 worker fix: dispatchNative now correctly threads projectDir and + // workstream per-request (see worker.ts dispatchNative closure). Workstream + // commands no longer need to force subprocess — native dispatch handles them. + return policy.preferNative && this.registry.has(request.registryCommand); } private subprocessReason(request: TransportRequest, policy: TransportPolicyLike): TransportDecision['reason'] { - if (request.workstream) return 'workstream_forced'; if (!policy.preferNative) return 'native_not_preferred'; if (!this.registry.has(request.registryCommand)) return 'native_unregistered'; throw new Error( - `Unexpected subprocess reason state for command '${request.registryCommand}' with preferNative=${String(policy.preferNative)} and workstream=${String(request.workstream)}`, + `Unexpected subprocess reason state for command '${request.registryCommand}' with preferNative=${String(policy.preferNative)}`, ); } diff --git a/sdk/src/query-raw-output-projection.ts b/sdk/src/query-raw-output-projection.ts index 7dcd3d6e1..c0474393e 100644 --- a/sdk/src/query-raw-output-projection.ts +++ b/sdk/src/query-raw-output-projection.ts @@ -67,6 +67,25 @@ export function formatQueryRawOutput(registryCommand: string, data: unknown): st return Array.isArray(u) && u.length > 0 ? 'true' : 'false'; } + // #3631: CJS handlers projected these to a scalar under --raw. Mirror that + // here so SDK dispatch matches CJS behaviour when family routers request + // mode: 'raw' on the bridge. + if (registryCommand === 'phase.next-decimal') { + if (data && typeof data === 'object' && !Array.isArray(data)) { + const next = (data as Record).next; + if (typeof next === 'string') return next; + } + return safeStringify(data); + } + + if (registryCommand === 'roadmap.get-phase') { + if (data && typeof data === 'object' && !Array.isArray(data)) { + const section = (data as Record).section; + if (typeof section === 'string') return section; + } + return ''; + } + if (typeof data === 'string') { return data; } diff --git a/sdk/src/query/command-aliases.generated.ts b/sdk/src/query/command-aliases.generated.ts index 17268030e..6c79b91e6 100644 --- a/sdk/src/query/command-aliases.generated.ts +++ b/sdk/src/query/command-aliases.generated.ts @@ -42,7 +42,6 @@ export const VERIFY_COMMAND_ALIASES: readonly FamilyCommandAlias[] = [ { canonical: 'verify.artifacts', aliases: ['verify artifacts'], subcommand: 'artifacts', mutation: false }, { canonical: 'verify.key-links', aliases: ['verify key-links'], subcommand: 'key-links', mutation: false }, { canonical: 'verify.schema-drift', aliases: ['verify schema-drift'], subcommand: 'schema-drift', mutation: false }, - { canonical: 'verify.codebase-drift', aliases: ['verify codebase-drift'], subcommand: 'codebase-drift', mutation: false }, ] as const; export const INIT_COMMAND_ALIASES: readonly FamilyCommandAlias[] = [ @@ -122,8 +121,6 @@ export const NON_FAMILY_COMMAND_ALIASES: readonly NonFamilyCommandAlias[] = [ { canonical: 'generate-claude-md', aliases: [], mutation: true }, { canonical: 'generate-claude-profile', aliases: [], mutation: true }, { canonical: 'generate-dev-preferences', aliases: [], mutation: true }, - { canonical: 'intel.patch-meta', aliases: ['intel patch-meta'], mutation: true }, - { canonical: 'intel.snapshot', aliases: ['intel snapshot'], mutation: true }, { canonical: 'learnings.copy', aliases: ['learnings copy'], mutation: true }, { canonical: 'learnings.delete', aliases: ['learnings delete'], mutation: true }, { canonical: 'learnings.prune', aliases: ['learnings prune'], mutation: true }, diff --git a/sdk/src/query/command-family-handlers.ts b/sdk/src/query/command-family-handlers.ts index 97f0df283..11470e8c5 100644 --- a/sdk/src/query/command-family-handlers.ts +++ b/sdk/src/query/command-family-handlers.ts @@ -15,8 +15,11 @@ import { roadmapUpdatePlanProgress } from './roadmap-update-plan-progress.js'; import { verifyPlanStructure, verifyPhaseCompleteness, verifyReferences, verifyCommits, verifyArtifacts, verifySchemaDrift, - verifyCodebaseDrift, } from './verify.js'; +// verifyCodebaseDrift intentionally NOT imported — drift is out-of-seam +// (CJS-only) per ADR/PRD docs/adr/3524-cjs-sdk-hard-seam.md §3 and +// docs/prd/3524-cjs-sdk-hard-seam.md L160. The CJS router dispatches +// verify codebase-drift directly to bin/lib/drift.cjs / verify.cjs. import { verifyKeyLinks, validateConsistency, validateHealth, validateAgents, validateContext } from './validate.js'; import { phaseListPlans, phaseListArtifacts, @@ -71,7 +74,8 @@ export const FAMILY_HANDLERS: Record { if (!validation.valid) { const suggestion = validation.suggestion ? `. Did you mean: ${validation.suggestion}?` : ''; throw new GSDError( - `Unknown config key: "${keyPath}"${suggestion}`, + `Unknown config key: ${keyPath}${suggestion}`, ErrorClassification.Validation, ); } @@ -301,6 +302,123 @@ export const configSet: QueryHandler = async (args, projectDir, workstream) => { validateShipPrBodySections(parsedValue); } + // CJS parity (config.cjs:430-441): boolean-only keys must reject non-boolean + // input. Without this, `config-set git.create_tag maybe` silently writes + // "maybe" to disk under SDK dispatch even though the CJS path correctly + // rejects it. Bug #3086. + if (keyPath === 'workflow.post_planning_gaps' && typeof parsedValue !== 'boolean') { + throw new GSDError( + `Invalid workflow.post_planning_gaps '${rawValue}'. Must be a boolean (true or false).`, + ErrorClassification.Validation, + ); + } + if (keyPath === 'git.create_tag' && typeof parsedValue !== 'boolean') { + throw new GSDError( + `Invalid git.create_tag '${rawValue}'. Must be a boolean (true or false).`, + ErrorClassification.Validation, + ); + } + + // Codebase drift detector value validation — port of config.cjs:430-437. (#2003) + const VALID_DRIFT_ACTIONS = ['warn', 'auto-remap']; + if (keyPath === 'workflow.drift_action' && !VALID_DRIFT_ACTIONS.includes(String(parsedValue))) { + throw new GSDError( + `Invalid workflow.drift_action '${rawValue}'. Valid values: ${VALID_DRIFT_ACTIONS.join(', ')}`, + ErrorClassification.Validation, + ); + } + if (keyPath === 'workflow.drift_threshold') { + if (typeof parsedValue !== 'number' || !Number.isInteger(parsedValue) || parsedValue < 1) { + throw new GSDError( + `Invalid workflow.drift_threshold '${rawValue}'. Must be a positive integer.`, + ErrorClassification.Validation, + ); + } + } + + // Human verification checkpoint mode (#3309) — port of config.cjs:457-460. + const VALID_HUMAN_VERIFY_MODES = ['mid-flight', 'end-of-phase']; + if (keyPath === 'workflow.human_verify_mode' && !VALID_HUMAN_VERIFY_MODES.includes(String(parsedValue))) { + throw new GSDError( + `Invalid workflow.human_verify_mode '${rawValue}'. Valid values: ${VALID_HUMAN_VERIFY_MODES.join(', ')}`, + ErrorClassification.Validation, + ); + } + + // Context position enum validation (#2937) — port of config.cjs:463-466. + const VALID_CONTEXT_POSITIONS = ['front', 'end']; + if (keyPath === 'statusline.context_position' && !VALID_CONTEXT_POSITIONS.includes(String(parsedValue))) { + throw new GSDError( + `Invalid statusline.context_position '${rawValue}'. Valid values: ${VALID_CONTEXT_POSITIONS.join(', ')}`, + ErrorClassification.Validation, + ); + } + + // Fallow scope + profile enum validation (#3424) — port of config.cjs:469-477. + const VALID_FALLOW_SCOPES = ['phase', 'repo']; + if (keyPath === 'code_quality.fallow.scope' && !VALID_FALLOW_SCOPES.includes(String(parsedValue))) { + throw new GSDError( + `Invalid code_quality.fallow.scope '${rawValue}'. Valid values: ${VALID_FALLOW_SCOPES.join(', ')}`, + ErrorClassification.Validation, + ); + } + const VALID_FALLOW_PROFILES = ['minimal', 'standard', 'strict']; + if (keyPath === 'code_quality.fallow.profile' && !VALID_FALLOW_PROFILES.includes(String(parsedValue))) { + throw new GSDError( + `Invalid code_quality.fallow.profile '${rawValue}'. Valid values: ${VALID_FALLOW_PROFILES.join(', ')}`, + ErrorClassification.Validation, + ); + } + + // review.default_reviewers (#3079) — port of normalizeConfiguredDefaultReviewers + // from bin/lib/review-reviewer-selection.cjs. Validates array shape, rejects + // empties, requires string slugs matching ^[a-zA-Z0-9_-]+$, and normalizes to + // lowercase-unique order. `parsedValue` is rewritten in place so the persisted + // value carries the normalized form (matching CJS config.cjs:479-483 behavior). + let normalizedValue: unknown = parsedValue; + if (keyPath === 'review.default_reviewers') { + if (parsedValue === null || parsedValue === undefined) { + throw new GSDError( + 'review.default_reviewers must be a JSON array of reviewer slugs', + ErrorClassification.Validation, + ); + } + if (!Array.isArray(parsedValue)) { + throw new GSDError( + 'review.default_reviewers must be a JSON array of reviewer slugs', + ErrorClassification.Validation, + ); + } + if (parsedValue.length === 0) { + throw new GSDError( + 'review.default_reviewers cannot be empty', + ErrorClassification.Validation, + ); + } + const seen = new Set(); + const normalized: string[] = []; + for (const item of parsedValue) { + if (typeof item !== 'string') { + throw new GSDError( + 'review.default_reviewers must contain only string slugs', + ErrorClassification.Validation, + ); + } + if (!/^[a-zA-Z0-9_-]+$/.test(item)) { + throw new GSDError( + `invalid reviewer slug in review.default_reviewers: ${item}`, + ErrorClassification.Validation, + ); + } + const slug = item.toLowerCase(); + if (!seen.has(slug)) { + seen.add(slug); + normalized.push(slug); + } + } + normalizedValue = normalized; + } + // D6: Lock protection for read-modify-write (match CJS config.cjs:296) const paths = planningPaths(projectDir, workstream); const lockPath = await acquireStateLock(paths.config); @@ -315,7 +433,7 @@ export const configSet: QueryHandler = async (args, projectDir, workstream) => { } previousValue = getValueAtPath(config, keyPath); - setConfigValue(config, keyPath, parsedValue); + setConfigValue(config, keyPath, normalizedValue); await atomicWriteConfig(paths.config, config); } finally { await releaseStateLock(lockPath); @@ -449,47 +567,57 @@ export const configNewProject: QueryHandler = async (args, projectDir, workstrea const hasFirecrawl = !!(process.env.FIRECRAWL_API_KEY || existsSync(join(homeDir, '.gsd', 'firecrawl_api_key'))); const hasExaSearch = !!(process.env.EXA_API_KEY || existsSync(join(homeDir, '.gsd', 'exa_api_key'))); - // Build default config + // Build default config. Source is the canonical Configuration Module manifest + // at sdk/shared/config-defaults.manifest.json (CONFIG_DEFAULTS from + // sdk/src/configuration/index.ts) — but ONLY a subset is materialized at + // init time. Legacy CJS `buildNewProjectConfig` (bin/lib/config.cjs:155-210) + // intentionally omits keys whose value is meaningful only when set + // explicitly so config-get returns "Key not found" and workflows fall back + // to auto-detect (e.g. git.base_branch falls back to origin/HEAD + // resolution). Keeping the SDK init shape aligned with CJS preserves that + // workflow contract while the manifest remains the schema-wide source of + // truth for validation and key existence (per ADR §6). + // + // Runtime API-key detection overrides the manifest's `false` defaults for + // the three search providers — manifest comment explicitly notes this. + const manifestDefaults = CONFIG_DEFAULTS as Record; + // Strip the metadata-only "_comment" key before it gets persisted. + const { _comment: _ignoredComment, ...sanitizedManifest } = manifestDefaults; + void _ignoredComment; + + // Top-level keys present in the manifest but NOT in CJS init output. Each + // either has its own resolution path (resolve_model_ids, context_window, + // mode) or lives under a non-init heading (planning.*, graphify.* are + // opt-in features users configure separately). + const TOP_LEVEL_OMITTED_FROM_INIT = new Set([ + 'resolve_model_ids', 'context_window', 'mode', 'planning', 'graphify', + ]); + // Nested git keys omitted by CJS init. `git.base_branch` triggers + // origin/HEAD auto-detect when absent — materializing `null` here would + // suppress that and break ship-ready preflight (#3079). + const GIT_KEYS_OMITTED_FROM_INIT = new Set(['base_branch']); + + const filteredTopLevel: Record = {}; + for (const [k, v] of Object.entries(sanitizedManifest)) { + if (TOP_LEVEL_OMITTED_FROM_INIT.has(k)) continue; + filteredTopLevel[k] = v; + } + const manifestGit = (filteredTopLevel.git as Record) || {}; + const filteredGit: Record = {}; + for (const [k, v] of Object.entries(manifestGit)) { + if (GIT_KEYS_OMITTED_FROM_INIT.has(k)) continue; + filteredGit[k] = v; + } + const defaults: Record = { - model_profile: 'balanced', - commit_docs: false, - parallelization: 1, - search_gitignored: false, + ...filteredTopLevel, + git: filteredGit, brave_search: hasBraveSearch, firecrawl: hasFirecrawl, exa_search: hasExaSearch, - git: { - branching_strategy: 'none', - phase_branch_template: 'gsd/phase-{phase}-{slug}', - milestone_branch_template: 'gsd/{milestone}-{slug}', - quick_branch_template: null, - }, - workflow: { - research: true, - plan_check: true, - verifier: true, - nyquist_validation: true, - auto_advance: false, - node_repair: true, - node_repair_budget: 2, - ui_phase: true, - ui_safety_gate: true, - text_mode: false, - research_before_questions: false, - discuss_mode: 'discuss', - skip_discuss: false, - code_review: true, - code_review_depth: 'standard', - }, - ship: { - pr_body_sections: [], - }, - hooks: { - context_warnings: true, - }, - project_code: null, - phase_naming: 'sequential', - agent_skills: {}, + // CJS `buildNewProjectConfig` includes `features: {}` as a hardcoded + // top-level slot; the manifest doesn't yet — keep parity until the + // manifest is amended in a separate enhancement. features: {}, }; @@ -535,7 +663,9 @@ export const configNewProject: QueryHandler = async (args, projectDir, workstrea await atomicWriteConfig(paths.config, config); - return { data: { created: true, path: paths.config } }; + // Match CJS `ensureConfigFile` shape: report the relative project-rooted + // path so output stays workspace-portable. + return { data: { created: true, path: '.planning/config.json' } }; }; // ─── configEnsureSection ────────────────────────────────────────────────── diff --git a/sdk/src/query/config-query.ts b/sdk/src/query/config-query.ts index 4d26d8271..09668f89e 100644 --- a/sdk/src/query/config-query.ts +++ b/sdk/src/query/config-query.ts @@ -35,6 +35,29 @@ import { const RUNTIMES_WITH_REASONING_EFFORT = runtimesWithReasoningEffort(); +/** + * Schema-level defaults for well-known config keys. + * + * Mirrors the CJS table at get-shit-done/bin/lib/config.cjs:505-510 byte-for- + * byte. When `config-get` lookups fall off the dot path and no `--default` + * was supplied, the handler consults this map before throwing + * `Key not found`. Without parity here, the SDK path emits + * CONFIG_KEY_NOT_FOUND for keys the CJS path returns transparently — every + * skill that reads `context_window`, `git.create_tag`, or executor stall + * thresholds breaks under SDK dispatch. + * + * Bugs #2943, #3086, executor-stall-defaults tests — RED→GREEN via this + * map. Keep this in lockstep with config.cjs:SCHEMA_DEFAULTS. Drift is + * detected by the bug-2943 and #3086 behavioral suites: when the table + * grows, both sides must grow together or those tests fail. + */ +const SCHEMA_DEFAULTS: Readonly> = Object.freeze({ + context_window: 200000, + 'executor.stall_detect_interval_minutes': 5, + 'executor.stall_threshold_minutes': 10, + 'git.create_tag': true, +}); + // ─── configGet ────────────────────────────────────────────────────────────── /** @@ -72,14 +95,29 @@ export const configGet: QueryHandler = async (args, projectDir, workstream) => { try { raw = await readFile(paths.config, 'utf-8'); } catch { - throw new GSDError(`No config.json found at ${paths.config}`, ErrorClassification.Validation); + // config.json missing — CJS parity (config.cjs:524-533): + // 1. --default beats everything + // 2. else SCHEMA_DEFAULTS supply a documented value (#2943) + // 3. else CONFIG_NO_FILE error + if (defaultValue !== undefined) return { data: defaultValue }; + if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) { + return { data: SCHEMA_DEFAULTS[keyPath] }; + } + const err = new GSDError(`No config.json found at ${paths.config}`, ErrorClassification.Validation); + (err as GSDError & { reason?: string }).reason = 'config_no_file'; + throw err; } let config: Record; try { config = JSON.parse(raw) as Record; } catch { - throw new GSDError(`Malformed config.json at ${paths.config}`, ErrorClassification.Validation); + // Lead the message with "Failed to read config.json" — matches the CJS + // `cmdConfigGet` / `setConfigValue` error vocabulary so tests written + // against the legacy contract keep matching. + const err = new GSDError(`Failed to read config.json: malformed JSON at ${paths.config}`, ErrorClassification.Validation); + (err as GSDError & { reason?: string }).reason = 'config_parse_failed'; + throw err; } const keys = keyPath.split('.'); @@ -88,14 +126,26 @@ export const configGet: QueryHandler = async (args, projectDir, workstream) => { if (current === undefined || current === null || typeof current !== 'object') { // UNIX convention (cf. `git config --get`): missing key exits 1, not 10. // See issue #2544 — callers use `if ! gsd-sdk query config-get k; then` patterns. + // CJS parity ordering (config.cjs:543-551): --default first, then + // SCHEMA_DEFAULTS, then CONFIG_KEY_NOT_FOUND. if (defaultValue !== undefined) return { data: defaultValue }; - throw new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Execution); + if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) { + return { data: SCHEMA_DEFAULTS[keyPath] }; + } + const err = new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Execution); + (err as GSDError & { reason?: string }).reason = 'config_key_not_found'; + throw err; } current = (current as Record)[key]; } if (current === undefined) { if (defaultValue !== undefined) return { data: defaultValue }; - throw new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Execution); + if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) { + return { data: SCHEMA_DEFAULTS[keyPath] }; + } + const err = new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Execution); + (err as GSDError & { reason?: string }).reason = 'config_key_not_found'; + throw err; } // Mask plaintext for keys in SECRET_CONFIG_KEYS to match CJS behavior at diff --git a/sdk/src/query/decisions.test.ts b/sdk/src/query/decisions.test.ts index ca7b5637e..ff8cdac20 100644 --- a/sdk/src/query/decisions.test.ts +++ b/sdk/src/query/decisions.test.ts @@ -97,9 +97,12 @@ describe('parseDecisions (#2492)', () => { }); it('does not crash on malformed bullet lines', () => { + // Phase 6 (#3575): regex now accepts alphanumeric IDs (D-[A-Za-z0-9_-]+). + // D-bogus IS now valid (pure alpha segment); only truly malformed patterns + // (no D- prefix, wrong bullet syntax) are rejected. const malformed = ` - not a decision (no D-NN) -- **D-bogus:** wrong id format +- **D-bogus:** alphanumeric id — now accepted since Phase 6 - **D-7:** single digit allowed - **D-10:** ten `; @@ -107,7 +110,10 @@ describe('parseDecisions (#2492)', () => { const ids = decisions.map((d) => d.id); expect(ids).toContain('D-7'); expect(ids).toContain('D-10'); - expect(ids).not.toContain('D-bogus'); + // D-bogus IS now accepted — alphanumeric IDs are valid since Phase 6 (#3575) + expect(ids).toContain('D-bogus'); + // Pure non-bullet text is still not parsed as a decision + expect(ids).not.toContain('D-NN'); }); it('preserves multi-line decision text continuations', () => { diff --git a/sdk/src/query/decisions.ts b/sdk/src/query/decisions.ts index b8edda27d..9c5be6296 100644 --- a/sdk/src/query/decisions.ts +++ b/sdk/src/query/decisions.ts @@ -29,7 +29,7 @@ import { isAbsolute, join } from 'node:path'; import type { QueryHandler } from './utils.js'; export interface ParsedDecision { - /** Stable id: `D-01`, `D-7`, `D-42`. */ + /** Stable id: `D-01`, `D-42`, `D-INFRA-01`, `D-FOO_BAR`. Numeric or alphanumeric. */ id: string; /** Body text (everything after `**D-NN[ tags]:**` up to next bullet/blank). */ text: string; @@ -93,7 +93,11 @@ export function parseDecisions(content: string): ParsedDecision[] { let inDiscretion = false; // Bullet line: `- **D-NN[ [tags]]:** text` - const bulletRe = /^\s*-\s+\*\*D-(\d+)(?:\s*\[([^\]]+)\])?\s*:\*\*\s*(.*)$/; + // Phase 6 (#3575): aligned to CJS regex — accepts alphanumeric IDs (D-01, D-INFRA-01, D-FOO_BAR) + // in addition to numeric-only IDs (D-42). The first character after `D-` must + // be alphanumeric, so malformed shapes like `D--foo` or `D-_bar` are rejected. + // CJS callers consume {id, text} and ignore the optional extras. + const bulletRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?\s*:\*\*\s*(.*)$/; let current: ParsedDecision | null = null; diff --git a/sdk/src/query/frontmatter-mutation.ts b/sdk/src/query/frontmatter-mutation.ts index 36948033f..b5e1b4c7d 100644 --- a/sdk/src/query/frontmatter-mutation.ts +++ b/sdk/src/query/frontmatter-mutation.ts @@ -20,7 +20,7 @@ import { readFile, writeFile } from 'node:fs/promises'; import { GSDError, ErrorClassification } from '../errors.js'; import { extractFrontmatter } from './frontmatter.js'; -import { normalizeMd, resolvePathUnderProject } from './helpers.js'; +import { normalizeMd, resolveFrontmatterPath } from './helpers.js'; import type { QueryHandler } from './utils.js'; // ─── FRONTMATTER_SCHEMAS ────────────────────────────────────────────────── @@ -193,15 +193,10 @@ export const frontmatterSet: QueryHandler = async (args, projectDir) => { throw new GSDError('file path contains null bytes', ErrorClassification.Validation); } - let fullPath: string; - try { - fullPath = await resolvePathUnderProject(projectDir, filePath); - } catch (err) { - if (err instanceof GSDError) { - return { data: { error: err.message, path: filePath } }; - } - throw err; - } + // CJS parity (frontmatter.cjs:340/354/369): no project-root prefix check. + // Bug #3509 — accept absolute paths outside the project (tmpdirs whose + // names include spaces fail the under-project test on macOS). + const fullPath = resolveFrontmatterPath(projectDir, filePath); let content: string; try { @@ -245,15 +240,10 @@ export const frontmatterMerge: QueryHandler = async (args, projectDir) => { throw new GSDError('file path contains null bytes', ErrorClassification.Validation); } - let fullPath: string; - try { - fullPath = await resolvePathUnderProject(projectDir, filePath); - } catch (err) { - if (err instanceof GSDError) { - return { data: { error: err.message, path: filePath } }; - } - throw err; - } + // CJS parity (frontmatter.cjs:340/354/369): no project-root prefix check. + // Bug #3509 — accept absolute paths outside the project (tmpdirs whose + // names include spaces fail the under-project test on macOS). + const fullPath = resolveFrontmatterPath(projectDir, filePath); let content: string; try { @@ -318,15 +308,10 @@ export const frontmatterValidate: QueryHandler = async (args, projectDir) => { ); } - let fullPath: string; - try { - fullPath = await resolvePathUnderProject(projectDir, filePath); - } catch (err) { - if (err instanceof GSDError) { - return { data: { error: err.message, path: filePath } }; - } - throw err; - } + // CJS parity (frontmatter.cjs:340/354/369): no project-root prefix check. + // Bug #3509 — accept absolute paths outside the project (tmpdirs whose + // names include spaces fail the under-project test on macOS). + const fullPath = resolveFrontmatterPath(projectDir, filePath); let content: string; try { diff --git a/sdk/src/query/frontmatter.ts b/sdk/src/query/frontmatter.ts index 3a4b87049..6ff4f4c50 100644 --- a/sdk/src/query/frontmatter.ts +++ b/sdk/src/query/frontmatter.ts @@ -19,7 +19,7 @@ import { readFile } from 'node:fs/promises'; import { GSDError, ErrorClassification } from '../errors.js'; import type { QueryHandler } from './utils.js'; -import { escapeRegex, resolvePathUnderProject } from './helpers.js'; +import { escapeRegex, resolveFrontmatterPath } from './helpers.js'; // ─── splitInlineArray ─────────────────────────────────────────────────────── @@ -363,15 +363,10 @@ export const frontmatterGet: QueryHandler = async (args, projectDir) => { throw new GSDError('file path contains null bytes', ErrorClassification.Validation); } - let fullPath: string; - try { - fullPath = await resolvePathUnderProject(projectDir, filePath); - } catch (err) { - if (err instanceof GSDError) { - return { data: { error: err.message, path: filePath } }; - } - throw err; - } + // CJS parity (frontmatter.cjs:323): no project-root prefix check — accept + // any absolute path (and macOS tmpdir paths whose names contain spaces). + // Bug #3509. + const fullPath = resolveFrontmatterPath(projectDir, filePath); let content: string; try { @@ -381,7 +376,12 @@ export const frontmatterGet: QueryHandler = async (args, projectDir) => { } const fm = extractFrontmatter(content); - const field = args[1]; + // CLI invocation is `frontmatter get --field `; the CJS router + // passes args.slice(2) = [file, '--field', name] to the SDK. Previously the + // handler treated args[1] as the field name and saw `'--field'`. Parse the + // flag so both invocation shapes work (positional second arg AND --field). + const fieldFlagIdx = args.indexOf('--field'); + const field = fieldFlagIdx >= 0 ? args[fieldFlagIdx + 1] : args[1]; if (field) { const value = fm[field]; diff --git a/sdk/src/query/helpers.ts b/sdk/src/query/helpers.ts index c23a8602b..3f41a6615 100644 --- a/sdk/src/query/helpers.ts +++ b/sdk/src/query/helpers.ts @@ -493,6 +493,26 @@ export async function resolvePathUnderProject(projectDir: string, userPath: stri return realCandidate; } +/** + * Resolve a user-supplied file path the way CJS frontmatter handlers do. + * + * Mirrors `path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath)` + * from get-shit-done/bin/lib/frontmatter.cjs (lines 323, 340, 354, 369). + * Does NOT enforce the "under project root" prefix check — frontmatter + * verbs accept arbitrary absolute paths (the user is naming a file outside + * `.planning/`, often a phase-scoped plan in an external location, or a + * tmpdir inside `/var/folders` whose path includes spaces). + * + * Bug #3509 parity: tests on macOS use `os.tmpdir()` directories that + * resolve outside the project root; the project-scoped variant was + * rejecting them with "path escapes project directory". Use this helper + * for the frontmatter family. Use `resolvePathUnderProject` for commands + * that must stay inside the project (e.g. template output, decisions). + */ +export function resolveFrontmatterPath(projectDir: string, userPath: string): string { + return isAbsolute(userPath) ? normalize(userPath) : resolve(projectDir, userPath); +} + // ─── sanitizeForDisplay (security.cjs) ─────────────────────────────────────── /** Port of `sanitizeForPrompt` from `security.cjs`. */ diff --git a/sdk/src/query/init-complex.ts b/sdk/src/query/init-complex.ts index 4f7d1473f..cc323cf73 100644 --- a/sdk/src/query/init-complex.ts +++ b/sdk/src/query/init-complex.ts @@ -642,16 +642,13 @@ export const initManager: QueryHandler = async (_args, projectDir, workstream) = } } - // Sliding window: only first undiscussed phase is available to discuss - let foundNextToDiscuss = false; + // Bug #2268: mark EVERY undiscussed phase as is_next_to_discuss, not just + // the first one. Multiple independent phases can be discussed in parallel + // — the sliding-window pattern made the manager only recommend one + // discuss action even when callers had free capacity to discuss several. for (const phase of phases) { const status = phase.disk_status as string; - if (!foundNextToDiscuss && (status === 'empty' || status === 'no_directory')) { - phase.is_next_to_discuss = true; - foundNextToDiscuss = true; - } else { - phase.is_next_to_discuss = false; - } + phase.is_next_to_discuss = (status === 'empty' || status === 'no_directory'); } // Check WAITING.json signal diff --git a/sdk/src/query/init.ts b/sdk/src/query/init.ts index db0a59a86..771238895 100644 --- a/sdk/src/query/init.ts +++ b/sdk/src/query/init.ts @@ -22,11 +22,13 @@ import { readFile, readdir } from 'node:fs/promises'; import { join, relative, basename } from 'node:path'; import { execSync } from 'node:child_process'; import { homedir } from 'node:os'; +import { GSDError, ErrorClassification } from '../errors.js'; import { loadConfig, type GSDConfig } from '../config.js'; import { resolveModel, MODEL_PROFILES } from './config-query.js'; import { maskIfSecret } from './secrets.js'; import { findPhase } from './phase.js'; +import { getMilestonePhaseFilter } from './state.js'; import { roadmapGetPhase, getMilestoneInfo, extractCurrentMilestone, extractPhasesFromSection } from './roadmap.js'; import { determinePhaseStatus } from './progress.js'; import { planningPaths, normalizePhaseName, toPosixPath, resolveAgentsDir, detectRuntime } from './helpers.js'; @@ -141,13 +143,16 @@ function computeExpectedPhaseDirName( async function shouldDropArchivedPhaseMatch( phaseInfo: Record | null, roadmapPhase: Record | null, - projectDir: string, - workstream?: string, + _projectDir: string, + _workstream?: string, ): Promise { - if (!phaseInfo?.archived || !roadmapPhase || !roadmapPhase.found) return false; - const archivedTag = String(phaseInfo.archived ?? ''); - const milestone = await getMilestoneInfo(projectDir, workstream); - if (milestone?.version && archivedTag === milestone.version) return false; + // Matches CJS cmdInitPlanPhase / cmdInitExecutePhase / cmdInitVerifyWork: + // if (phaseInfo?.archived && roadmapPhase?.found) phaseInfo = null; + // Unconditional drop — the ROADMAP is authoritative for the current milestone, + // regardless of what archived milestone the on-disk match came from. Do NOT add + // a milestone-version equality check (#2391 regression risk). + if (!phaseInfo?.archived) return false; + if (!roadmapPhase || !roadmapPhase.found) return false; return true; } @@ -367,6 +372,13 @@ export const initExecutePhase: QueryHandler = async (args, projectDir, workstrea return { data: { error: 'phase required for init execute-phase' } }; } + // --tdd is a boolean override of config.workflow.tdd_mode — matches the CJS + // path's parseNamedArgs(args, [], ['validate', 'tdd']) projection + // (bin/lib/init-command-router.cjs handler block) which passes options.tdd + // through to cmdInitExecutePhase. Without parsing here, `gsd-tools init + // execute-phase 1 --tdd` would never override a false config value. + const tddFlag = args.includes('--tdd'); + const config = await loadConfig(projectDir); const paths = planningPaths(projectDir, workstream); const planningDir = paths.planning; @@ -394,7 +406,7 @@ export const initExecutePhase: QueryHandler = async (args, projectDir, workstrea const result: Record = { executor_model: executorModel, verifier_model: verifierModel, - tdd_mode: config.workflow.tdd_mode ?? false, + tdd_mode: tddFlag || (config.workflow.tdd_mode ?? false), commit_docs: config.commit_docs, sub_repos: (config as Record).sub_repos ?? [], parallelization: config.parallelization, @@ -450,6 +462,10 @@ export const initPlanPhase: QueryHandler = async (args, projectDir, workstream) return { data: { error: 'phase required for init plan-phase' } }; } + // --tdd boolean override (parity with CJS router's parseNamedArgs + the + // legacy cmdInitPlanPhase `options.tdd || config.tdd_mode || false`). + const tddFlag = args.includes('--tdd'); + const config = await loadConfig(projectDir); const paths = planningPaths(projectDir, workstream); const planningDir = paths.planning; @@ -498,7 +514,7 @@ export const initPlanPhase: QueryHandler = async (args, projectDir, workstream) researcher_model: researcherModel, planner_model: plannerModel, checker_model: checkerModel, - tdd_mode: config.workflow.tdd_mode ?? false, + tdd_mode: tddFlag || (config.workflow.tdd_mode ?? false), research_enabled: config.workflow.research, plan_checker_enabled: config.workflow.plan_check, nyquist_validation_enabled: config.workflow.nyquist_validation, @@ -568,8 +584,14 @@ export const initNewMilestone: QueryHandler = async (_args, projectDir) => { let phaseDirCount = 0; try { if (existsSync(phasesDir)) { + // Bug #2445 parity with CJS `cmdInitNewMilestone`: filter phase dirs + // to the current milestone so stale dirs from a prior milestone that + // weren't archived don't inflate the count. Without this filter the + // SDK returns the full directory count, which the new-milestone + // workflow then uses to gate "is this a fresh start" decisions. + const isDirInMilestone = await getMilestonePhaseFilter(projectDir); phaseDirCount = readdirSync(phasesDir, { withFileTypes: true }) - .filter(entry => entry.isDirectory()) + .filter(entry => entry.isDirectory() && isDirInMilestone(entry.name)) .length; } } catch { /* intentionally empty */ } @@ -1052,7 +1074,12 @@ export const initMapCodebase: QueryHandler = async (_args, projectDir) => { commit_docs: config.commit_docs, search_gitignored: config.search_gitignored, parallelization: config.parallelization, - subagent_timeout: (config as Record).subagent_timeout ?? undefined, + // subagent_timeout lives at workflow.subagent_timeout per the canonical + // Configuration manifest (sdk/shared/config-defaults.manifest.json). Reading + // the top-level config.subagent_timeout returned undefined, so the workflow + // step that consumes this value had to invent its own fallback. Default to + // 300000 (5 min) per the manifest. (#1472) + subagent_timeout: (((config as Record).workflow as Record | undefined)?.subagent_timeout as number | undefined) ?? 300000, date: now.toISOString().split('T')[0], timestamp: now.toISOString(), codebase_dir: '.planning/codebase', @@ -1174,12 +1201,18 @@ export const initListWorkspaces: QueryHandler = async (_args, _projectDir) => { export const initRemoveWorkspace: QueryHandler = async (args, _projectDir) => { const name = args[0]; if (!name) { - return { data: { error: 'workspace name required for init remove-workspace' } }; + // Throw so the CLI dispatcher projects a non-zero exit + writes the message + // to stderr — returning `{ data: { error } }` was treated as success by + // the CLI output path, hiding the validation failure from callers. + throw new GSDError('workspace name required for init remove-workspace', ErrorClassification.Validation); } // T-14-01: Reject path traversal attempts if (name.includes('/') || name.includes('\\') || name.includes('..')) { - return { data: { error: `Invalid workspace name: ${name} (path separators not allowed)` } }; + throw new GSDError( + `Invalid workspace name: ${name} (path separators not allowed)`, + ErrorClassification.Validation, + ); } const home = process.env.HOME || homedir(); @@ -1188,7 +1221,7 @@ export const initRemoveWorkspace: QueryHandler = async (args, _projectDir) => { const manifestPath = join(wsPath, 'WORKSPACE.md'); if (!existsSync(wsPath)) { - return { data: { error: `Workspace not found: ${wsPath}` } }; + throw new GSDError(`Workspace not found: ${wsPath}`, ErrorClassification.Validation); } const repos: Array> = []; diff --git a/sdk/src/query/phase-lifecycle.ts b/sdk/src/query/phase-lifecycle.ts index 1dc4e83f3..d2a4176d2 100644 --- a/sdk/src/query/phase-lifecycle.ts +++ b/sdk/src/query/phase-lifecycle.ts @@ -32,8 +32,9 @@ import { planningPaths, } from './helpers.js'; import { extractFrontmatter } from './frontmatter.js'; -import { extractCurrentMilestone } from './roadmap.js'; +import { extractCurrentMilestone, phaseMarkdownRegexSource } from './roadmap.js'; import { getMilestonePhaseFilter } from './state.js'; +import { isCanonicalPlanFile, describeNonCanonicalPlans } from './phase.js'; import { acquireStateLock, readModifyWriteStateMdFull, @@ -86,27 +87,43 @@ export { readModifyWriteRoadmapMd, replaceInCurrentMilestone }; */ export const phaseAdd: QueryHandler = async (args, projectDir, workstream) => { // ── Flag parsing ──────────────────────────────────────────────────────── - // Separate recognized flags from positional args. Any unrecognized --flag - // is rejected immediately so it is never silently absorbed into positional slots. - const RECOGNIZED_FLAGS = new Set(['--dry-run']); + // Mirrors the CJS phase add router (phase-command-router.cjs): recognise + // --dry-run and --id ; reject every other --flag; ignore --raw so it + // never leaks into the description; join the remaining positional tokens + // with a single space so multi-word descriptions like `phase add User + // Dashboard` produce description "User Dashboard". customId comes from the + // --id flag, never from positional[1]. let dryRun = false; + let customIdArg: string | null = null; const positional: string[] = []; - for (const arg of args) { - if (arg.startsWith('--')) { - if (!RECOGNIZED_FLAGS.has(arg)) { - throw new GSDError( - `Unknown flag ${arg} for phase.add`, - ErrorClassification.Validation, - ); - } - if (arg === '--dry-run') dryRun = true; - } else { - positional.push(arg); + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (arg === '--raw') { + // CJS router strips --raw before invoking the handler; preserve parity + // so a stray --raw never poisons the description. + continue; } + if (arg === '--dry-run') { + dryRun = true; + continue; + } + if (arg === '--id') { + const id = args[i + 1]; + if (!id || id.startsWith('--')) { + throw new GSDError('--id requires a value', ErrorClassification.Validation); + } + customIdArg = id; + i++; + continue; + } + if (arg.startsWith('--')) { + throw new GSDError(`phase add does not support ${arg}`, ErrorClassification.Validation); + } + positional.push(arg); } - const description = positional[0]; + const description = positional.join(' ').trim(); if (!description) { throw new GSDError('description required for phase add', ErrorClassification.Validation); } @@ -119,8 +136,9 @@ export const phaseAdd: QueryHandler = async (args, projectDir, workstream) => { } catch { /* use defaults */ } const slug = generatePhaseSlug(description); - // positional[1] is the optional customId — flags are already stripped - const customId = positional[1] || null; + // customId always comes from the --id flag; positional tokens are reserved + // for the description (which is joined above). + const customId = customIdArg; // Optional project code prefix (e.g., 'CK' -> 'CK-01-foundation') const projectCode = (config.project_code as string) || ''; @@ -227,17 +245,25 @@ export const phaseAdd: QueryHandler = async (args, projectDir, workstream) => { export const phaseAddBatch: QueryHandler = async (args, projectDir, workstream) => { let descriptions: string[]; const descIdx = args.indexOf('--descriptions'); - if (descIdx !== -1 && args[descIdx + 1] !== undefined) { - try { - const parsed = JSON.parse(args[descIdx + 1]) as unknown; - if (!Array.isArray(parsed)) { - throw new GSDError('--descriptions must be a JSON array', ErrorClassification.Validation); - } - descriptions = parsed.map((x) => String(x)); - } catch (e) { - if (e instanceof GSDError) throw e; - throw new GSDError('--descriptions must be a valid JSON array', ErrorClassification.Validation); + if (descIdx !== -1) { + // CJS router parity (phase-command-router.cjs): a dangling --descriptions + // or one whose value is another flag must surface the same JSON-array error + // string, not silently fall through to positional parsing or throw a + // different "valid JSON" variant. + const rawValue = args[descIdx + 1]; + if (rawValue === undefined || rawValue.startsWith('--')) { + throw new GSDError('--descriptions must be a JSON array', ErrorClassification.Validation); } + let parsed: unknown; + try { + parsed = JSON.parse(rawValue); + } catch { + throw new GSDError('--descriptions must be a JSON array', ErrorClassification.Validation); + } + if (!Array.isArray(parsed)) { + throw new GSDError('--descriptions must be a JSON array', ErrorClassification.Validation); + } + descriptions = parsed.map((x) => String(x)); } else { descriptions = args.filter((a) => a !== '--raw'); } @@ -345,8 +371,25 @@ export const phaseAddBatch: QueryHandler = async (args, projectDir, workstream) * @returns QueryResult with { phase_number, after_phase, name, slug, directory } */ export const phaseInsert: QueryHandler = async (args, projectDir, workstream) => { - const afterPhase = args[0]; - const description = args[1]; + // CJS router parity (phase-command-router.cjs): explicitly reject + // --dry-run (insert is destructive on disk + roadmap and has no preview + // path), strip --raw, and join all positional args after `afterPhase` into + // a single space-delimited description so `phase insert 1 Fix Critical Bug` + // produces description "Fix Critical Bug" instead of just "Fix". + const positional: string[] = []; + for (const arg of args) { + if (arg === '--dry-run') { + throw new GSDError('phase insert does not support --dry-run', ErrorClassification.Validation); + } + if (arg === '--raw') continue; + if (arg.startsWith('--')) { + throw new GSDError(`phase insert does not support ${arg}`, ErrorClassification.Validation); + } + positional.push(arg); + } + + const afterPhase = positional[0]; + const description = positional.slice(1).join(' ').trim(); if (!afterPhase || !description) { throw new GSDError('after-phase and description required for phase insert', ErrorClassification.Validation); @@ -367,6 +410,19 @@ export const phaseInsert: QueryHandler = async (args, projectDir, workstream) => const afterPhaseEscaped = unpadded.replace(/\./g, '\\.'); const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i'); if (!targetPattern.test(content)) { + // Bug #3098 parity: when only the summary checklist exists for this + // phase (no `### Phase N:` detail section), point the user at the + // missing detail section rather than implying the phase is absent. + const checklistPattern = new RegExp( + `-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+0*${afterPhaseEscaped}:`, + 'i', + ); + if (checklistPattern.test(content)) { + throw new GSDError( + `Phase ${afterPhase} exists in roadmap summary but is missing a detail section (### Phase ${afterPhase}: ...).`, + ErrorClassification.Validation, + ); + } throw new GSDError(`Phase ${afterPhase} not found in ROADMAP.md`, ErrorClassification.Validation); } @@ -699,7 +755,11 @@ async function renameIntegerPhases( const m = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i); if (!m) return null; const dirInt = parseInt(m[1], 10); - if (dirInt <= removedInt) return null; + // CJS parity: skip backlog phases (999.x). These are parked ideas with a + // numbering convention that lives outside the active sequence; renumbering + // them would clobber the convention and corrupt downstream lookups. + // (bug-2434) + if (dirInt <= removedInt || dirInt >= 999) return null; return { dir, oldInt: dirInt, @@ -742,12 +802,65 @@ async function renameIntegerPhases( // ─── updateRoadmapAfterPhaseRemoval ──────────────────────────────────── +/** + * Decrement integer phase number while skipping non-renumbered ranges. Mirrors + * `decrementRoadmapPhaseNumber` in phase.cjs lines 860-864. + * + * Skips when: + * • not an integer + * • num <= removedInt (already-renumbered phases stay put) + * • num >= 999 (backlog/parked-idea numbering range) + * + * Returns the original raw string when the guards trip so the regex pass + * leaves dates and unrelated numerics intact. + */ +function decrementRoadmapPhaseNumber(raw: string, removedInt: number): string { + const num = parseInt(raw, 10); + if (!Number.isInteger(num) || num <= removedInt || num >= 999) return raw; + return String(num - 1); +} + +/** + * Decrement integer or decimal phase token (e.g. "5" or "5.2"). Mirrors + * `decrementRoadmapPhaseToken` in phase.cjs lines 866-872 — preserves the + * decimal suffix when present and applies the same guards. + */ +function decrementRoadmapPhaseToken(raw: string, removedInt: number): string { + const match = String(raw).match(/^(\d+)(\.\d+)?$/); + if (!match) return raw; + const num = parseInt(match[1]!, 10); + if (!Number.isInteger(num) || num <= removedInt || num >= 999) return raw; + return `${num - 1}${match[2] || ''}`; +} + +/** + * Decrement zero-padded phase number while preserving the original pad width. + * Mirrors `decrementRoadmapPaddedPhaseNumber` in phase.cjs lines 874-878. + */ +function decrementRoadmapPaddedPhaseNumber(raw: string, removedInt: number): string { + const num = parseInt(raw, 10); + if (!Number.isInteger(num) || num <= removedInt || num >= 999) return raw; + return String(num - 1).padStart(raw.length, '0'); +} + /** * Remove a phase section from ROADMAP.md and renumber subsequent integer phases. * - * Port of updateRoadmapAfterPhaseRemoval from phase.cjs lines 569-595. + * Port of updateRoadmapAfterPhaseRemoval from phase.cjs lines 880-922. * Uses readModifyWriteRoadmapMd for atomic writes. * + * The renumbering pass uses **5 targeted regex replacements** (not a loop) + * because the loop approach is dangerous: + * • It can match YYYY-MM-DD substrings and corrupt dates (bug-2435). + * • It can rename backlog phases (999.x) that should stay frozen (bug-2434). + * • It can renumber the same phase multiple times if the regex matches + * overlap (bug-3355 — phase 7 → 6 → 5 → ...). + * + * The CJS pattern uses negative lookbehind/ahead on the padded-prefix regex + * to skip dates and decrement helpers that guard against `num >= 999`. Keep + * this implementation byte-for-byte in lockstep with phase.cjs:880-922 — + * deviations are how the three bugs above slipped in. + * * @param projectDir - Project root directory * @param targetPhase - Phase identifier that was removed * @param isDecimal - Whether the removed phase was a decimal phase @@ -763,9 +876,30 @@ async function updateRoadmapAfterPhaseRemoval( await readModifyWriteRoadmapMd(projectDir, (content) => { const escaped = escapeRegex(targetPhase); - // Remove the phase section (header + body until next phase header or end) + // Remove the phase section (header + body until next phase header or end). + // + // #3601: the end-of-section lookahead is DEPTH-AWARE. The named capture + // (?#{2,4}) records the hash count of the header being removed and the + // lookahead requires the same depth via \k(?!#). Two contracts are + // preserved: + // + // (#3601 case) Remove `### Phase 2:` and stop at `### Phase 2.1:` — + // Phase 2.1 is a peer-level decimal phase (depth 3) and must survive. + // + // (#3355 case) Remove `### Phase 27:` and CONTINUE past + // `#### Phase 27.1:` (depth 4 — child of Phase 27) until the next + // depth-3 header. The child decimal is part of the integer phase + // being removed. + // + // The `(?!#)` negative lookahead after the backreference prevents the + // depth-3 match from being satisfied by a depth-4+ header that starts + // with the same three hashes. `[^\n:]+` accepts numeric, decimal, AND + // custom phase IDs (PROJ-42) as terminators. content = content.replace( - new RegExp(`\\n?#{2,4}\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`, 'i'), + new RegExp( + `\\n?(?#{2,4})\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n\\k(?!#)\\s+Phase\\s+[^\\n:]+\\s*:|$)`, + 'i', + ), '', ); @@ -781,46 +915,59 @@ async function updateRoadmapAfterPhaseRemoval( '', ); - // For integer phase removal, renumber all subsequent phases in ROADMAP text if (!isDecimal) { - const MAX_PHASE = 99; - for (let oldNum = MAX_PHASE; oldNum > removedInt; oldNum--) { - const newNum = oldNum - 1; - const oldStr = String(oldNum); - const newStr = String(newNum); - const oldPad = oldStr.padStart(2, '0'); - const newPad = newStr.padStart(2, '0'); + // Phase headers: ### Phase N: / ### Phase N.M: + content = content.replace( + /(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)(\s*:)/gi, + (_match, prefix: string, num: string, suffix: string) => + `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`, + ); - // Renumber phase headers: ### Phase N: - content = content.replace( - new RegExp(`(#{2,4}\\s*Phase\\s+)${escapeRegex(oldStr)}(\\s*:)`, 'gi'), - `$1${newStr}$2`, - ); + // Checkbox-list summary references: `- [ ] Phase N:` + content = content.replace( + /(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi, + (_match, prefix: string, num: string, suffix: string) => + `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`, + ); - // Renumber inline Phase N references - content = content.replace( - new RegExp(`(Phase\\s+)${escapeRegex(oldStr)}([:\\s])`, 'g'), - `$1${newStr}$2`, - ); + // Table-row phase numbers: `| N. ` — bare integer in a cell. + content = content.replace( + /(\|\s*)(\d+)(\.\s)/g, + (_match, prefix: string, num: string, suffix: string) => + `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`, + ); - // Renumber padded plan references: 07-01 -> 06-01 - content = content.replace( - new RegExp(`${escapeRegex(oldPad)}-(\\d{2})`, 'g'), - `${newPad}-$1`, - ); + // Padded plan references: NN-NN (optionally followed by an arbitrary + // kebab-case slug, then -PLAN.md / -SUMMARY.md). + // + // #2435: negative lookbehind `(? + `${decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`, + ); - // Renumber table row phase numbers: | 7. -> | 6. - content = content.replace( - new RegExp(`(\\|\\s*)${escapeRegex(oldStr)}\\.\\s`, 'g'), - `$1${newStr}. `, - ); - - // Renumber depends-on references - content = content.replace( - new RegExp(`(\\*\\*Depends on:\\*\\*\\s*Phase\\s+)${escapeRegex(oldStr)}\\b`, 'gi'), - `$1${newStr}`, - ); - } + // Depends-on references — two bold-colon variants in the wild. + content = content.replace( + /(\*\*Depends on\*\*\s*:\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi, + (_match, prefix: string, num: string) => + `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`, + ); + content = content.replace( + /(Depends on:\*\*\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi, + (_match, prefix: string, num: string) => + `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`, + ); } return content; @@ -1095,14 +1242,23 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream) // Step C: Update ROADMAP.md atomically if (existsSync(paths.roadmap)) { await readModifyWriteRoadmapMd(projectDir, async (roadmapContent) => { - const phaseEscaped = escapeRegex(phaseNum); + // Padding-tolerant fragment so a padded input like "02.7" still matches + // un-padded ROADMAP prose ("### Phase 2.7:"). CJS routes every phase- + // number ROADMAP regex through phaseMarkdownRegexSource (#3537) — + // mirror that contract here so phase.complete with the padded form + // produces the same ROADMAP as the un-padded form. + const phaseEscaped = phaseMarkdownRegexSource(phaseNum); // Checkbox: - [ ] Phase N: -> - [x] Phase N: (...completed DATE) + // CJS parity (phase.cjs): direct replace, NOT scoped through + // replaceInCurrentMilestone. Same reasoning as the plan-count + // update below — milestone wrapped in
would otherwise be + // skipped (bug-2005). const checkboxPattern = new RegExp( `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`, 'i', ); - roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`); + roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`); // Progress table: update Status to Complete, add date const tableRowPattern = new RegExp( @@ -1123,13 +1279,18 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream) return '|' + cells.join('|') + '|'; }); - // Update plan count in phase section + // Update plan count in phase section. + // CJS parity (phase.cjs:1076-1083): direct replace, NOT scoped through + // replaceInCurrentMilestone. Scoping to "after last
" fails + // when the current milestone itself is wrapped in
... + //
— there's no content after the close tag, so the regex + // never matches and **Plans:** stays at 0/N (bug-2005). const planCountPattern = new RegExp( `(#{2,4}\\s*Phase\\s+${phaseEscaped}(?:(?!\\n#{2,4})[\\s\\S])*?\\*\\*Plans:\\*\\*[ \\t]*)[^\\n]+`, 'i', ); - roadmapContent = replaceInCurrentMilestone( - roadmapContent, planCountPattern, + roadmapContent = roadmapContent.replace( + planCountPattern, `$1${summaryCount}/${planCount} plans complete`, ); @@ -1156,12 +1317,15 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream) const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : ''; const reqMatch = sectionText.match(/\*\*Requirements\*?\*?:?\s*([^\n]+)/i); + let reqContent = await readFile(reqPath, 'utf-8'); + let reqContentChanged = false; + if (reqMatch) { const reqIds = reqMatch[1].replace(/[[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean); - let reqContent = await readFile(reqPath, 'utf-8'); for (const reqId of reqIds) { const reqEscaped = escapeRegex(reqId); + const before = reqContent; // Update checkbox: - [ ] **REQ-ID** -> - [x] **REQ-ID** reqContent = reqContent.replace( new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'), @@ -1172,8 +1336,42 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream) new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*(?:Pending|In Progress)\\s*(\\|)`, 'gi'), '$1 Complete $2', ); + if (reqContent !== before) reqContentChanged = true; } + } + // Bug #2526 parity (phase.cjs:1140-1167): independent of whether the + // roadmap declared a Requirements: line, scan the REQUIREMENTS.md + // body for `**REQ-ID**` references and compare against the IDs that + // actually appear in the Traceability table. Surface every body + // ID that has no traceability row so the operator can keep the + // table in sync. + const bodyReqIds: string[] = []; + const bodyReqPattern = /\*\*([A-Z][A-Z0-9]*-\d+)\*\*/g; + let bodyMatch: RegExpExecArray | null; + while ((bodyMatch = bodyReqPattern.exec(reqContent)) !== null) { + if (!bodyReqIds.includes(bodyMatch[1]!)) bodyReqIds.push(bodyMatch[1]!); + } + + const traceabilityHeadingMatch = reqContent.match(/^#{1,6}\s+Traceability\b/im); + const traceabilitySection = traceabilityHeadingMatch + ? reqContent.slice(traceabilityHeadingMatch.index!) + : ''; + const tableReqIds = new Set(); + const tableRowPattern = /^\|\s*([A-Z][A-Z0-9]*-\d+)\s*\|/gm; + let tableMatch: RegExpExecArray | null; + while ((tableMatch = tableRowPattern.exec(traceabilitySection)) !== null) { + tableReqIds.add(tableMatch[1]!); + } + + const unregistered = bodyReqIds.filter((id) => !tableReqIds.has(id)); + if (unregistered.length > 0) { + warnings.push( + `REQUIREMENTS.md: ${unregistered.length} REQ-ID(s) found in body but missing from Traceability table: ${unregistered.join(', ')} — add them manually to keep traceability in sync`, + ); + } + + if (reqContentChanged) { await writeFile(reqPath, reqContent, 'utf-8'); requirementsUpdated = true; } @@ -1217,6 +1415,12 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream) for (const dir of dirs) { const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i); if (dm) { + // Bug #2129 parity: skip backlog phases (999.x). They are parked + // ideas with reserved numbering, not part of the active sequence. + // Without this, completing phase 2 in a project that has a 999.1 + // backlog directory would jump next_phase to 999.1 instead of the + // intended Phase 3 from ROADMAP. + if (/^999(?:\.|$)/.test(dm[1]!)) continue; if (comparePhaseNum(dm[1], phaseNum) > 0) { nextPhaseNum = dm[1]; nextPhaseName = dm[2] || null; @@ -1433,6 +1637,24 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream) } } + // Step F2: Auto-prune STATE.md decisions when `workflow.auto_prune_state` + // is true. Mirrors CJS cmdPhaseComplete (bin/lib/phase.cjs:1378-1390) which + // calls cmdStatePrune({keepRecent:'3', dryRun:false, silent:true}). Without + // this, completing phase N with auto_prune_state=true leaves stale [Phase + // 1..N-3] decisions in STATE.md forever. (#2087) + let autoPruned = false; + try { + if (existsSync(paths.config)) { + const rawConfig = JSON.parse(await readFile(paths.config, 'utf-8')) as Record; + const wf = rawConfig.workflow as Record | undefined; + if (wf && wf.auto_prune_state === true && existsSync(paths.state)) { + const { statePrune } = await import('./state-mutation.js'); + await statePrune(['--keep-recent', '3', '--silent'], projectDir, workstream); + autoPruned = true; + } + } + } catch { /* best-effort, matches CJS */ } + // Step G: Return result return { data: { @@ -1446,6 +1668,7 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream) roadmap_updated: existsSync(paths.roadmap), state_updated: stateUpdated, requirements_updated: requirementsUpdated, + auto_pruned: autoPruned, warnings, has_warnings: warnings.length > 0, }, @@ -1547,13 +1770,19 @@ export const phasesList: QueryHandler = async (args, projectDir, workstream) => if (type) { const files: string[] = []; + const warnings: string[] = []; for (const dir of dirs) { const dirPath = join(phasesDir, dir); if (!existsSync(dirPath)) continue; const dirFiles = await readdir(dirPath); let filtered: string[]; if (type === 'plans') { - filtered = dirFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); + filtered = dirFiles.filter(isCanonicalPlanFile); + // #2893 parity — surface plan-shaped files the canonical filter + // rejected so callers (executor init, etc.) don't silently see zero + // plans. Per-dir prefix mirrors phase.cjs:120. + const w = describeNonCanonicalPlans(dirFiles, filtered); + if (w) warnings.push(`${dir}: ${w}`); } else if (type === 'summaries') { filtered = dirFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); } else { @@ -1561,7 +1790,13 @@ export const phasesList: QueryHandler = async (args, projectDir, workstream) => } files.push(...filtered.sort()); } - return { data: { files, count: files.length, phase_dir: phase ? dirs[0]?.replace(/^\d+(?:\.\d+)*-?/, '') : null } }; + const result: Record = { + files, + count: files.length, + phase_dir: phase ? dirs[0]?.replace(/^\d+(?:\.\d+)*-?/, '') : null, + }; + if (warnings.length) result['warning'] = warnings.join(' | '); + return { data: result }; } return { data: { directories: dirs, count: dirs.length } }; diff --git a/sdk/src/query/phase-roadmap-mutation.ts b/sdk/src/query/phase-roadmap-mutation.ts index 6b62f2405..2bb53e62e 100644 --- a/sdk/src/query/phase-roadmap-mutation.ts +++ b/sdk/src/query/phase-roadmap-mutation.ts @@ -5,7 +5,21 @@ import { acquireStateLock, releaseStateLock } from './state-mutation.js'; /** * Replace a pattern only in the current milestone section of ROADMAP.md. * - * Port of replaceInCurrentMilestone from core.cjs line 1197-1206. + * Port of replaceInCurrentMilestone from core.cjs lines 1013-1022. + * + * Semantics (byte-for-byte CJS parity): + * • No `` in the content → plain `content.replace(pattern, replacement)`. + * • Otherwise → split at the last `` and replace only in the + * content AFTER it. + * + * INTENTIONALLY DOES NOT fall back to "search the last
block when + * the after-slice didn't match." That fallback existed in an earlier SDK + * port and would silently corrupt shipped-milestone content when the current + * milestone is itself wrapped in `
...
` and there's + * nothing after the close tag. CJS callers handle the "milestone inside + *
" case by passing the unscoped `content.replace(...)` directly + * (see phase.cjs:1080 for plan-count update). Keep this function in + * lockstep with core.cjs — deviations are how bug-2005 slipped in. */ export function replaceInCurrentMilestone( content: string, @@ -19,30 +33,7 @@ export function replaceInCurrentMilestone( const offset = lastDetailsClose + '
'.length; const before = content.slice(0, offset); const after = content.slice(offset); - - const replacedAfter = after.replace(pattern, replacement); - if (replacedAfter !== after) { - return before + replacedAfter; - } - - const detailsBlockRe = /
[\s\S]*?<\/details>/gi; - const spans: { start: number; end: number; text: string }[] = []; - let m: RegExpExecArray | null; - while ((m = detailsBlockRe.exec(content)) !== null) { - spans.push({ start: m.index, end: m.index + m[0].length, text: m[0] }); - } - - if (spans.length === 0) { - return content.replace(pattern, replacement); - } - - const lastSpan = spans[spans.length - 1]; - const updatedLastBlock = lastSpan.text.replace(pattern, replacement); - return ( - content.slice(0, lastSpan.start) + - updatedLastBlock + - content.slice(lastSpan.end) - ); + return before + after.replace(pattern, replacement); } /** diff --git a/sdk/src/query/phase.ts b/sdk/src/query/phase.ts index 0a7511416..d6e23839d 100644 --- a/sdk/src/query/phase.ts +++ b/sdk/src/query/phase.ts @@ -17,6 +17,7 @@ * ``` */ +import { existsSync } from 'node:fs'; import { readFile, readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { GSDError, ErrorClassification } from '../errors.js'; @@ -47,10 +48,54 @@ interface PhaseInfo { has_verification: boolean; has_reviews: boolean; archived?: string; + /** + * #2893 — non-canonical plan filename warning (singular). Present only when + * a plan-shaped file in this phase dir is not the canonical + * `{padded_phase}-{NN}-PLAN.md` shape; the executor surfaces this so users + * see a loud signal instead of plan_count: 0 with no clue why. + */ + warning?: string; } // ─── Internal helpers ────────────────────────────────────────────────────── +/** + * #2893 — canonical plan filename predicate and the diagnostic "looks like a + * plan but isn't canonical" net. Centralised so every read site (find-phase, + * phase-plan-index, phases list --type plans) emits the same warning message. + * + * Mirrors get-shit-done/bin/lib/phase.cjs lines 17–52. + */ +export const isCanonicalPlanFile = (f: string): boolean => f.endsWith('-PLAN.md') || f === 'PLAN.md'; + +const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\.md$/i; +const PLAN_PRE_BOUNCE_RE = /-PLAN.*\.pre-bounce\.md$/i; +const looksLikePlanFile = (f: string): boolean => + /\.md$/i.test(f) + && /PLAN/i.test(f) + && !PLAN_OUTLINE_RE.test(f) + && !PLAN_PRE_BOUNCE_RE.test(f); + +/** + * Build the canonical "non-canonical plan files" warning string used by every + * SDK read site. Returns null when there are no offenders. + * + * Format mirrors describeNonCanonicalPlans in phase.cjs so consumers see the + * same message regardless of which entry point they call. + */ +export function describeNonCanonicalPlans(dirFiles: string[], matchedFiles: string[]): string | null { + const matched = new Set(matchedFiles); + const offenders = dirFiles.filter((f) => looksLikePlanFile(f) && !matched.has(f)); + if (offenders.length === 0) return null; + return ( + `Found ${offenders.length} plan-shaped file(s) in this phase that don't match the canonical ` + + `naming convention "{padded_phase}-{NN}-PLAN.md" (or bare "PLAN.md") and were skipped: ` + + offenders.map((f) => `"${f}"`).join(', ') + + `. Rename to the canonical form (e.g. "01-01-PLAN.md") so the executor can detect them. ` + + `See agents/gsd-planner.md write_phase_prompt step for the full contract.` + ); +} + /** * Get file stats for a phase directory. * @@ -63,15 +108,17 @@ async function getPhaseFileStats(phaseDir: string): Promise<{ hasContext: boolean; hasVerification: boolean; hasReviews: boolean; + allFiles: string[]; }> { const files = await readdir(phaseDir); return { - plans: files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'), + plans: files.filter(isCanonicalPlanFile), summaries: files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'), hasResearch: files.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'), hasContext: files.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'), hasVerification: files.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'), hasReviews: files.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'), + allFiles: files, }; } @@ -111,9 +158,12 @@ async function searchPhaseInDir(baseDir: string, relBase: string, normalized: st const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; const phaseDir = join(baseDir, match); - const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = await getPhaseFileStats(phaseDir); + const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews, allFiles } = await getPhaseFileStats(phaseDir); const plans = unsortedPlans.sort(); const summaries = unsortedSummaries.sort(); + // #2893 parity — emit the same warning shape as cmdPhasePlanIndex when a + // plan-shaped file would be skipped by the canonical filter. + const planNamingWarning = describeNonCanonicalPlans(allFiles, plans); const completedPlanIds = new Set( summaries.flatMap((s) => { @@ -128,7 +178,7 @@ async function searchPhaseInDir(baseDir: string, relBase: string, normalized: st return !completedPlanIds.has(planId) && !completedPlanIds.has(canonical); }); - return { + const result: PhaseInfo = { found: true, directory: toPosixPath(join(relBase, match)), phase_number: phaseNumber, @@ -142,6 +192,8 @@ async function searchPhaseInDir(baseDir: string, relBase: string, normalized: st has_verification: hasVerification, has_reviews: hasReviews, }; + if (planNamingWarning) result.warning = planNamingWarning; + return result; } catch { return null; } @@ -180,23 +232,15 @@ export const findPhase: QueryHandler = async (args, projectDir, workstream) => { const phasesDir = planningPaths(projectDir, workstream).phases; const normalized = normalizePhaseName(phase); - const notFound: PhaseInfo = { - found: false, - directory: null, - phase_number: null, - phase_name: null, - phase_slug: null, - plans: [], - summaries: [], - incomplete_plans: [], - has_research: false, - has_context: false, - has_verification: false, - has_reviews: false, - }; + // Track every directory we actually probed so the not-found payload can + // surface them to the caller for diagnostics (#3164 acceptance criterion). + const searchedDirectories: string[] = []; // Search current phases first const relPhasesDir = relPlanningPath(workstream) + '/phases'; + if (existsSync(phasesDir)) { + searchedDirectories.push(relPhasesDir); + } const current = await searchPhaseInDir(phasesDir, relPhasesDir, normalized); if (current) return { data: current }; @@ -215,6 +259,7 @@ export const findPhase: QueryHandler = async (args, projectDir, workstream) => { const version = versionMatch ? versionMatch[1] : archiveName; const archivePath = join(milestonesDir, archiveName); const relBase = '.planning/milestones/' + archiveName; + searchedDirectories.push(relBase); const result = await searchPhaseInDir(archivePath, relBase, normalized); if (result) { result.archived = version; @@ -223,6 +268,21 @@ export const findPhase: QueryHandler = async (args, projectDir, workstream) => { } } catch { /* milestones dir doesn't exist */ } + const notFound: PhaseInfo & { searched_directories: string[] } = { + found: false, + directory: null, + phase_number: null, + phase_name: null, + phase_slug: null, + plans: [], + summaries: [], + incomplete_plans: [], + has_research: false, + has_context: false, + has_verification: false, + has_reviews: false, + searched_directories: searchedDirectories, + }; return { data: notFound }; }; @@ -285,13 +345,11 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream) // Get all files in phase directory const phaseFiles = await readdir(phaseDir); - const planFiles = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort(); + const planFiles = phaseFiles.filter(isCanonicalPlanFile).sort(); const summaryFiles = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); - const nonCanonicalPlanFiles = phaseFiles.filter((f) => ( - f.toLowerCase().endsWith('.md') - && /(^|-)plan(-|\.)/i.test(f) - && !(f.endsWith('-PLAN.md') || f === 'PLAN.md') - )).sort(); + // #2893 parity — same diagnostic format as find-phase / phases-list. Use the + // centralised helper so the message shape never drifts between read sites. + const planNamingWarning = describeNonCanonicalPlans(phaseFiles, planFiles); // Build set of plan IDs with summaries — match the planId derivation logic const completedPlanIds = new Set( @@ -483,10 +541,6 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream) let hasCheckpoints = false; const warnings: string[] = []; - if (nonCanonicalPlanFiles.length > 0) { - warnings.push(`Ignored noncanonical plan files: ${nonCanonicalPlanFiles.join(', ')}`); - } - // Surface unresolved depends_on references from Pass 2 — without this, a dropped // short-form edge silently collapses the dependent plan into wave 1 and the only // signal is a misleading "declared wave: N but depends_on DAG places it in wave 1" @@ -542,6 +596,12 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream) incomplete, has_checkpoints: hasCheckpoints, }; + // #2893 — non-canonical plan filename warning is a singular `warning` field; + // see describeNonCanonicalPlans above. Other diagnostics (unresolved deps, + // wave-declaration mismatches) flow through the existing `warnings` array. + if (planNamingWarning) { + result['warning'] = planNamingWarning; + } if (warnings.length > 0) { result['warnings'] = warnings; } diff --git a/sdk/src/query/roadmap.ts b/sdk/src/query/roadmap.ts index 6e3f14cad..04a77f0fd 100644 --- a/sdk/src/query/roadmap.ts +++ b/sdk/src/query/roadmap.ts @@ -309,6 +309,15 @@ export async function extractCurrentMilestone(content: string, projectDir: strin const matchedVersion = m[1]; // Skip headings that reference the same version (e.g. "## v2.0 Phase Details"). if (matchedVersion && currentVersionStr && matchedVersion === currentVersionStr) continue; + // Bug #2787: skip "heading-like" lines that sit inside a fenced code + // block. GFM fences toggle on a line starting with ``` or ~~~ (with + // optional info string); the closing fence must be the same char with + // no info string. Walk forward from the start of restContent up to + // the match index, toggling fenceChar. If we're inside a fence at the + // match, ignore this match and continue scanning. Without this, a + // line like `# Ops runbook — v1.0 compat` inside ```bash truncates the + // milestone slice and hides every phase that follows. + if (isInsideFencedCodeBlock(restContent, m.index)) continue; sectionEnd = sectionStart + sectionMatch[0].length + m.index; break; } @@ -366,6 +375,46 @@ export async function extractCurrentMilestone(content: string, projectDir: strin return content.slice(sectionStart, sectionEnd) + phaseDetailsTail; } +/** + * Return true when `offset` falls inside an open GFM fenced code block + * within the provided `content`. + * + * GFM fence semantics (bug #2787): + * - Opening fence: a line starting with at least 3 backticks or 3 tildes, + * optionally followed by an info string (e.g. ```bash, ~~~markdown). + * - Closing fence: a line starting with at least 3 of the SAME char as + * the opener, with NO info string — so ```js inside an open ```text + * fence does NOT close it. + * + * We walk lines from the start of `content` to `offset`, toggling a + * `fenceChar` cursor on each fence boundary. Returns true when the + * cursor is non-null at `offset`. + */ +function isInsideFencedCodeBlock(content: string, offset: number): boolean { + let fenceChar: '`' | '~' | null = null; + let lineStart = 0; + for (let i = 0; i <= offset; i++) { + if (i === content.length || content[i] === '\n') { + const line = content.slice(lineStart, i); + const openMatch = line.match(/^(`{3,}|~{3,})(\s*)([^\n]*)$/); + if (openMatch) { + const fenceRun = openMatch[1]!; + const ch = fenceRun[0] === '`' ? '`' : '~'; + const info = openMatch[3]!.trim(); + if (fenceChar === null) { + // Opening fence — info string allowed. + fenceChar = ch; + } else if (ch === fenceChar && info.length === 0) { + // Closing fence must match opener and carry no info string. + fenceChar = null; + } + } + lineStart = i + 1; + } + } + return fenceChar !== null; +} + // ─── Next-milestone helpers (issue #2497) ───────────────────────────────── /** @@ -484,41 +533,89 @@ export async function extractNextMilestoneSection( // ─── Internal helpers ───────────────────────────────────────────────────── +/** + * Padding-tolerant regex fragment for a phase number — emits `0*` so + * the fragment matches both `Phase 3` and `Phase 03` (bug #2391 / #3537). + * + * Mirrors `phaseMarkdownRegexSource` in core.cjs and the local copy in + * roadmap-update-plan-progress.ts. Falls back to `escapeRegex(phaseNum)` for + * non-numeric IDs (custom project codes like `PROJ-42`). + */ +export function phaseMarkdownRegexSource(phaseNum: string): string { + const stripped = String(phaseNum).replace(/^[A-Z]{1,6}-(?=\d)/i, ''); + const match = stripped.match(/^0*(\d+)([A-Z])?((?:\.\d+)*)$/i); + if (!match) return escapeRegex(phaseNum); + + const integer = match[1]!.replace(/^0+/, '') || '0'; + const letter = match[2] ? escapeRegex(match[2]) : ''; + const decimal = match[3] ? escapeRegex(match[3]) : ''; + return `0*${escapeRegex(integer)}${letter}${decimal}`; +} + +/** + * #3599 (parity with core.cjs phaseMarkdownRegexSourceExact, lines 691-708): + * when the caller passed a project-code-prefixed ID like `PROJ-42`, return + * the exact-escaped form so the caller can search the ROADMAP for + * `### Phase PROJ-42:` BEFORE falling back to the padding-tolerant numeric + * form. Returns null when the input has no project-code prefix — in that + * case `phaseMarkdownRegexSource` is the only form the caller needs. + * + * Two-pass at the call site preserves the #3537 contract (`CK-01` directory + * names mapping to `Phase 1:` prose) while letting `PROJ-42` resolve to its + * own prefixed heading without cross-matching a bare `### Phase 42:` that + * happens to share the trailing integer. + */ +export function phaseMarkdownRegexSourceExact(phaseNum: string): string | null { + const raw = String(phaseNum); + if (!/^[A-Z]{1,6}-(?=\d)/i.test(raw)) return null; + return escapeRegex(raw); +} + /** * Search for a phase section in roadmap content. * * Port of searchPhaseInContent from roadmap.cjs lines 14-73. */ function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: string): PhaseSection | null { - // Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name + // Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name. + // Uses the padding-tolerant fragment so zero-padded inputs ("03") match + // unpadded ROADMAP headings ("### Phase 3:"). See #2391 / #3537. + // Capture group 1 = the as-written phase token from the heading so callers + // get the canonical form (matching the ROADMAP source-of-truth), not the + // padded input the user typed. Without this, `roadmap get-phase 02.7` + // and `roadmap get-phase 2.7` produce divergent payloads for the same + // heading, breaking bug-3537 parity. const phasePattern = new RegExp( - `#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, + `#{2,4}\\s*Phase\\s+(${escapedPhase}):\\s*([^\\n]+)`, 'i' ); const headerMatch = content.match(phasePattern); if (!headerMatch) { - // Fallback: check if phase exists in summary list but missing detail section + // Fallback: check if phase exists in summary list but missing detail section. + // Same canonical-token capture: surface the as-written checklist form. const checklistPattern = new RegExp( - `-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${escapedPhase}:\\s*([^*]+)\\*\\*`, + `-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+(${escapedPhase}):\\s*([^*]+)\\*\\*`, 'i' ); const checklistMatch = content.match(checklistPattern); if (checklistMatch) { + const canonicalChecklistPhase = checklistMatch[1]; return { found: false, - phase_number: phaseNum, - phase_name: checklistMatch[1].trim(), + phase_number: canonicalChecklistPhase, + phase_name: checklistMatch[2].trim(), error: 'malformed_roadmap', - message: `Phase ${phaseNum} exists in summary list but missing "### Phase ${phaseNum}:" detail section. ROADMAP.md needs both formats.`, + message: `Phase ${canonicalChecklistPhase} exists in summary list but missing "### Phase ${canonicalChecklistPhase}:" detail section. ROADMAP.md needs both formats.`, }; } return null; } - const phaseName = headerMatch[1].trim(); + const canonicalPhaseNum = headerMatch[1]; + const phaseName = headerMatch[2].trim(); const headerIndex = headerMatch.index!; // Find the end of this section (next ## or ### phase header, or end of file) @@ -546,9 +643,13 @@ function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: s ? criteriaMatch[1].trim().split('\n').map(line => line.replace(/^\s*\d+\.\s*/, '').trim()).filter(Boolean) : []; + // Suppress unused-arg warning — `phaseNum` is retained as the function + // signature so future callers can reintroduce input-mirroring if needed. + void phaseNum; + return { found: true, - phase_number: phaseNum, + phase_number: canonicalPhaseNum, phase_name: phaseName, goal, mode, @@ -609,14 +710,38 @@ export const roadmapGetPhase: QueryHandler = async (args, projectDir, workstream } const milestoneContent = await extractCurrentMilestone(rawContent, projectDir, workstream); - const escapedPhase = escapeRegex(phaseNum); - - // Search the current milestone slice first, then fall back to full roadmap. const fullContent = stripShippedMilestones(rawContent); - const milestoneResult = searchPhaseInContent(milestoneContent, escapedPhase, phaseNum); + + // Two-pass lookup (parity with bin/lib/roadmap.cjs #3599 path): if the input + // carries a project-code prefix like `PROJ-42`, try the EXACT escaped form + // first so we match `### Phase PROJ-42:` without cross-matching `### Phase 42:`. + // Only fall back to the padding-tolerant numeric form (which strips the + // prefix per the #3537 contract for CK-01 → Phase 1 directory layout) when + // the exact form misses. + const exactEscaped = phaseMarkdownRegexSourceExact(phaseNum); + // Padding-tolerant fragment (bug #2391): caller may pass "03" — match against + // unpadded ROADMAP headings ("Phase 3:") without forcing the caller to normalize. + const numericEscaped = phaseMarkdownRegexSource(phaseNum); + + // Try exact-prefixed match first when applicable. + let milestoneResult: PhaseSection | null = null; + let fallbackFromFullContent: PhaseSection | null = null; + if (exactEscaped) { + milestoneResult = searchPhaseInContent(milestoneContent, exactEscaped, phaseNum); + if (!milestoneResult || milestoneResult.error) { + fallbackFromFullContent = searchPhaseInContent(fullContent, exactEscaped, phaseNum); + } + } + // Padding-tolerant fallback (#3537) — also covers the no-prefix case. + if (!milestoneResult || milestoneResult.error) { + milestoneResult = milestoneResult || searchPhaseInContent(milestoneContent, numericEscaped, phaseNum); + } + if (!fallbackFromFullContent) { + fallbackFromFullContent = searchPhaseInContent(fullContent, numericEscaped, phaseNum); + } const result = (milestoneResult && !milestoneResult.error) ? milestoneResult - : searchPhaseInContent(fullContent, escapedPhase, phaseNum) || milestoneResult; + : fallbackFromFullContent || milestoneResult; if (!result) { return { data: { found: false, phase_number: phaseNum } }; @@ -670,6 +795,12 @@ export const roadmapAnalyze: QueryHandler = async (_args, projectDir, workstream const dependsMatch = section.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i); const depends_on = dependsMatch ? dependsMatch[1].trim() : null; + // **Mode:** field — vertical-MVP slice flag per CONTEXT.md "MVP Mode" + // glossary. Pattern mirrors the roadmapGetPhase extraction above so the + // analyze output surfaces the same value the get-phase handler returns. + const modeMatchPhase = section.match(/\*\*Mode(?::\*\*|\*\*:)\s*([^\n]+)/i); + const mode = modeMatchPhase ? modeMatchPhase[1].trim().toLowerCase() : null; + // Check completion on disk const normalized = normalizePhaseName(phaseNum); let diskStatus = 'no_directory'; @@ -714,6 +845,7 @@ export const roadmapAnalyze: QueryHandler = async (_args, projectDir, workstream name: phaseName, goal, depends_on, + mode, plan_count: planCount, summary_count: summaryCount, has_context: hasContext, @@ -788,12 +920,21 @@ export const roadmapAnnotateDependencies: QueryHandler = async (args, projectDir const { spawnSync } = await import('node:child_process'); const toolsPath = resolveGsdToolsPath(projectDir); + // CRITICAL: set GSD_SDK_NESTED=1 so the CJS router in the child process + // detects nesting and routes directly to cmdRoadmapAnnotateDependencies + // instead of dispatching back through executeForCjs. Without this guard, + // SDK→spawn(gsd-tools)→router→SDK→spawn(gsd-tools)→… loops until the + // synckit 15s timeout fires and bug-3537's annotate test surfaces a + // misleading "code=null" failure. + const childEnv: NodeJS.ProcessEnv = { ...process.env, GSD_SDK_NESTED: '1' }; + const result = spawnSync(process.execPath, [toolsPath, 'roadmap', 'annotate-dependencies', phase], { cwd: projectDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 15000, maxBuffer: 1024 * 1024, + env: childEnv, }); if (result.error) { diff --git a/sdk/src/query/state-mutation.test.ts b/sdk/src/query/state-mutation.test.ts index bef63a2a1..e4c69b08d 100644 --- a/sdk/src/query/state-mutation.test.ts +++ b/sdk/src/query/state-mutation.test.ts @@ -1147,7 +1147,12 @@ describe('statePrune current phase extraction (#3471)', () => { if (tmpDir) await rm(tmpDir, { recursive: true, force: true }); }); - it('uses frontmatter progress.completed_phases when body Current Phase field is absent', async () => { + it('reads Current Phase from body text (CJS-aligned); frontmatter progress fields are not used', async () => { + // Phase 6 alignment: SDK now uses stateExtractField(content, 'Current Phase') + // as the primary/only source, matching CJS state.cjs:1615. Frontmatter + // progress.completed_phases is no longer consulted. + // STATE.md below has progress.completed_phases:12 but no body "Current Phase:" + // field → currentPhase = 0 → cutoff = -3 ≤ 0 → "Only 0 phases" (no-op). const stateContent = `--- gsd_state_version: 1.0 milestone: v1.1 @@ -1171,13 +1176,18 @@ Phase 12 execution in progress. const result = await statePrune(['--keep-recent', '3', '--dry-run'], tmpDir); const data = result.data as Record; + // No body "Current Phase:" field → defaults to 0 → cutoff ≤ 0 → early exit. expect(data.pruned).toBe(false); - expect(data.dry_run).toBe(true); - expect(data.cutoff_phase).toBe(9); - expect(data.reason).toBeUndefined(); + expect(typeof data.reason).toBe('string'); + expect(String(data.reason)).toContain('Only 0 phases'); + expect(data.dry_run).toBeUndefined(); + expect(data.cutoff_phase).toBeUndefined(); }); it('returns a targeted reason when no current phase source can be parsed', async () => { + // Phase 6 alignment: when no body "Current Phase:" field exists, currentPhase + // defaults to 0 (like CJS `parseInt(...) || 0`). The reason message matches + // CJS: "Only 0 phases — nothing to prune with --keep-recent N". const stateContent = `--- gsd_state_version: 1.0 milestone: v1.1 @@ -1193,6 +1203,8 @@ status: executing expect(data.pruned).toBe(false); expect(typeof data.reason).toBe('string'); - expect(String(data.reason)).toContain('Could not determine current phase'); + // Matches CJS: "Only 0 phases — nothing to prune with --keep-recent 3" + expect(String(data.reason)).toContain('Only 0 phases'); + expect(String(data.reason)).toContain('nothing to prune'); }); }); diff --git a/sdk/src/query/state-mutation.ts b/sdk/src/query/state-mutation.ts index 8ba3f80c8..d9355bc33 100644 --- a/sdk/src/query/state-mutation.ts +++ b/sdk/src/query/state-mutation.ts @@ -21,6 +21,7 @@ import { open, unlink, stat, readFile, writeFile, readdir } from 'node:fs/promises'; import { constants, unlinkSync, existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, + realpathSync, } from 'node:fs'; import { isAbsolute, join, relative, resolve } from 'node:path'; import { GSDError, ErrorClassification } from '../errors.js'; @@ -34,7 +35,8 @@ import { normalizeMd, } from './helpers.js'; import { buildStateFrontmatter, getMilestonePhaseFilter } from './state.js'; -import { stateExtractField, stateReplaceField, stateReplaceFieldWithFallback } from './state-document.js'; +import { scanPhasePlans } from './plan-scan.js'; +import { stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, computeProgressPercent } from './state-document.js'; import type { QueryHandler } from './utils.js'; const PROGRESS_FRONTMATTER_FIELDS = new Set(['Progress', 'Total Plans in Phase', 'Total Phases']); @@ -90,14 +92,26 @@ function readTextArgOrFile( if (!filePath) { return (value ?? '').trim(); } - const root = resolve(projectDir); - const resolved = isAbsolute(filePath) ? resolve(filePath) : resolve(root, filePath); - const rel = relative(root, resolved); + // Resolve symlinks on both the project root and the target path before + // comparing — matches CJS `validatePath` in security.cjs. On macOS, + // `os.tmpdir()` returns `/var/folders/...` but the realpath is + // `/private/var/folders/...`; without realpath normalization, the + // `relative()` check sees `/private/var/...` vs `/var/...` as different + // tree roots and rejects safe in-project files. Symlink resolution falls + // back to logical resolve() when the path doesn't exist yet (e.g., file + // about to be created). + function realpathOrResolve(p: string): string { + try { return realpathSync(p); } catch { return resolve(p); } + } + const resolvedBase = realpathOrResolve(resolve(projectDir)); + const targetLogical = isAbsolute(filePath) ? resolve(filePath) : resolve(resolvedBase, filePath); + const resolvedTarget = realpathOrResolve(targetLogical); + const rel = relative(resolvedBase, resolvedTarget); if (rel.startsWith('..') || isAbsolute(rel)) { throw new Error(`${label} path rejected: outside project directory`); } try { - return readFileSync(resolved, 'utf-8').trimEnd(); + return readFileSync(resolvedTarget, 'utf-8').trimEnd(); } catch { throw new Error(`${label} file not found: ${filePath}`); } @@ -307,6 +321,18 @@ export const stateUpdate: QueryHandler = async (args, projectDir, workstream) => throw new GSDError('field and value required for state update', ErrorClassification.Validation); } + // Match CJS `cmdStateUpdate` contract: caller receives `{ updated: false, + // reason: '...' }` when the operation is a no-op so shell-script consumers + // can JSON.parse output and branch on the reason. Without an explicit + // STATE.md check up front, readModifyWriteStateMd's auto-create behavior + // would mask "STATE.md missing" as a successful no-op write. + const statePath = planningPaths(projectDir, workstream).state; + try { + await readFile(statePath, 'utf-8'); + } catch { + return { data: { updated: false, reason: 'STATE.md not found' } }; + } + let updated = false; const shouldResync = PROGRESS_FRONTMATTER_FIELDS.has(field); await readModifyWriteStateMd(projectDir, (content) => { @@ -321,7 +347,10 @@ export const stateUpdate: QueryHandler = async (args, projectDir, workstream) => preserveExistingProgress: !shouldResync, }); - return { data: { updated } }; + if (!updated) { + return { data: { updated: false, reason: `Field "${field}" not found in STATE.md` } }; + } + return { data: { updated: true } }; }; /** @@ -631,14 +660,25 @@ export const stateRecordMetric: QueryHandler = async (args, projectDir, workstre return { data: { error: 'phase, plan, and duration required' } }; } + // CJS `cmdStateRecordMetric` contract: error out if STATE.md doesn't exist + // rather than auto-creating it (which `readModifyWriteStateMd` would do). + const statePath = planningPaths(projectDir, workstream).state; + try { + await readFile(statePath, 'utf-8'); + } catch { + return { data: { error: 'STATE.md not found' } }; + } + let recorded = false; + let created = false; await readModifyWriteStateMd(projectDir, (content) => { const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i; const metricsMatch = content.match(metricsPattern); + const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks} tasks | ${files} files |`; + if (metricsMatch) { let tableBody = metricsMatch[2].trimEnd(); - const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks} tasks | ${files} files |`; if (tableBody.trim() === '' || tableBody.includes('None yet')) { tableBody = newRow; @@ -648,14 +688,28 @@ export const stateRecordMetric: QueryHandler = async (args, projectDir, workstre content = content.replace(metricsPattern, (_match, header: string) => `${header}${tableBody}\n`); recorded = true; + } else { + // Section absent — DWIM: auto-create canonical ## Performance Metrics scaffold, + // then append the row. Matches CJS state.cjs DWIM behavior. + const scaffold = [ + '', + '## Performance Metrics', + '', + '| Phase | Plan | Duration | Notes |', + '|-------|------|----------|-------|', + newRow, + '', + ].join('\n'); + content = content.trimEnd() + '\n' + scaffold; + recorded = true; + created = true; } return content; }, workstream); - if (recorded) { - return { data: { recorded: true, phase, plan, duration } }; - } - return { data: { recorded: false, reason: 'Performance Metrics section not found in STATE.md' } }; + const result: Record = { recorded: true, phase, plan, duration }; + if (created) result.created = true; + return { data: result }; }; /** @@ -668,6 +722,16 @@ export const stateRecordMetric: QueryHandler = async (args, projectDir, workstre * @returns QueryResult with { updated, percent, completed, total } */ export const stateUpdateProgress: QueryHandler = async (_args, projectDir, workstream) => { + // CJS `cmdStateUpdateProgress` contract: error out when STATE.md is missing. + // Without this check the SDK silently returns `{ updated: false }` with no + // STATE.md-aware reason, masking the missing-file condition. + const statePath = planningPaths(projectDir, workstream).state; + try { + await readFile(statePath, 'utf-8'); + } catch { + return { data: { error: 'STATE.md not found' } }; + } + const phasesDir = planningPaths(projectDir, workstream).phases; let totalPlans = 0; let totalSummaries = 0; @@ -749,7 +813,7 @@ export const stateAddDecision: QueryHandler = async (args, projectDir, workstrea } const entry = `- [Phase ${phase || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`; - let added = false; + let created = false; await readModifyWriteStateMd(projectDir, (content) => { const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; @@ -759,16 +823,22 @@ export const stateAddDecision: QueryHandler = async (args, projectDir, workstrea let sectionBody = match[2]; sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, ''); sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n'; - content = content.replace(sectionPattern, (_match, header: string) => `${header}${sectionBody}`); - added = true; + return content.replace(sectionPattern, (_match, header: string) => `${header}${sectionBody}`); } - return content; + + // Section absent — DWIM (CJS state.cjs:481-492): auto-create the + // canonical `## Decisions` scaffold and append the entry. Matches the + // begin-phase / advance-plan DWIM behavior. Without this, callers that + // never touched the Decisions section see `{added: false}` even though + // STATE.md is writable. Bug #3286. + const scaffold = ['', '## Decisions', '', entry, ''].join('\n'); + created = true; + return content.trimEnd() + '\n' + scaffold; }, workstream); - if (added) { - return { data: { added: true, decision: entry } }; - } - return { data: { added: false, reason: 'Decisions section not found in STATE.md' } }; + const result: Record = { added: true, decision: entry }; + if (created) result['created'] = true; + return { data: result }; }; /** @@ -796,7 +866,7 @@ export const stateAddBlocker: QueryHandler = async (args, projectDir, workstream } const entry = `- ${blockerText}`; - let added = false; + let created = false; await readModifyWriteStateMd(projectDir, (content) => { const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; @@ -806,16 +876,20 @@ export const stateAddBlocker: QueryHandler = async (args, projectDir, workstream let sectionBody = match[2]; sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, ''); sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n'; - content = content.replace(sectionPattern, (_match, header: string) => `${header}${sectionBody}`); - added = true; + return content.replace(sectionPattern, (_match, header: string) => `${header}${sectionBody}`); } - return content; + + // Section absent — DWIM (CJS state.cjs:532-542): auto-create the + // canonical `### Blockers` scaffold and append the entry. Bug #3286 + // parity — matches stateAddDecision DWIM above. + const scaffold = ['', '### Blockers', '', entry, ''].join('\n'); + created = true; + return content.trimEnd() + '\n' + scaffold; }, workstream); - if (added) { - return { data: { added: true, blocker: blockerText } }; - } - return { data: { added: false, reason: 'Blockers section not found in STATE.md' } }; + const result: Record = { added: true, blocker: blockerText }; + if (created) result['created'] = true; + return { data: result }; }; /** @@ -829,6 +903,14 @@ export const stateResolveBlocker: QueryHandler = async (args, projectDir, workst return { data: { error: 'text required' } }; } + // CJS `cmdStateResolveBlocker` contract: error out when STATE.md is missing. + const statePath = planningPaths(projectDir, workstream).state; + try { + await readFile(statePath, 'utf-8'); + } catch { + return { data: { error: 'STATE.md not found' } }; + } + let removedMatchingLine = false; let blockersSectionFound = false; @@ -861,13 +943,15 @@ export const stateResolveBlocker: QueryHandler = async (args, projectDir, workst return content; }, workstream); - if (removedMatchingLine) { + // CJS `cmdStateResolveBlocker` contract: `resolved: true` whenever the + // Blockers section was found, even if no line matched. The semantic is + // "the resolve operation ran against a Blockers section" rather than "a + // specific line was found and removed". Only `resolved: false` when the + // Blockers section itself is missing. + if (blockersSectionFound) { return { data: { resolved: true, blocker: searchText } }; } - return { data: { resolved: false, reason: blockersSectionFound - ? 'Blocker text not found in STATE.md' - : 'Blockers section not found in STATE.md' - } }; + return { data: { resolved: false, reason: 'Blockers section not found in STATE.md' } }; }; // ─── state.add-roadmap-evolution ───────────────────────────────────────── @@ -1019,6 +1103,14 @@ export const stateRecordSession: QueryHandler = async (args, projectDir, workstr const stoppedAt = parsed['stopped-at'] as string | null | undefined; const resumeFile = ((parsed['resume-file'] as string | null) ?? 'None'); + // CJS `cmdStateRecordSession` contract: error out when STATE.md is missing. + const statePath = planningPaths(projectDir, workstream).state; + try { + await readFile(statePath, 'utf-8'); + } catch { + return { data: { error: 'STATE.md not found' } }; + } + const now = new Date().toISOString(); const updated: string[] = []; @@ -1347,8 +1439,10 @@ export const stateValidate: QueryHandler = async (_args, projectDir, workstream) if (phaseDir) { const phaseDirPath = join(phasesDir, phaseDir.name); const files = readdirSync(phaseDirPath); - const diskPlans = files.filter(f => /-PLAN\.md$/i.test(f)).length; - const diskSummaries = files.filter(f => /-SUMMARY\.md$/i.test(f)).length; + // Bug #3257 parity: count nested plans/ subdirectory via scanPhasePlans + // so /executing/i status checks below see the full plan count + // regardless of whether the planner used the flat or nested layout. + const { planCount: diskPlans, summaryCount: diskSummaries } = scanPhasePlans(phaseDirPath); if (totalPlansInPhase !== null && diskPlans !== totalPlansInPhase) { warnings.push( @@ -1419,16 +1513,20 @@ export const stateSync: QueryHandler = async (args, projectDir, workstream) => { let totalDiskPlans = 0; let totalDiskSummaries = 0; + let diskCompletedPhases = 0; let highestIncompletePhase: string | null = null; let highestIncompletePhaseplanCount = 0; for (const dir of entries) { const dirPath = join(phasesDir, dir); - const files = readdirSync(dirPath); - const plans = files.filter(f => /-PLAN\.md$/i.test(f)).length; - const summaries = files.filter(f => /-SUMMARY\.md$/i.test(f)).length; + // Bug #3257 parity: scanPhasePlans handles nested plans/ subdirectories + // and the extended filename forms (e.g. 5-PLAN-01-setup.md). Without + // this, state.sync sees 0 plans for canonical nested layouts and emits + // bogus "Total Plans in Phase 0 -> 0" sync updates. + const { planCount: plans, summaryCount: summaries, completed } = scanPhasePlans(dirPath); totalDiskPlans += plans; totalDiskSummaries += summaries; + if (completed) diskCompletedPhases++; const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); if (phaseMatch && plans > 0 && summaries < plans) { @@ -1437,6 +1535,12 @@ export const stateSync: QueryHandler = async (args, projectDir, workstream) => { } } + // CJS parity: total_phases for the percent calculation is the count of + // phase directories in the active milestone (or the actual count on disk + // if no milestone filter is configured). Required so the phase-fraction + // cap in computeProgressPercent (#3242 Bug B) sees the right denominator. + const syncTotalPhases = entries.length; + const runModifier = (modified: string): string => { let m = modified; if (highestIncompletePhase) { @@ -1448,7 +1552,17 @@ export const stateSync: QueryHandler = async (args, projectDir, workstream) => { } } - const percent = totalDiskPlans > 0 ? Math.min(100, Math.round((totalDiskSummaries / totalDiskPlans) * 100)) : 0; + // Use min(plan_fraction, phase_fraction) so ROADMAP-declared-but- + // unrealized future phases cap the reported percent (CJS bug #3242 Bug B + // parity). Fall back to 0 when computeProgressPercent returns null + // (totalDiskPlans === 0 case). + const computedPercent = computeProgressPercent( + totalDiskSummaries, + totalDiskPlans, + diskCompletedPhases, + syncTotalPhases, + ); + const percent = computedPercent !== null ? computedPercent : 0; const currentProgress = stateExtractField(m, 'Progress'); if (currentProgress) { const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10); @@ -1621,32 +1735,10 @@ export const statePrune: QueryHandler = async (args, projectDir, workstream) => } const fullContent = await readFile(statePath, 'utf-8'); - const fm = extractFrontmatter(fullContent); - const fmProgress = (typeof fm.progress === 'object' && fm.progress !== null) - ? fm.progress as Record - : null; - const phaseCandidates: unknown[] = [ - fm.current_phase, - stateExtractField(fullContent, 'Current Phase'), - fmProgress?.completed_phases, - fmProgress?.total_phases, - ]; - let currentPhase: number | null = null; - for (const candidate of phaseCandidates) { - const parsed = parseInt(String(candidate ?? '').trim(), 10); - if (Number.isInteger(parsed) && parsed > 0) { - currentPhase = parsed; - break; - } - } - if (currentPhase === null) { - return { - data: { - pruned: false, - reason: 'Could not determine current phase from STATE.md. Add **Current Phase:** N, frontmatter current_phase: N, progress.completed_phases, or progress.total_phases.', - }, - }; - } + // Align with CJS state.cjs:1615 — read Current Phase from the body text first, + // fall back to 0 (same as CJS `parseInt(..., 10) || 0`). + const currentPhaseRaw = stateExtractField(fullContent, 'Current Phase'); + const currentPhase = parseInt(String(currentPhaseRaw ?? '').trim(), 10) || 0; const cutoff = currentPhase - keepRecent; if (cutoff <= 0) { diff --git a/sdk/src/query/state.ts b/sdk/src/query/state.ts index 1c97659a7..2b6cc59ae 100644 --- a/sdk/src/query/state.ts +++ b/sdk/src/query/state.ts @@ -32,6 +32,7 @@ import { stateExtractField, } from './state-document.js'; import { getMilestoneInfo, extractCurrentMilestone } from './roadmap.js'; +import { scanPhasePlans } from './plan-scan.js'; import type { QueryHandler } from './utils.js'; // ─── Internal helpers ────────────────────────────────────────────────────── @@ -110,7 +111,17 @@ export async function buildStateFrontmatter( const status = stateExtractField(bodyContent, 'Status'); const progressRaw = stateExtractField(bodyContent, 'Progress'); const lastActivity = stateExtractField(bodyContent, 'Last Activity'); - const stoppedAt = stateExtractField(bodyContent, 'Stopped At') || stateExtractField(bodyContent, 'Stopped at'); + // Bug #2444 parity with CJS `buildStateFrontmatter`: scope `Stopped At` + // extraction to the `## Session` section so historical plain-text mentions + // in earlier prose (e.g. "## Previous Session Notes / Stopped at: …") don't + // promote into the frontmatter. CJS scopes the regex to the section match; + // `stateExtractField` on the whole body would return the first plain match, + // which is the stale historical value. + const sessionMatch = bodyContent.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i); + const sessionSection = sessionMatch ? sessionMatch[1] : ''; + const stoppedAt = sessionSection + ? (stateExtractField(sessionSection, 'Stopped At') || stateExtractField(sessionSection, 'Stopped at')) + : null; const pausedAt = stateExtractField(bodyContent, 'Paused At'); // Bug #2613: read existing STATE.md frontmatter as preservation backstop. @@ -153,12 +164,14 @@ export async function buildStateFrontmatter( let diskCompletedPhases = 0; for (const dir of phaseDirs) { - const files = await readdir(join(phasesDir, dir)); - const plans = files.filter(f => /-PLAN\.md$/i.test(f)).length; - const summaries = files.filter(f => /-SUMMARY\.md$/i.test(f)).length; - diskTotalPlans += plans; - diskTotalSummaries += summaries; - if (plans > 0 && summaries >= plans) diskCompletedPhases++; + // Bug #3257 parity: route through scanPhasePlans so nested plans/ + // subdirectories (the planner default layout) get counted. The naive + // top-level `-PLAN.md` filter undercounts every phase that uses the + // canonical `phases/NN-name/plans/-PLAN-MM-slug.md` shape. + const { planCount, summaryCount, completed } = scanPhasePlans(join(phasesDir, dir)); + diskTotalPlans += planCount; + diskTotalSummaries += summaryCount; + if (completed) diskCompletedPhases++; } totalPhases = isDirInMilestone.phaseCount > 0 diff --git a/sdk/src/query/validate.ts b/sdk/src/query/validate.ts index 1a0fe4a17..db21706fe 100644 --- a/sdk/src/query/validate.ts +++ b/sdk/src/query/validate.ts @@ -29,6 +29,72 @@ import { resolveBundledAgentsDir } from '../sdk-package-compatibility.js'; /** Max length for key_links regex patterns (ReDoS mitigation). */ const MAX_KEY_LINK_PATTERN_LEN = 512; +const PHASE_TOKEN_FROM_DIR_RE = /^(?:[A-Z]{1,6}-)?(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i; +const MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i; + +/** + * List milestone-archive directories under `.planning/milestones/`, sorted by + * version (numeric — `v1.10` after `v1.2`). Mirrors `listMilestoneArchiveDirs` + * in verify.cjs. + */ +async function listMilestoneArchiveDirs(planBase: string): Promise { + const milestonesDir = join(planBase, 'milestones'); + try { + const entries = await readdir(milestonesDir, { withFileTypes: true }); + return entries + .filter((e) => e.isDirectory() && MILESTONE_ARCHIVE_DIR_RE.test(e.name)) + .map((e) => join(milestonesDir, e.name)) + .sort((a, b) => { + const an = a.slice(a.lastIndexOf('/') + 1); + const bn = b.slice(b.lastIndexOf('/') + 1); + return an.localeCompare(bn, undefined, { numeric: true }); + }); + } catch { + return []; + } +} + +/** + * Pick the active milestone archive dir, preferring the version named in + * STATE.md when it maps to an on-disk archive; falling back to the highest + * (most recent) version-ish name. Mirrors `getActiveMilestoneArchiveDir` + * in verify.cjs. + */ +async function getActiveMilestoneArchiveDir(planBase: string): Promise { + const archiveDirs = await listMilestoneArchiveDirs(planBase); + if (archiveDirs.length === 0) return null; + + try { + const statePath = join(planBase, 'STATE.md'); + if (existsSync(statePath)) { + const state = await readFile(statePath, 'utf-8'); + const m = state.match(/^\s*(?:\*\*)?milestone(?:\*\*)?:\s*([^\s\r\n#]+).*$/mi); + if (m && m[1]) { + const milestone = m[1].trim(); + const candidate = join(planBase, 'milestones', `${milestone}-phases`); + if (archiveDirs.includes(candidate)) return candidate; + } + } + } catch { /* intentionally empty */ } + + return archiveDirs[archiveDirs.length - 1]; +} + +/** + * Collect the active phase roots to validate against. When the flat + * `.planning/phases/` directory exists, it counts. When an active + * milestone archive (e.g. `.planning/milestones/v1.7-phases/`) exists, it + * counts as well. Mirrors `collectPhaseRoots` in verify.cjs:437. Bug #3164. + */ +async function collectPhaseRoots(planBase: string): Promise { + const roots: string[] = []; + const flatPhasesDir = join(planBase, 'phases'); + if (existsSync(flatPhasesDir)) roots.push(flatPhasesDir); + const activeArchive = await getActiveMilestoneArchiveDir(planBase); + if (activeArchive) roots.push(activeArchive); + return roots; +} + /** * Canonical plan stem used for PLAN/SUMMARY matching. * Example: `68-01-scaffolding` -> `68-01`. @@ -219,23 +285,40 @@ export const validateConsistency: QueryHandler = async (_args, projectDir, works roadmapPhases.add(m[1]); } - // Get phases on disk + // Get phases on disk — flat layout AND active milestone archive (bug #3164). + // CJS uses `collectDiskPhases(planBase)` + `collectPhaseRoots(planBase)`. + // Each root contributes its phase tokens to diskPhases. Plan-level scans + // below walk every root, not just the flat one. const diskPhases = new Set(); - let diskDirs: string[] = []; - try { - const entries = await readdir(paths.phases, { withFileTypes: true }); - diskDirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); - for (const dir of diskDirs) { - const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); - if (dm) diskPhases.add(dm[1]); + const phaseRoots = await collectPhaseRoots(paths.planning); + /** Map of root → its phase-directory entries (for downstream plan scans). */ + const rootDirs = new Map(); + for (const root of phaseRoots) { + try { + const entries = await readdir(root, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); + rootDirs.set(root, dirs); + for (const dir of dirs) { + const dm = dir.match(PHASE_TOKEN_FROM_DIR_RE); + if (dm) diskPhases.add(dm[1]); + } + } catch { + rootDirs.set(root, []); } - } catch { - // phases directory doesn't exist } - // Check: phases in ROADMAP but not on disk + // Check: phases in ROADMAP but not on disk. CJS parity: compare against + // both the as-written form and the canonical normalized form, AND strip the + // optional project-code prefix on disk dirs (handled by + // PHASE_TOKEN_FROM_DIR_RE above) so `CK-64-…` is recognised as phase 64. for (const p of roadmapPhases) { - if (!diskPhases.has(p) && !diskPhases.has(normalizePhaseName(p))) { + const normalizedP = normalizePhaseName(p); + const unpaddedP = String(parseInt(p, 10)); + if ( + !diskPhases.has(p) && + !diskPhases.has(normalizedP) && + !diskPhases.has(unpaddedP) + ) { warnings.push(`Phase ${p} in ROADMAP.md but no directory on disk`); } } @@ -270,60 +353,63 @@ export const validateConsistency: QueryHandler = async (_args, projectDir, works } } - // Check plan numbering and summaries within each phase - for (const dir of diskDirs) { - let phaseFiles: string[]; - try { - phaseFiles = await readdir(join(paths.phases, dir)); - } catch { - continue; - } + // Check plan numbering and summaries within each phase across every active + // phase root. Bug #3164 \u2014 projects on the milestone-archive layout have + // phases under `.planning/milestones/-phases//`, not the + // flat `.planning/phases/` directory. + for (const root of phaseRoots) { + const dirs = rootDirs.get(root) ?? []; + // Label paths relative to planning/ so warnings carry the archive prefix + // (e.g. `milestones/v1.7-phases/65-current`) instead of bare phase names. + const relRoot = root.startsWith(paths.planning + '/') + ? root.slice(paths.planning.length + 1) + : root; - const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')).sort(); - const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md')); - - // Extract plan numbers and check for gaps - const planNums = plans.map(p => { - const pm = p.match(/-(\d{2})-PLAN\.md$/); - return pm ? parseInt(pm[1], 10) : null; - }).filter((n): n is number => n !== null); - - for (let i = 1; i < planNums.length; i++) { - if (planNums[i] !== planNums[i - 1] + 1) { - warnings.push(`Gap in plan numbering in ${dir}: plan ${planNums[i - 1]} \u2192 ${planNums[i]}`); - } - } - - // Check: summaries without matching plans - const planIds = new Set(plans.map(p => p.replace('-PLAN.md', ''))); - const summaryIds = new Set(summaries.map(s => s.replace('-SUMMARY.md', ''))); - - for (const sid of summaryIds) { - if (!planIds.has(sid)) { - warnings.push(`Summary ${sid}-SUMMARY.md in ${dir} has no matching PLAN.md`); - } - } - } - - // Check frontmatter completeness in plans - for (const dir of diskDirs) { - let phaseFiles: string[]; - try { - phaseFiles = await readdir(join(paths.phases, dir)); - } catch { - continue; - } - - const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')); - for (const plan of plans) { + for (const dir of dirs) { + const phaseLabel = relRoot === 'phases' ? dir : `${relRoot}/${dir}`; + let phaseFiles: string[]; try { - const content = await readFile(join(paths.phases, dir, plan), 'utf-8'); - const fm = extractFrontmatter(content); - if (!fm.wave) { - warnings.push(`${dir}/${plan}: missing 'wave' in frontmatter`); - } + phaseFiles = await readdir(join(root, dir)); } catch { - // Cannot read plan file + continue; + } + + const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')).sort(); + const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md')); + + // Extract plan numbers and check for gaps + const planNums = plans.map(p => { + const pm = p.match(/-(\d{2})-PLAN\.md$/); + return pm ? parseInt(pm[1], 10) : null; + }).filter((n): n is number => n !== null); + + for (let i = 1; i < planNums.length; i++) { + if (planNums[i] !== planNums[i - 1] + 1) { + warnings.push(`Gap in plan numbering in ${phaseLabel}: plan ${planNums[i - 1]} \u2192 ${planNums[i]}`); + } + } + + // Check: summaries without matching plans + const planIds = new Set(plans.map(p => p.replace('-PLAN.md', ''))); + const summaryIds = new Set(summaries.map(s => s.replace('-SUMMARY.md', ''))); + + for (const sid of summaryIds) { + if (!planIds.has(sid)) { + warnings.push(`Summary ${sid}-SUMMARY.md in ${phaseLabel} has no matching PLAN.md`); + } + } + + // Check frontmatter completeness in plans (same scope as above). + for (const plan of plans) { + try { + const content = await readFile(join(root, dir, plan), 'utf-8'); + const fm = extractFrontmatter(content); + if (!fm.wave) { + warnings.push(`${phaseLabel}/${plan}: missing 'wave' in frontmatter`); + } + } catch { + // Cannot read plan file + } } } } diff --git a/sdk/src/query/verify.ts b/sdk/src/query/verify.ts index 9eb55c945..764ddac33 100644 --- a/sdk/src/query/verify.ts +++ b/sdk/src/query/verify.ts @@ -26,7 +26,6 @@ import { planningPaths, } from './helpers.js'; import type { QueryHandler } from './utils.js'; -import { resolveGsdToolsPath } from '../sdk-package-compatibility.js'; // ─── verifyPlanStructure ─────────────────────────────────────────────────── @@ -645,48 +644,13 @@ export const verifySchemaDrift: QueryHandler = async (args, projectDir, workstre }; }; -/** - * verify.codebase-drift — structural drift detector (#2003). - * - * Non-blocking by contract: every failure mode returns a successful response - * with `{ skipped: true, reason }`. The post-execute drift gate in - * `/gsd-execute-phase` relies on this guarantee. - * - * Delegates to the Node-side implementation in `bin/lib/drift.cjs` and - * `bin/lib/verify.cjs` via a child process so the drift logic stays in one - * canonical place (see `cmdVerifyCodebaseDrift`). - */ -export const verifyCodebaseDrift: QueryHandler = async (_args, projectDir) => { - try { - const { execFileSync } = await import('node:child_process'); - const toolsPath = resolveGsdToolsPath(projectDir); - const out = execFileSync(process.execPath, [toolsPath, 'verify', 'codebase-drift'], { - cwd: projectDir, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }).trim(); - try { - return { data: JSON.parse(out) }; - } catch { - return { - data: { - skipped: true, - reason: 'sdk-parse-failed', - action_required: false, - directive: 'none', - elements: [], - }, - }; - } - } catch (err) { - return { - data: { - skipped: true, - reason: 'sdk-exception: ' + (err instanceof Error ? err.message : String(err)), - action_required: false, - directive: 'none', - elements: [], - }, - }; - } -}; +// verify.codebase-drift handler intentionally NOT exported from the SDK. +// drift (bin/lib/drift.cjs) is out-of-seam, CJS-only per ADR/PRD +// docs/adr/3524-cjs-sdk-hard-seam.md §3 and docs/prd/3524-cjs-sdk-hard-seam.md +// L160: "CJS-only Module handlers (...drift...) keep their in-process CJS +// implementations because no SDK counterpart exists." Previous Phase 6 stub +// (which execFileSync'd back to gsd-tools) created an infinite SDK→CLI→SDK +// recursion when the CJS verify-command-router dispatched through the SDK +// bridge — observed forking hundreds of node processes on a 64 GiB host. +// The router now dispatches `verify codebase-drift` direct to +// `verify.cmdVerifyCodebaseDrift`, which is the canonical implementation. diff --git a/sdk/src/runtime-bridge-sync/projectdir-regression.test.ts b/sdk/src/runtime-bridge-sync/projectdir-regression.test.ts index 3ae741446..e8c68432a 100644 --- a/sdk/src/runtime-bridge-sync/projectdir-regression.test.ts +++ b/sdk/src/runtime-bridge-sync/projectdir-regression.test.ts @@ -118,19 +118,17 @@ describe('executeForCjs projectDir regression (Phase 5.0 bug)', () => { expect(String(data.error)).toMatch(/STATE\.md not found/i); }); - it('workstream transport contract: GSDTransport forces subprocess for workstream requests (subprocess disabled in worker → ok:false)', () => { - // This test documents an architectural constraint, not a bug. + it('workstream support: GSDTransport routes workstream requests natively (Phase 6 fix)', () => { + // Phase 6 fix: GSDTransport no longer forces subprocess for workstream-scoped + // requests. The worker's dispatchNative closure (Phase 5.1 fix) correctly + // threads request.workstream through to registry.dispatch(), so native handlers + // route to the workstream-scoped .planning/workstreams// directory. // - // GSDTransport.subprocessReason() returns 'workstream_forced' when - // request.workstream is set (gsd-transport.ts line ~72). The worker has - // subprocess disabled (allowFallbackToSubprocess=false), so a workstream - // request always surfaces as ok:false / internal_error. - // - // This is the expected contract for the sync bridge worker: workstream - // scoped commands cannot run natively in the worker and must be invoked - // via the async bridge or gsd-tools.cjs subprocess fallback instead. - // - // This test is here to document + pin the behavior, not to assert a fix. + // The workstream 'some-workstream' has no separate STATE.md in tmpDir/ + // .planning/workstreams/some-workstream/, so the handler returns a domain-level + // "not found" error (ok:true with {error:...}) — exactly like the nonexistent + // projectDir case. This confirms native dispatch was used (subprocess would + // have returned ok:false / errorKind). const result = executeForCjs({ registryCommand: 'state.json', registryArgs: [], @@ -141,11 +139,12 @@ describe('executeForCjs projectDir regression (Phase 5.0 bug)', () => { workstream: 'some-workstream', }); - // Workstream forces subprocess; subprocess disabled → ok:false. - expect(result.ok).toBe(false); - if (result.ok) return; - // The error surfaces as internal_error because 'Subprocess fallback disabled' - // does not match the unknown_command classifier pattern. - expect(['internal_error', 'unknown_command']).toContain(result.errorKind); + // Native dispatch used → ok:true (handler-level not-found, not a dispatch error). + expect(result.ok).toBe(true); + if (!result.ok) return; + const data = result.data as Record; + // Domain-level not-found: workstream's STATE.md doesn't exist in the fixture. + expect(data).toHaveProperty('error'); + expect(String(data.error)).toMatch(/STATE\.md not found/i); }); }); diff --git a/sdk/src/runtime-bridge-sync/worker.ts b/sdk/src/runtime-bridge-sync/worker.ts index b3c5f0e21..c8d26f0fe 100644 --- a/sdk/src/runtime-bridge-sync/worker.ts +++ b/sdk/src/runtime-bridge-sync/worker.ts @@ -21,6 +21,7 @@ import { QueryRuntimeBridge } from '../query-runtime-bridge.js'; import { GSDToolsError } from '../gsd-tools-error.js'; import { GSDError, ErrorClassification } from '../errors.js'; import { createQueryNativeErrorFactory } from '../query-tools-error-factory.js'; +import { formatQueryRawOutput } from '../query-raw-output-projection.js'; import type { RuntimeBridgeExecuteInput } from '../query-runtime-bridge.js'; import type { RuntimeBridgeSyncResult, SyncErrorKind } from './index.js'; @@ -57,6 +58,12 @@ function getBridge(): QueryRuntimeBridge { request.registryArgs, ); }, + // #3631: forward raw-mode projection so mode:'raw' returns the per-command + // scalar string (next-decimal token, get-phase section, etc.) instead of + // falling back to generic JSON-stringify. Without this, family-router + // sdkHandlers requesting mode:'raw' under --raw receive a stringified + // JSON IR — the regression #3577 introduced for every family router. + formatNativeRaw: (registryCommand, data) => formatQueryRawOutput(registryCommand, data), // Subprocess fallback stubs — never called because allowFallbackToSubprocess=false execSubprocessJson: () => Promise.reject(new Error('Subprocess fallback disabled in sync bridge worker')), @@ -114,7 +121,20 @@ function getBridge(): QueryRuntimeBridge { * - GSDToolsError failure → native_failure * - Unknown Error → internal_error */ -function classifyError(error: unknown): { kind: SyncErrorKind; exitCode: number; message: string } { +function readReason(error: unknown): string | undefined { + // Handlers can pin a CJS-style ERROR_REASON snake_case code on the GSDError + // they throw (e.g. configGet → 'config_key_not_found'). The worker + // propagates it through errorDetails so the CJS dispatcher can call + // `error(msg, reason)` and `--json-errors` clients see a typed reason + // rather than the generic 'unknown'. (Bugs #2943, #3086.) + if (error && typeof error === 'object' && 'reason' in error) { + const r = (error as { reason?: unknown }).reason; + if (typeof r === 'string' && r.length > 0) return r; + } + return undefined; +} + +function classifyError(error: unknown): { kind: SyncErrorKind; exitCode: number; message: string; reason?: string } { if (error instanceof GSDToolsError) { const { classification, exitCode, message } = error; @@ -131,8 +151,28 @@ function classifyError(error: unknown): { kind: SyncErrorKind; exitCode: number; return { kind: 'native_timeout', exitCode: exitCode ?? 1, message }; } - // Check if cause is a TypeError → internal_error + // Unwrap the cause once. The native direct adapter wraps every non- + // GSDToolsError thrown by a handler in a GSDToolsError via + // `createNativeFailureError`, preserving the original via `cause`. + // Classification of validation / blocked errors therefore has to walk + // through to the cause — otherwise every GSDError validation surfaces + // as `native_failure` and callers cannot distinguish "you gave me bad + // input" from "the SDK crashed." (Phase 6 / #3592 contract bug.) const cause = (error as NodeJS.ErrnoException & { cause?: unknown }).cause; + if (cause instanceof GSDError) { + const reason = readReason(cause); + if ( + cause.classification === ErrorClassification.Validation || + cause.classification === ErrorClassification.Blocked + ) { + return { kind: 'validation_error', exitCode: 10, message: cause.message, reason }; + } + // Execution-classified GSDError is a 'handler said no' result — + // exitCode 1, internal_error kind for taxonomy purposes, but pass + // the structured reason through so the CJS dispatcher can render + // the proper `--json-errors` shape. + return { kind: 'internal_error', exitCode: 1, message: cause.message, reason }; + } if (cause instanceof TypeError) { return { kind: 'internal_error', exitCode: exitCode ?? 1, message }; } @@ -142,13 +182,14 @@ function classifyError(error: unknown): { kind: SyncErrorKind; exitCode: number; if (error instanceof GSDError) { const { classification, message } = error; + const reason = readReason(error); if ( classification === ErrorClassification.Validation || classification === ErrorClassification.Blocked ) { - return { kind: 'validation_error', exitCode: 10, message }; + return { kind: 'validation_error', exitCode: 10, message, reason }; } - return { kind: 'internal_error', exitCode: 1, message }; + return { kind: 'internal_error', exitCode: 1, message, reason }; } if (error instanceof TypeError) { @@ -169,12 +210,14 @@ runAsWorker(async (input: RuntimeBridgeExecuteInput): Promise { cleanup(tmpDir); }); + // Point the SDK at the repo's agents/ dir (sibling of get-shit-done/) via the + // GSD_AGENTS_DIR override. The SDK side of init resolves agents from + // GSD_AGENTS_DIR or the runtime config dir (~/.claude/agents for Claude); it + // does NOT walk up from cwd like the CJS-era code did. Without this override + // these tests would only pass on a dev machine with ~/.claude/agents/ + // populated — which masked the divergence on Linux CI where that path is + // absent. See sdk/src/query/QUERY-HANDLERS.md ("subprocess vs in-process + // path resolution") and sdk/src/query/helpers.ts:resolveAgentsDir. + const REPO_AGENTS_DIR = path.resolve(__dirname, '..', 'agents'); + test('init execute-phase includes agents_installed=true when agents exist', () => { - // Create phase dir for init const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup'); fs.mkdirSync(phaseDir, { recursive: true }); - // Create agents dir as sibling of get-shit-done/ (the installed layout) - // gsd-tools.cjs resolves agents from GSD_INSTALL_DIR or __dirname/../../agents - const gsdInstallDir = path.resolve(__dirname, '..', 'get-shit-done', 'bin'); - const configDir = path.resolve(gsdInstallDir, '..', '..'); - const agentsDir = path.join(configDir, 'agents'); - - // Agents already exist in the repo root /agents/ dir which is sibling to get-shit-done/ - const result = runGsdTools('init execute-phase 1 --raw', tmpDir); + const result = runGsdTools('init execute-phase 1 --raw', tmpDir, { GSD_AGENTS_DIR: REPO_AGENTS_DIR }); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(typeof output.agents_installed, 'boolean', 'init execute-phase must include agents_installed field'); - // The repo has agents/ dir with all gsd-*.md files, so this should be true assert.strictEqual(output.agents_installed, true, - 'agents_installed should be true when agents directory has gsd-*.md files'); + 'agents_installed should be true when GSD_AGENTS_DIR has gsd-*.md files'); }); test('init plan-phase includes agents_installed=true when agents exist', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup'); fs.mkdirSync(phaseDir, { recursive: true }); - const result = runGsdTools('init plan-phase 1 --raw', tmpDir); + const result = runGsdTools('init plan-phase 1 --raw', tmpDir, { GSD_AGENTS_DIR: REPO_AGENTS_DIR }); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); diff --git a/tests/bug-3631-router-raw-flag.test.cjs b/tests/bug-3631-router-raw-flag.test.cjs new file mode 100644 index 000000000..1ef627513 --- /dev/null +++ b/tests/bug-3631-router-raw-flag.test.cjs @@ -0,0 +1,134 @@ +'use strict'; + +/** + * Regression tests for #3631 — SDK dispatch path in family routers must + * forward the `--raw` flag through to `output()`. + * + * Before the fix, every `*-command-router.cjs` `sdkHandler` called + * `output(result.data)` without the second positional `raw` argument or the + * third positional `rawValue`. With `--raw` set, the SDK path therefore + * emitted JSON-stringified data ({"next":"2.1",...}) instead of the scalar + * the CJS path used to print (e.g. `2.1`). + * + * Both tests below exercise the live SDK path: + * 1. `phase next-decimal --raw ` must emit the next-decimal token. + * 2. `roadmap get-phase --raw ` must emit the phase's roadmap section. + * + * Per CONTRIBUTING.md: assertions are on structured (scalar) tokens, not + * substring grep against full JSON. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync } = require('node:child_process'); + +const GSD_TOOLS = path.resolve(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs'); + +function run(args, cwd) { + try { + return { + ok: true, + stdout: execFileSync(process.execPath, [GSD_TOOLS, ...args], { + cwd, + encoding: 'utf-8', + timeout: 15000, + }), + }; + } catch (e) { + return { + ok: false, + stdout: (e.stdout && e.stdout.toString()) || '', + stderr: (e.stderr && e.stderr.toString()) || '', + code: e.status, + }; + } +} + +function makeFixture() { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3631-')); + const planning = path.join(tmp, '.planning'); + fs.mkdirSync(path.join(planning, 'phases'), { recursive: true }); + fs.writeFileSync( + path.join(planning, 'ROADMAP.md'), + [ + '# Project Roadmap', + '', + '## v1', + '', + '### Phase 1: First', + '', + 'Body of phase 1.', + '', + '### Phase 2: Second', + '', + 'Body of phase 2.', + '', + ].join('\n') + ); + // PROJECT.md anchors the planning root for callers that resolve it. + fs.writeFileSync(path.join(planning, 'PROJECT.md'), '# Test\n'); + return tmp; +} + +describe('bug #3631 — SDK family routers forward --raw to output()', () => { + test('phase next-decimal --raw emits the scalar next-decimal token (not JSON)', () => { + const tmp = makeFixture(); + try { + const res = run(['phase', 'next-decimal', '--raw', '1'], tmp); + assert.ok( + res.ok, + `command must succeed; got code=${res.code} stderr=${res.stderr}` + ); + const trimmed = res.stdout.trim(); + // Scalar form — must be a phase id token like "1.1", not a JSON object. + assert.doesNotMatch( + trimmed, + /^\{/, + `--raw must not emit JSON; got: ${trimmed}` + ); + assert.match( + trimmed, + /^0*\d+(?:\.\d+)?$/, + `--raw must emit a scalar phase id; got: ${trimmed}` + ); + // SDK and CJS both normalize the base phase before computing the next- + // decimal token; CJS emits "1.1" while SDK normalizes "1"→"01" and emits + // "01.1". Both are valid scalar projections — assert on parity with the + // computed-next semantics rather than the exact padding form. + assert.ok( + trimmed === '1.1' || trimmed === '01.1', + `expected next-decimal of base "1" to be 1.1 or 01.1; got: ${trimmed}` + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('roadmap get-phase --raw emits the phase section (not JSON)', () => { + const tmp = makeFixture(); + try { + const res = run(['roadmap', 'get-phase', '--raw', '2'], tmp); + assert.ok( + res.ok, + `command must succeed; got code=${res.code} stderr=${res.stderr}` + ); + const trimmed = res.stdout.trim(); + assert.doesNotMatch( + trimmed, + /^\{/, + `--raw must not emit JSON; got: ${trimmed.slice(0, 80)}` + ); + // Section text starts with the heading. + assert.match( + trimmed, + /Phase 2:\s*Second/, + `--raw must emit the section body containing the Phase 2 heading; got: ${trimmed.slice(0, 80)}` + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/cjs-sdk-bridge-integration.test.cjs b/tests/cjs-sdk-bridge-integration.test.cjs new file mode 100644 index 000000000..18ce9eb11 --- /dev/null +++ b/tests/cjs-sdk-bridge-integration.test.cjs @@ -0,0 +1,93 @@ +'use strict'; + +/** + * Integration test for `get-shit-done/bin/lib/cjs-sdk-bridge.cjs` — locks the + * load-success invariant that Phase 5/6 silently violated before this PR. + * + * Original bug: the bridge used `require('@gsd-build/sdk')` to load the + * runtime-bridge module. That package name is not resolvable from the root + * `node_modules` (the SDK lives at `./sdk/` as a sibling, not a dependency), + * and even if it were, the public entry didn't expose `executeForCjs` or + * `formatStateLoadRawStdout`. `tryLoadSdk()` always returned false, + * `_loadFailed` was cached for the process lifetime, and every CJS router + * silently fell through to the CJS fallback path — making the entire + * CJS→SDK delegation in Phase 5/6 dead code. CI passed because the fallback + * still executed CJS handlers, masking the regression. + * + * This test proves: + * 1. `tryLoadSdk()` returns true on the current checkout. + * 2. `getExecuteForCjs()` returns a real function (not null). + * 3. `getFormatStateLoadRawStdout()` returns a real function (not null). + * 4. Calling `executeForCjs` with a real canonical registry command + * produces a successful SDK result — proving the bridge actually + * dispatches through the runtime bridge rather than failing/falling back. + * + * Requires `sdk/dist/` to exist (i.e. `npm run build:sdk` has run). The + * project's `pretest` hook runs `build:sdk` before tests, so this is met by + * default. If `dist/` is missing, the assertion failures in this file + * surface the cause directly rather than silently masking under fallback. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const BRIDGE_PATH = path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'cjs-sdk-bridge.cjs'); + +describe('cjs-sdk-bridge: SDK runtime bridge integration', () => { + test('tryLoadSdk() resolves the bundled SDK on the current checkout', () => { + // Fresh require each run so module-level caches reset. + delete require.cache[require.resolve(BRIDGE_PATH)]; + const bridge = require(BRIDGE_PATH); + const loaded = bridge.tryLoadSdk(); + assert.strictEqual( + loaded, + true, + 'tryLoadSdk() must return true; if false, the bridge can no longer ' + + 'locate sdk/dist/runtime-bridge-sync/index.js or its exports — every ' + + 'CJS router will fall back to the per-side CJS handler.', + ); + }); + + test('getExecuteForCjs() returns a function after a successful load', () => { + const bridge = require(BRIDGE_PATH); + bridge.tryLoadSdk(); + assert.strictEqual(typeof bridge.getExecuteForCjs(), 'function'); + }); + + test('getFormatStateLoadRawStdout() returns a function after a successful load', () => { + const bridge = require(BRIDGE_PATH); + bridge.tryLoadSdk(); + assert.strictEqual(typeof bridge.getFormatStateLoadRawStdout(), 'function'); + }); + + test('executeForCjs() actually dispatches a canonical registry command (not a fallback)', () => { + const bridge = require(BRIDGE_PATH); + assert.strictEqual(bridge.tryLoadSdk(), true); + const executeForCjs = bridge.getExecuteForCjs(); + + // `generate-slug` is a canonical, project-independent command in the SDK + // registry. It does not require a `.planning/` fixture, so its success + // proves the bridge dispatch path works end-to-end without confounding + // it with project-state setup. Same command Phase 5.0's smoke test uses. + const result = executeForCjs({ + registryCommand: 'generate-slug', + registryArgs: ['Phase 6 Bridge Wired'], + legacyCommand: 'generate-slug', + legacyArgs: ['Phase 6 Bridge Wired'], + mode: 'json', + projectDir: process.cwd(), + }); + + assert.strictEqual( + result.ok, + true, + `executeForCjs result.ok must be true; got: ${JSON.stringify(result)}. ` + + 'If this fails, the bridge loaded but registry.dispatch did not return ' + + 'a typed-ok result for a known-canonical command — the seam is broken.', + ); + assert.ok(result.data && typeof result.data === 'object', 'result.data must be an object'); + assert.strictEqual(result.data.slug, 'phase-6-bridge-wired'); + assert.strictEqual(result.exitCode, 0); + }); +}); diff --git a/tests/decisions-generator.test.cjs b/tests/decisions-generator.test.cjs new file mode 100644 index 000000000..267dd1270 --- /dev/null +++ b/tests/decisions-generator.test.cjs @@ -0,0 +1,217 @@ +'use strict'; + +/** + * Parity test: decisions.generated.cjs vs sdk/src/query/decisions.ts + * + * Verifies that the generated CJS artifact matches the SDK source-of-truth + * for all supported ID formats (numeric and alphanumeric) and edge cases. + * + * Covers: Phase 6 (#3575) MIGRATE_ME resolution for decisions.cjs. + */ + +const assert = require('assert'); +const { describe, test } = require('node:test'); +const { parseDecisions } = require('../get-shit-done/bin/lib/decisions.cjs'); + +// ─── Core parity: numeric IDs (legacy format) ──────────────────────────────── + +describe('decisions-generator parity — numeric IDs (legacy)', () => { + test('extracts D-NN entries with {id, text}', () => { + const md = ` + +## Implementation Decisions + +### Auth +- **D-01:** Use OAuth 2.0 with PKCE +- **D-02:** Session storage in Redis + +### Storage +- **D-03:** Postgres 15 with pgvector + +`; + const ds = parseDecisions(md); + assert.deepStrictEqual(ds.map(d => d.id), ['D-01', 'D-02', 'D-03']); + assert.strictEqual(ds[0].text, 'Use OAuth 2.0 with PKCE'); + }); + + test('returns [] when no block is present', () => { + assert.deepStrictEqual(parseDecisions('# Just a header\nno decisions here'), []); + }); + + test('returns [] for empty / null / undefined input', () => { + assert.deepStrictEqual(parseDecisions(''), []); + assert.deepStrictEqual(parseDecisions(null), []); + assert.deepStrictEqual(parseDecisions(undefined), []); + }); + + test('ignores D-IDs outside the block', () => { + const md = ` +Top of file. - **D-99:** Not a real decision (outside block). + +- **D-01:** Real decision + +After the block. - **D-77:** Also not real. +`; + const ds = parseDecisions(md); + assert.deepStrictEqual(ds.map(d => d.id), ['D-01']); + }); +}); + +// ─── Phase 6 extension: alphanumeric IDs ───────────────────────────────────── + +describe('decisions-generator parity — alphanumeric IDs (Phase 6 extension)', () => { + test('accepts alphanumeric IDs: D-INFRA-01', () => { + const md = ` + +### Infrastructure +- **D-INFRA-01:** Use Kubernetes for orchestration + +`; + const ds = parseDecisions(md); + assert.strictEqual(ds.length, 1); + assert.strictEqual(ds[0].id, 'D-INFRA-01'); + assert.strictEqual(ds[0].text, 'Use Kubernetes for orchestration'); + }); + + test('accepts alphanumeric IDs: D-42 (single numeric)', () => { + const md = ` + +### Architecture +- **D-42:** Use microservices + +`; + const ds = parseDecisions(md); + assert.strictEqual(ds[0].id, 'D-42'); + }); + + test('accepts mixed numeric and alphanumeric IDs in same block', () => { + const md = ` + +### Planning +- **D-01:** First numeric decision +- **D-FOO_BAR:** Alphanumeric with underscore +- **D-ARCH-123:** Mixed alphanumeric with hyphen + +`; + const ds = parseDecisions(md); + const ids = ds.map(d => d.id); + assert.ok(ids.includes('D-01'), 'should have D-01'); + assert.ok(ids.includes('D-FOO_BAR'), 'should have D-FOO_BAR'); + assert.ok(ids.includes('D-ARCH-123'), 'should have D-ARCH-123'); + }); + + test('CJS callers can use {id, text} shape — extra fields present but safe to ignore', () => { + const md = ` + +### Category +- **D-INFRA-01:** Database selection + +`; + const ds = parseDecisions(md); + const d = ds[0]; + // Verify {id, text} is present as CJS callers expect + assert.strictEqual(typeof d.id, 'string'); + assert.strictEqual(typeof d.text, 'string'); + // Extra SDK fields are present but can be ignored + assert.ok('category' in d, 'category field present'); + assert.ok('tags' in d, 'tags field present'); + assert.ok('trackable' in d, 'trackable field present'); + }); +}); + +// ─── Richer schema fields (SDK extension) ──────────────────────────────────── + +describe('decisions-generator parity — richer schema', () => { + test('marks decisions under "Claude\'s Discretion" as non-trackable', () => { + const md = ` + +### Claude's Discretion +- **D-50:** Internal naming is flexible + +`; + const ds = parseDecisions(md); + assert.strictEqual(ds[0].trackable, false); + }); + + test('marks [informational] tagged decisions as non-trackable', () => { + const md = ` + +### Info +- **D-03 [informational]:** Background context only + +`; + const ds = parseDecisions(md); + assert.strictEqual(ds[0].trackable, false); + assert.ok(ds[0].tags.includes('informational')); + }); + + test('marks [folded] tagged decisions as non-trackable', () => { + const md = ` + +### Deferred +- **D-05 [folded]:** Will handle later + +`; + const ds = parseDecisions(md); + assert.strictEqual(ds[0].trackable, false); + }); + + test('extracts category from ### heading', () => { + const md = ` + +### Storage Backend +- **D-01:** Use PostgreSQL + +`; + const ds = parseDecisions(md); + assert.strictEqual(ds[0].category, 'Storage Backend'); + }); + + test('parses ALL blocks (not just first)', () => { + const md = ` + +### One +- **D-01:** First batch + + +Some prose. + + +### Two +- **D-02:** Second batch + +`; + const ids = parseDecisions(md).map(d => d.id); + assert.ok(ids.includes('D-01')); + assert.ok(ids.includes('D-02')); + }); + + test('strips fenced code blocks before parsing', () => { + const md = ` +\`\`\` + +### Fake +- **D-99:** Should not be parsed + +\`\`\` + + +### Real +- **D-01:** Real decision + +`; + const ds = parseDecisions(md); + const ids = ds.map(d => d.id); + assert.ok(ids.includes('D-01')); + assert.ok(!ids.includes('D-99')); + }); + + test('curly-quote "Claude’s Discretion" variant is non-trackable', () => { + const content = + '\n### Claude’s Discretion\n- **D-50:** Should be non-trackable\n'; + const ds = parseDecisions(content); + const d50 = ds.find(d => d.id === 'D-50'); + assert.ok(d50, 'D-50 should be found'); + assert.strictEqual(d50.trackable, false); + }); +}); diff --git a/tests/lint-shared-module-handsync.test.cjs b/tests/lint-shared-module-handsync.test.cjs new file mode 100644 index 000000000..497159499 --- /dev/null +++ b/tests/lint-shared-module-handsync.test.cjs @@ -0,0 +1,369 @@ +'use strict'; + +/** + * Tests for scripts/lint-shared-module-handsync.cjs — Phase 6 of #3524 (#3575). + * + * Three cases: + * 1. No new drift pair: lint exits 0 on the current repo tree (all cooperating + * siblings on the allowlist; migrateMeBacklog pairs do not fail). + * 2. Intentional new drift: synthesize a fixture tree with an unlisted + * foo-test.cjs / foo-test.ts pair, assert exit 1 + typed error JSON. + * 3. Allowlist entry honored: same pair as case 2, but with a cooperatingSiblings + * allowlist entry present, assert exit 0. + * + * Assertions use the lint's --json mode: the production code emits a typed IR + * (ok / reason / errors / warnings / counts), and tests parse and assert on + * structured fields rather than substring-matching stderr/stdout (per + * CONTRIBUTING.md "Prohibited: Raw Text Matching on Test Outputs"). + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { spawnSync } = require('node:child_process'); + +const LINT_SCRIPT = path.join(__dirname, '..', 'scripts', 'lint-shared-module-handsync.cjs'); +const ALLOWLIST_PATH = path.join(__dirname, '..', 'scripts', 'shared-module-handsync-allowlist.json'); +const REPO_ROOT = path.join(__dirname, '..'); + +// --------------------------------------------------------------------------- +// Helper: run the lint script in --json mode and parse the result. +// Returns { status, payload } where payload is the parsed JSON IR (or null +// if the lint emitted no JSON, which would be a test-infrastructure bug). +// --------------------------------------------------------------------------- +function runLintJson(extraArgs = []) { + const result = spawnSync(process.execPath, [LINT_SCRIPT, '--json', ...extraArgs], { + encoding: 'utf8', + cwd: REPO_ROOT, + }); + let payload = null; + try { + payload = JSON.parse(result.stdout.trim()); + } catch { + // Leave payload as null; tests assert on payload presence. + } + return { status: result.status, payload }; +} + +// --------------------------------------------------------------------------- +// Helper: create an isolated fixture tree for testing +// +// Layout: +// / +// get-shit-done/bin/lib/.cjs +// sdk/src/query/.ts (if tsInQuery === true) +// sdk/src/.ts (if tsInQuery === false) +// scripts/shared-module-handsync-allowlist.json (custom allowlist) +// --------------------------------------------------------------------------- +function createFixture({ cjsName, tsName, tsInQuery = true, allowlistExtra = {} }) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-handsync-')); + + const cjsDir = path.join(tmpDir, 'get-shit-done', 'bin', 'lib'); + fs.mkdirSync(cjsDir, { recursive: true }); + + const tsDir = tsInQuery + ? path.join(tmpDir, 'sdk', 'src', 'query') + : path.join(tmpDir, 'sdk', 'src'); + fs.mkdirSync(tsDir, { recursive: true }); + + const scriptsDir = path.join(tmpDir, 'scripts'); + fs.mkdirSync(scriptsDir, { recursive: true }); + + fs.writeFileSync(path.join(cjsDir, `${cjsName}.cjs`), `'use strict';\n// fixture cjs\n`); + fs.writeFileSync(path.join(tsDir, `${tsName}.ts`), `// fixture ts\nexport {};\n`); + + const realAllowlist = JSON.parse(fs.readFileSync(ALLOWLIST_PATH, 'utf8')); + const fixtureAllowlist = { + cooperatingSiblings: [ + ...(realAllowlist.cooperatingSiblings || []), + ...(allowlistExtra.cooperatingSiblings || []), + ], + migrateMeBacklog: [ + ...(realAllowlist.migrateMeBacklog || []), + ...(allowlistExtra.migrateMeBacklog || []), + ], + }; + fs.writeFileSync( + path.join(scriptsDir, 'shared-module-handsync-allowlist.json'), + JSON.stringify(fixtureAllowlist, null, 2) + ); + + return tmpDir; +} + +function cleanupFixture(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// Case 1: No new drift pair — exits 0 on current repo tree +// --------------------------------------------------------------------------- +describe('lint-shared-module-handsync: current repo tree', () => { + test('exits 0 with the real allowlist and current repo tree', () => { + const { status, payload } = runLintJson(); + assert.strictEqual(status, 0); + assert.ok(payload, 'expected JSON payload on stdout'); + assert.strictEqual(payload.ok, true); + }); + + test('reports cooperating sibling count and zero unauthorized pairs', () => { + const { payload } = runLintJson(); + assert.ok(payload); + assert.strictEqual(typeof payload.cooperatingCount, 'number'); + assert.ok(payload.cooperatingCount > 0, 'expected at least one cooperating sibling'); + // No errors field on success — only warnings (backlog) may be present + assert.strictEqual(payload.ok, true); + }); + + test('script has no syntax errors', () => { + const result = spawnSync(process.execPath, ['--check', LINT_SCRIPT], { encoding: 'utf8' }); + assert.strictEqual(result.status, 0); + }); +}); + +// --------------------------------------------------------------------------- +// Case 2: Intentional new drift — exits 1 with informative typed error +// --------------------------------------------------------------------------- +describe('lint-shared-module-handsync: intentional new drift pair', () => { + test('exits 1 when an unlisted cjs/ts pair exists', () => { + const tmpDir = createFixture({ cjsName: 'foo-test', tsName: 'foo-test', tsInQuery: true }); + try { + const { status, payload } = runLintJson(['--root', tmpDir]); + assert.strictEqual(status, 1); + assert.ok(payload); + assert.strictEqual(payload.ok, false); + assert.strictEqual(payload.reason, 'unauthorized_pairs'); + } finally { + cleanupFixture(tmpDir); + } + }); + + test('typed error payload names the unauthorized pair', () => { + const tmpDir = createFixture({ cjsName: 'foo-test', tsName: 'foo-test', tsInQuery: true }); + try { + const { payload } = runLintJson(['--root', tmpDir]); + assert.ok(payload && Array.isArray(payload.errors)); + assert.strictEqual(payload.errors.length, 1); + const [entry] = payload.errors; + assert.match(entry.relCjs, /foo-test\.cjs$/); + assert.ok(Array.isArray(entry.tsPaths)); + assert.ok(entry.tsPaths.some((p) => /foo-test\.ts$/.test(p))); + } finally { + cleanupFixture(tmpDir); + } + }); + + test('exits 1 for unlisted pair in sdk/src/.ts (non-query) position', () => { + const tmpDir = createFixture({ cjsName: 'bar-test', tsName: 'bar-test', tsInQuery: false }); + try { + const { status, payload } = runLintJson(['--root', tmpDir]); + assert.strictEqual(status, 1); + assert.ok(payload); + assert.strictEqual(payload.ok, false); + assert.strictEqual(payload.reason, 'unauthorized_pairs'); + } finally { + cleanupFixture(tmpDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// Case 3: Allowlist entry honored — exits 0 when pair IS on cooperatingSiblings +// --------------------------------------------------------------------------- +describe('lint-shared-module-handsync: allowlist entry honored', () => { + test('exits 0 when pair is in cooperatingSiblings allowlist', () => { + const cjsName = 'baz-cooperating'; + const tsName = 'baz-cooperating'; + const tmpDir = createFixture({ + cjsName, + tsName, + tsInQuery: true, + allowlistExtra: { + cooperatingSiblings: [ + { + cjs: `get-shit-done/bin/lib/${cjsName}.cjs`, + ts: `sdk/src/query/${tsName}.ts`, + classification: 'cooperating-sibling', + justification: 'Test fixture: synthetic cooperating sibling for lint test.', + }, + ], + }, + }); + try { + const { status, payload } = runLintJson(['--root', tmpDir]); + assert.strictEqual(status, 0); + assert.ok(payload); + assert.strictEqual(payload.ok, true); + } finally { + cleanupFixture(tmpDir); + } + }); + + // Regression guard: the lint matches on the (cjs, ts) PAIR, not on the + // cjs path alone. An allowlist entry whose ts points to a different path + // than the actual ts sibling on disk must NOT silently pass the pair. + test('rejects pair when TS path differs from allowlist entry', () => { + const cjsName = 'foo-wrong-ts'; + const tsName = 'foo-wrong-ts'; + const tmpDir = createFixture({ + cjsName, + tsName, + tsInQuery: true, // creates sdk/src/query/foo-wrong-ts.ts on disk + allowlistExtra: { + cooperatingSiblings: [ + { + cjs: `get-shit-done/bin/lib/${cjsName}.cjs`, + // Allowlist points at sdk/src/.ts — different location. + // Lint must reject because the on-disk pair is unauthorized. + ts: `sdk/src/${tsName}.ts`, + classification: 'cooperating-sibling', + justification: 'Test: validates pair-aware matching enforces ts path.', + }, + ], + }, + }); + try { + const { status, payload } = runLintJson(['--root', tmpDir]); + assert.strictEqual(status, 1, 'must fail when ts path mismatches'); + assert.ok(payload); + assert.strictEqual(payload.ok, false); + assert.strictEqual(payload.reason, 'unauthorized_pairs'); + } finally { + cleanupFixture(tmpDir); + } + }); + + test('exits 0 (no error) when pair is in migrateMeBacklog allowlist', () => { + const cjsName = 'qux-backlog'; + const tsName = 'qux-backlog'; + const tmpDir = createFixture({ + cjsName, + tsName, + tsInQuery: true, + allowlistExtra: { + migrateMeBacklog: [ + { + cjs: `get-shit-done/bin/lib/${cjsName}.cjs`, + ts: `sdk/src/query/${tsName}.ts`, + classification: 'drift-anti-pattern', + justification: 'Test fixture: synthetic backlog pair for lint test.', + trackedIn: 'test only', + }, + ], + }, + }); + try { + const { status, payload } = runLintJson(['--root', tmpDir]); + assert.strictEqual(status, 0); + assert.ok(payload); + assert.strictEqual(payload.ok, true); + // The backlog pair should be reported in warnings (not errors) + assert.ok(Array.isArray(payload.warnings)); + assert.ok( + payload.warnings.some((w) => /qux-backlog\.cjs$/.test(w.relCjs)), + 'expected qux-backlog in warnings' + ); + } finally { + cleanupFixture(tmpDir); + } + }); + + // Regression guard for #3632: when a cjs has TWO ts siblings sharing the + // same basename (e.g. sdk/src/foo.ts AND sdk/src/query/foo.ts) and only + // ONE pair is allowlisted, the unallowlisted sibling must still be reported. + // Prior bug: .some() at the cjs level returned true on the allowlisted + // pair, short-circuiting and silently dropping the unallowlisted sibling. + test('reports unallowlisted ts sibling when another ts sibling for the same cjs IS allowlisted (#3632)', () => { + const cjsName = 'multi-sibling'; + const tsName = 'multi-sibling'; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-multi-')); + try { + const cjsDir = path.join(tmpDir, 'get-shit-done', 'bin', 'lib'); + fs.mkdirSync(cjsDir, { recursive: true }); + const tsDirRoot = path.join(tmpDir, 'sdk', 'src'); + const tsDirQuery = path.join(tmpDir, 'sdk', 'src', 'query'); + fs.mkdirSync(tsDirQuery, { recursive: true }); + const scriptsDir = path.join(tmpDir, 'scripts'); + fs.mkdirSync(scriptsDir, { recursive: true }); + + // One cjs, two ts siblings on disk (same basename, different paths). + fs.writeFileSync(path.join(cjsDir, `${cjsName}.cjs`), `'use strict';\n`); + fs.writeFileSync(path.join(tsDirRoot, `${tsName}.ts`), `export {};\n`); + fs.writeFileSync(path.join(tsDirQuery, `${tsName}.ts`), `export {};\n`); + + // Allowlist ONLY the sdk/src/.ts pair. The sdk/src/query/.ts + // sibling is intentionally NOT allowlisted and must be reported. + fs.writeFileSync( + path.join(scriptsDir, 'shared-module-handsync-allowlist.json'), + JSON.stringify( + { + cooperatingSiblings: [ + { + cjs: `get-shit-done/bin/lib/${cjsName}.cjs`, + ts: `sdk/src/${tsName}.ts`, + classification: 'cooperating-sibling', + justification: 'Test: only the non-query sibling is allowlisted.', + }, + ], + migrateMeBacklog: [], + }, + null, + 2 + ) + ); + + const { status, payload } = runLintJson(['--root', tmpDir]); + assert.strictEqual( + status, + 1, + 'must fail: the sdk/src/query/.ts sibling is not allowlisted' + ); + assert.ok(payload); + assert.strictEqual(payload.ok, false); + assert.strictEqual(payload.reason, 'unauthorized_pairs'); + assert.ok(Array.isArray(payload.errors) && payload.errors.length >= 1); + const reportedTs = payload.errors.flatMap((e) => e.tsPaths); + assert.ok( + reportedTs.some((p) => /sdk\/src\/query\/multi-sibling\.ts$/.test(p)), + `expected query sibling in errors, got: ${JSON.stringify(reportedTs)}` + ); + // The allowlisted sibling must NOT appear in errors. + assert.ok( + !reportedTs.some((p) => /^sdk\/src\/multi-sibling\.ts$/.test(p)), + `allowlisted sibling sdk/src/multi-sibling.ts must not be flagged, got: ${JSON.stringify(reportedTs)}` + ); + } finally { + cleanupFixture(tmpDir); + } + }); + + test('generated .cjs files are excluded from pair detection', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-gen-')); + try { + const cjsDir = path.join(tmpDir, 'get-shit-done', 'bin', 'lib'); + fs.mkdirSync(cjsDir, { recursive: true }); + const tsDir = path.join(tmpDir, 'sdk', 'src', 'query'); + fs.mkdirSync(tsDir, { recursive: true }); + const scriptsDir = path.join(tmpDir, 'scripts'); + fs.mkdirSync(scriptsDir, { recursive: true }); + + // A .generated.cjs file + matching TS — should NOT trigger lint error + fs.writeFileSync(path.join(cjsDir, 'my-module.generated.cjs'), `'use strict';\n`); + fs.writeFileSync(path.join(tsDir, 'my-module.ts'), `export {};\n`); + + fs.writeFileSync( + path.join(scriptsDir, 'shared-module-handsync-allowlist.json'), + JSON.stringify({ cooperatingSiblings: [], migrateMeBacklog: [] }, null, 2) + ); + + const { status, payload } = runLintJson(['--root', tmpDir]); + assert.strictEqual(status, 0); + assert.ok(payload); + assert.strictEqual(payload.ok, true); + } finally { + cleanupFixture(tmpDir); + } + }); +}); diff --git a/tests/phase-6-cjs-sdk-seam-contracts.test.cjs b/tests/phase-6-cjs-sdk-seam-contracts.test.cjs new file mode 100644 index 000000000..b743f12a6 --- /dev/null +++ b/tests/phase-6-cjs-sdk-seam-contracts.test.cjs @@ -0,0 +1,507 @@ +'use strict'; + +/** + * Phase 6 (issue #3524 / PR #3577) — CJS↔SDK seam behavioral contract tests. + * + * Issue #3592 explicitly tracks the migration away from text-existence / + * source-grep tests onto behavioral contract tests. This file is the + * behavioral contract surface for everything Phase 6 introduced: + * + * • `get-shit-done/bin/lib/cjs-sdk-bridge.cjs` — load + cache + surface + * • `sdk/src/runtime-bridge-sync/index.ts` — sync dispatch primitive + * (returns `RuntimeBridgeSyncResult`, a discriminated union with a + * fixed `SyncErrorKind` taxonomy) + * • The 7 family routers (`init|phase|phases|roadmap|state|validate| + * verify-command-router.cjs`) + top-level `gsd-tools.cjs` dispatch — + * each must route a canonical registry command through the bridge + * and emit a JSON-shaped result on stdout. + * • Workstream-scoped commands — Phase 6 made these native; the + * bridge must accept a `workstream` field and the CLI must still + * fall back to CJS when `GSD_WORKSTREAM` is set (the gate the + * routers use to defer to per-side CJS handlers). + * + * Test rules in force (from `CONTRIBUTING.md` § Testing Standards and + * issue #3592): + * + * 1. No `readFileSync` of any `.cjs` source file to assert text + * content. Every assertion is on a parsed JSON object, a + * filesystem fact, an exit code, or a frozen enum value. + * 2. No `assert.match`/`.includes` on free-form child-process stdout + * or stderr. Either parse JSON, or assert on a structured field + * via the bridge API directly. + * 3. Frozen enums describe the canonical taxonomies the production + * code MUST emit. Drift between production and test fails the + * object-shape lock test, not a substring lookup. + * 4. Filesystem assertions use `fs.statSync().isFile()` / `.size` — + * never read the file content back as a substring assertion. + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); + +const REPO_ROOT = path.join(__dirname, '..'); +const BRIDGE_PATH = path.join(REPO_ROOT, 'get-shit-done', 'bin', 'lib', 'cjs-sdk-bridge.cjs'); + +// ─── Frozen taxonomies ──────────────────────────────────────────────────────── +// +// These describe the canonical shapes Phase 6 ships. Tests assert against the +// enum values, not against substring matches. Adding a new error kind or a +// new bridge export requires updating BOTH the production code AND the +// matching frozen set below — that's three coordinated edits, which is the +// drift-prevention property the new contract pattern is meant to provide. + +/** SDK runtime-bridge-sync `SyncErrorKind` taxonomy (sdk/src/runtime-bridge-sync/index.ts:62-68). */ +const SYNC_ERROR_KIND = Object.freeze({ + UNKNOWN_COMMAND: 'unknown_command', + NATIVE_FAILURE: 'native_failure', + NATIVE_TIMEOUT: 'native_timeout', + FALLBACK_FAILURE: 'fallback_failure', + VALIDATION_ERROR: 'validation_error', + INTERNAL_ERROR: 'internal_error', +}); + +const SYNC_ERROR_KIND_VALUES = Object.freeze(new Set(Object.values(SYNC_ERROR_KIND))); + +/** Surface of `cjs-sdk-bridge.cjs`. Adding an export requires updating both. */ +const BRIDGE_EXPORTS = Object.freeze([ + 'tryLoadSdk', + 'getExecuteForCjs', + 'getFormatStateLoadRawStdout', + 'getSdkModule', +]); + +/** TransportMode values accepted by executeForCjs. Bridge must support both. */ +const TRANSPORT_MODE = Object.freeze({ JSON: 'json', RAW: 'raw' }); + +// ─── Bridge module helper ───────────────────────────────────────────────────── +// +// Fresh-require the bridge once per describe block so each test sees an +// isolated load state. `delete require.cache[...]` is the canonical +// reset; never patch internals. + +function freshBridge() { + delete require.cache[require.resolve(BRIDGE_PATH)]; + return require(BRIDGE_PATH); +} + +// ─── 1. Bridge module surface contract ───────────────────────────────────────── + +describe('phase 6: cjs-sdk-bridge surface', () => { + test('exposes exactly the documented exports — frozen set', () => { + const bridge = freshBridge(); + const actual = Object.keys(bridge).sort(); + assert.deepStrictEqual( + actual, + [...BRIDGE_EXPORTS].sort(), + 'bridge surface drifted from BRIDGE_EXPORTS — update both production code and the frozen set together', + ); + }); + + test('every documented export is a function', () => { + const bridge = freshBridge(); + for (const name of BRIDGE_EXPORTS) { + assert.strictEqual(typeof bridge[name], 'function', `${name} must be a function`); + } + }); +}); + +// ─── 2. Bridge load + cache contract ────────────────────────────────────────── + +describe('phase 6: cjs-sdk-bridge load lifecycle', () => { + test('tryLoadSdk resolves the bundled SDK on a working checkout', () => { + const bridge = freshBridge(); + assert.strictEqual(bridge.tryLoadSdk(), true); + }); + + test('post-load getters return non-null when tryLoadSdk succeeded', () => { + const bridge = freshBridge(); + bridge.tryLoadSdk(); + assert.strictEqual(typeof bridge.getExecuteForCjs(), 'function'); + assert.strictEqual(typeof bridge.getFormatStateLoadRawStdout(), 'function'); + const mod = bridge.getSdkModule(); + assert.ok(mod && typeof mod === 'object', 'getSdkModule must return the cached module object'); + assert.strictEqual(typeof mod.executeForCjs, 'function'); + }); + + test('repeated tryLoadSdk calls return the cached result (same reference)', () => { + const bridge = freshBridge(); + bridge.tryLoadSdk(); + const fn1 = bridge.getExecuteForCjs(); + bridge.tryLoadSdk(); + const fn2 = bridge.getExecuteForCjs(); + assert.strictEqual(fn1, fn2, 'getExecuteForCjs must return the same cached function'); + }); + + test('pre-load getters return null', () => { + const bridge = freshBridge(); + assert.strictEqual(bridge.getExecuteForCjs(), null); + assert.strictEqual(bridge.getFormatStateLoadRawStdout(), null); + assert.strictEqual(bridge.getSdkModule(), null); + }); +}); + +// ─── 3. executeForCjs discriminated-union result shape ──────────────────────── + +describe('phase 6: executeForCjs RuntimeBridgeSyncResult shape', () => { + let bridge; + let executeForCjs; + let tmpDir; + + beforeEach(() => { + bridge = freshBridge(); + bridge.tryLoadSdk(); + executeForCjs = bridge.getExecuteForCjs(); + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('ok:true result shape — { ok, data, exitCode }', () => { + const result = executeForCjs({ + registryCommand: 'generate-slug', + registryArgs: ['Phase 6 Seam Contract'], + legacyCommand: 'generate-slug', + legacyArgs: ['Phase 6 Seam Contract'], + mode: TRANSPORT_MODE.JSON, + projectDir: tmpDir, + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.exitCode, 0); + assert.ok(result.data && typeof result.data === 'object', 'data must be an object on ok:true'); + assert.strictEqual(typeof result.data.slug, 'string'); + }); + + test('ok:false result for unknown command — errorKind ∈ SyncErrorKind, exitCode ≠ 0', () => { + const result = executeForCjs({ + registryCommand: 'totally.unknown.command.xyz', + registryArgs: [], + legacyCommand: 'totally.unknown.command.xyz', + legacyArgs: [], + mode: TRANSPORT_MODE.JSON, + projectDir: tmpDir, + }); + assert.strictEqual(result.ok, false); + assert.notStrictEqual(result.exitCode, 0); + assert.ok( + SYNC_ERROR_KIND_VALUES.has(result.errorKind), + `errorKind "${result.errorKind}" must be one of ${[...SYNC_ERROR_KIND_VALUES].join(', ')}`, + ); + assert.ok(Array.isArray(result.stderrLines), 'stderrLines must be an array on ok:false'); + }); + + test('mode:"json" returns parsed data, never a JSON-encoded string', () => { + // Regression for the Wave-1 bug where routers passed `mode: 'raw'` and the + // bridge pre-rendered to a JSON string that CJS output() then double- + // stringified. result.data MUST be a structured object/array/primitive + // — never a string that itself parses as JSON. + const result = executeForCjs({ + registryCommand: 'generate-slug', + registryArgs: ['Mode Json Check'], + legacyCommand: 'generate-slug', + legacyArgs: ['Mode Json Check'], + mode: TRANSPORT_MODE.JSON, + projectDir: tmpDir, + }); + assert.strictEqual(result.ok, true); + assert.notStrictEqual(typeof result.data, 'string', + 'mode:"json" must hand callers parsed data, not a serialized JSON blob'); + }); +}); + +// ─── 4. CLI family-router dispatch contracts ────────────────────────────────── +// +// One representative read-only command per family. Each test: +// 1. Invokes the CLI through `runGsdTools` (real child process). +// 2. Asserts exit success. +// 3. Parses stdout as JSON. +// 4. Asserts on a structured field, not on prose. +// +// This is the byte-for-byte parity contract Phase 6 promised: SDK-routed +// commands emit the same JSON shape as the legacy CJS handlers used to. + +describe('phase 6: CLI family-router dispatch emits structured JSON', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + // Minimal ROADMAP fixture for any family that scans it. + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# v1.0 Roadmap', + '', + '### Phase 1: Foundation', + '**Goal:** Setup', + '**Requirements**: REQ-01', + '**Plans:** 0 plans', + '', + ].join('\n'), + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + [ + '# State', + '', + '**Current Phase:** 01', + '**Status:** In progress', + '**Total Plans in Phase:** 0', + '**Progress:** [░░░░░░░░░░] 0%', + '**Last Activity:** 2026-05-15', + '', + ].join('\n'), + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('roadmap.get-phase emits found:true with structured phase fields', () => { + const result = runGsdTools(['roadmap', 'get-phase', '1'], tmpDir); + assert.ok(result.success, `roadmap get-phase failed: ${result.error}`); + const payload = JSON.parse(result.output); + assert.strictEqual(payload.found, true); + assert.strictEqual(payload.phase_number, '1'); + assert.strictEqual(payload.phase_name, 'Foundation'); + }); + + test('roadmap.analyze emits a milestones array', () => { + const result = runGsdTools(['roadmap', 'analyze'], tmpDir); + assert.ok(result.success, `roadmap analyze failed: ${result.error}`); + const payload = JSON.parse(result.output); + assert.ok(Array.isArray(payload.phases), 'phases must be an array'); + }); + + test('phase next-decimal emits a structured next/base shape', () => { + const result = runGsdTools(['phase', 'next-decimal', '1'], tmpDir); + assert.ok(result.success, `phase next-decimal failed: ${result.error}`); + const payload = JSON.parse(result.output); + assert.strictEqual(payload.base_phase, '01'); + assert.strictEqual(typeof payload.next, 'string'); + assert.ok(Array.isArray(payload.existing), 'existing must be an array'); + }); + + test('phases list emits a directories array with count', () => { + const result = runGsdTools(['phases', 'list'], tmpDir); + assert.ok(result.success, `phases list failed: ${result.error}`); + const payload = JSON.parse(result.output); + assert.ok(Array.isArray(payload.directories), 'directories must be an array'); + assert.strictEqual(typeof payload.count, 'number'); + }); + + test('state json emits a frontmatter object with progress', () => { + const result = runGsdTools(['state', 'json'], tmpDir); + assert.ok(result.success, `state json failed: ${result.error}`); + const payload = JSON.parse(result.output); + assert.strictEqual(payload.gsd_state_version, '1.0'); + assert.ok(payload.progress && typeof payload.progress === 'object', + 'progress must be a structured object, not a serialized string'); + }); + + test('init plan-phase emits phase_found + model fields', () => { + const result = runGsdTools(['init', 'plan-phase', '1'], tmpDir); + assert.ok(result.success, `init plan-phase failed: ${result.error}`); + const payload = JSON.parse(result.output); + assert.strictEqual(payload.phase_found, true); + assert.strictEqual(payload.phase_number, '1'); + assert.strictEqual(typeof payload.researcher_model, 'string'); + }); + + test('validate consistency emits valid + warnings array', () => { + const result = runGsdTools(['validate', 'consistency'], tmpDir); + assert.ok(result.success, `validate consistency failed: ${result.error}`); + const payload = JSON.parse(result.output); + assert.ok(typeof payload.valid === 'boolean' || Array.isArray(payload.warnings), + 'validate consistency must emit either {valid, warnings} shape'); + }); + + test('find-phase for non-existent phase emits found:false (not a process error)', () => { + const result = runGsdTools(['find-phase', '99'], tmpDir); + assert.ok(result.success, `find-phase should not error on missing phase: ${result.error}`); + const payload = JSON.parse(result.output); + assert.strictEqual(payload.found, false); + }); +}); + +// ─── 5. mode:"json" prevents double-stringify (Wave 1 bug regression) ───────── + +describe('phase 6: mode:"json" never double-stringifies the data', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# v1.0', + '', + '### Phase 1: Setup', + '**Goal:** Initial setup', + '', + ].join('\n'), + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // The Wave-1 bug shape: stdout looked like JSON of JSON, e.g. + // "\"{\\n \\\"found\\\": true\"". + // After the fix, stdout is a single JSON object that parses to an object — + // never a string that itself parses to an object. + test('roadmap get-phase stdout parses to an object, not a JSON-encoded string', () => { + const result = runGsdTools(['roadmap', 'get-phase', '1'], tmpDir); + assert.ok(result.success, `command failed: ${result.error}`); + const first = JSON.parse(result.output); + assert.strictEqual( + typeof first, + 'object', + 'CLI stdout for a JSON-mode command must parse directly to an object', + ); + assert.notStrictEqual( + typeof first, + 'string', + 'double-stringify regression: stdout parsed to a string that would itself parse as JSON', + ); + }); +}); + +// ─── 6. Workstream-scoped CJS fallback gate ──────────────────────────────────── +// +// Phase 6 made workstream-scoped commands native in the SDK transport, BUT the +// CJS routers still force CJS fallback when `GSD_WORKSTREAM` is set in the +// environment, so workstream-aware tests and inspections can target a +// specific workstream's `.planning/` slice without round-tripping through +// the synckit worker. Both modes must work and must produce the same JSON +// shape for the same input fixture. + +describe('phase 6: GSD_WORKSTREAM gate routes through CJS fallback consistently', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + ['# v1.0', '', '### Phase 1: Setup', '**Goal:** Setup', ''].join('\n'), + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('roadmap get-phase produces identical structured output with and without GSD_WORKSTREAM unset', () => { + const sdkPath = runGsdTools(['roadmap', 'get-phase', '1'], tmpDir); + assert.ok(sdkPath.success, `SDK dispatch failed: ${sdkPath.error}`); + const sdkPayload = JSON.parse(sdkPath.output); + + // When GSD_WORKSTREAM is set, the router falls through to CJS. For the + // primary planning slice (no workstream subdir yet), passing the env var + // should still parse the same ROADMAP.md and emit the same fields. + const cjsPath = runGsdTools(['roadmap', 'get-phase', '1'], tmpDir, { GSD_WORKSTREAM: '' }); + assert.ok(cjsPath.success, `CJS fallback dispatch failed: ${cjsPath.error}`); + const cjsPayload = JSON.parse(cjsPath.output); + + // Compare structured fields, never the rendered text. + assert.strictEqual(sdkPayload.found, cjsPayload.found); + assert.strictEqual(sdkPayload.phase_number, cjsPayload.phase_number); + assert.strictEqual(sdkPayload.phase_name, cjsPayload.phase_name); + }); +}); + +// ─── 7. Validation-error contract for malformed input ────────────────────────── +// +// When a registry command receives an invalid argument, the bridge must map +// the error to `validation_error` in the SyncErrorKind taxonomy and surface a +// non-zero exit code. This is the "negative path" coverage that #3592 +// explicitly calls out as required. + +describe('phase 6: validation errors map to SyncErrorKind.validation_error', () => { + let bridge; + let executeForCjs; + let tmpDir; + + beforeEach(() => { + bridge = freshBridge(); + bridge.tryLoadSdk(); + executeForCjs = bridge.getExecuteForCjs(); + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('find-phase with empty phase identifier returns ok:false + validation_error', () => { + const result = executeForCjs({ + registryCommand: 'find-phase', + registryArgs: [], + legacyCommand: 'find-phase', + legacyArgs: [], + mode: TRANSPORT_MODE.JSON, + projectDir: tmpDir, + }); + assert.strictEqual(result.ok, false); + assert.strictEqual(result.errorKind, SYNC_ERROR_KIND.VALIDATION_ERROR, + `validation errors must map to ${SYNC_ERROR_KIND.VALIDATION_ERROR}, got ${result.errorKind}`); + assert.notStrictEqual(result.exitCode, 0, 'validation_error must produce a non-zero exit code'); + }); +}); + +// ─── 8. Filesystem-fact write contract ───────────────────────────────────────── +// +// Phase 6 routes phase.add through the SDK. After a successful add, the +// phase directory and ROADMAP entry must be on disk. Test asserts on +// filesystem facts (`existsSync`, `statSync().isDirectory()`, file size > 0) +// — never reads the file content back as a substring assertion. + +describe('phase 6: phase.add SDK dispatch writes the expected filesystem facts', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# v1.0 Roadmap', + '', + '### Phase 1: Foundation', + '**Goal:** Setup', + '', + '---', + '', + ].join('\n'), + ); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('phase add User Dashboard creates phase 2 directory + appends ROADMAP entry', () => { + const before = fs.statSync(path.join(tmpDir, '.planning', 'ROADMAP.md')); + const result = runGsdTools(['phase', 'add', 'User', 'Dashboard'], tmpDir); + assert.ok(result.success, `phase add failed: ${result.error}`); + + const payload = JSON.parse(result.output); + assert.strictEqual(payload.phase_number, 2); + assert.strictEqual(payload.slug, 'user-dashboard'); + + // Filesystem facts: the directory exists and is a directory; the roadmap + // file grew (write happened). We do not read the file back to look for + // substrings — that's the prohibited pattern. + const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-user-dashboard'); + assert.ok(fs.existsSync(phaseDir), 'new phase directory must exist on disk'); + assert.ok(fs.statSync(phaseDir).isDirectory(), 'phase path must be a directory'); + + const after = fs.statSync(path.join(tmpDir, '.planning', 'ROADMAP.md')); + assert.ok(after.size > before.size, 'ROADMAP.md must grow when phase add appends an entry'); + }); +}); diff --git a/tests/phases-command-router.test.cjs b/tests/phases-command-router.test.cjs index 6f61ecc2a..5164c304b 100644 --- a/tests/phases-command-router.test.cjs +++ b/tests/phases-command-router.test.cjs @@ -1,10 +1,25 @@ 'use strict'; -const { describe, test } = require('node:test'); +const { describe, test, before, after } = require('node:test'); const assert = require('node:assert/strict'); const { routePhasesCommand } = require('../get-shit-done/bin/lib/phases-command-router.cjs'); +// These tests exercise the CJS dispatch path of the router. Since #3577 the +// router prefers the SDK bridge when sdk/dist is present, which would bypass +// the mocked `phase`/`milestone` handlers below. The router gates SDK +// dispatch on `process.env.GSD_WORKSTREAM` being unset, so set it for these +// tests to deterministically take the CJS path that the mocks model. +let _prevWorkstream; +before(() => { + _prevWorkstream = process.env.GSD_WORKSTREAM; + process.env.GSD_WORKSTREAM = 'test-unit'; +}); +after(() => { + if (_prevWorkstream === undefined) delete process.env.GSD_WORKSTREAM; + else process.env.GSD_WORKSTREAM = _prevWorkstream; +}); + describe('phases-command-router', () => { test('routes phases list with parsed options', () => { const calls = []; diff --git a/tests/plan-scan-generator.test.cjs b/tests/plan-scan-generator.test.cjs new file mode 100644 index 000000000..980453f5e --- /dev/null +++ b/tests/plan-scan-generator.test.cjs @@ -0,0 +1,196 @@ +'use strict'; + +/** + * Parity test — verifies that plan-scan.generated.cjs produces identical + * results to the compiled SDK ESM output for all exported functions. + * + * SDK side: import('../sdk/dist/query/plan-scan.js') + * CJS side: require('../get-shit-done/bin/lib/plan-scan.generated.cjs') + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const { createRequire } = require('node:module'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); +const crypto = require('crypto'); + +/** + * Build a unique-to-this-run path that is guaranteed not to exist. Hardcoded + * `/tmp/...` paths are a flake source on shared CI runners where the path can + * be left over from a prior run. We synthesize a random suffix under + * `os.tmpdir()` and force-remove the path first. + */ +function uniqueMissingPath(prefix = 'gsd-missing') { + const suffix = `${prefix}-${process.pid}-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`; + const p = path.join(os.tmpdir(), suffix); + // The probability of collision is negligible, but force-clean anyway to make + // the precondition explicit. Errors swallowed (path didn't exist — desired). + try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* noop */ } + return p; +} + +const requireFromRoot = createRequire(__filename); + +// CJS side — direct require works fine +const cjs = requireFromRoot('../get-shit-done/bin/lib/plan-scan.generated.cjs'); + +// ── isRootPlanFile ──────────────────────────────────────────────────────── + +describe('plan-scan-generator parity: isRootPlanFile', async () => { + const sdk = await import('../sdk/dist/query/plan-scan.js'); + + const fixtures = [ + { label: 'accepts bare PLAN.md', name: 'PLAN.md', expected: true }, + { label: 'accepts canonical -PLAN.md', name: '01-01-PLAN.md', expected: true }, + { label: 'accepts extended PLAN-01-setup.md', name: 'PLAN-01-setup.md', expected: true }, + { label: 'rejects -PLAN-OUTLINE.md', name: 'something-PLAN-OUTLINE.md', expected: false }, + { label: 'rejects .pre-bounce.md', name: 'PLAN.pre-bounce.md', expected: false }, + { label: 'rejects SUMMARY.md', name: 'SUMMARY.md', expected: false }, + { label: 'rejects unrelated file', name: 'README.md', expected: false }, + ]; + + for (const { label, name, expected } of fixtures) { + test(label, () => { + const sdkResult = sdk.isRootPlanFile(name); + const cjsResult = cjs.isRootPlanFile(name); + assert.strictEqual(sdkResult, expected, `SDK: ${label}`); + assert.strictEqual(cjsResult, expected, `CJS: ${label}`); + assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`); + }); + } +}); + +// ── isNestedPlanFile ────────────────────────────────────────────────────── + +describe('plan-scan-generator parity: isNestedPlanFile', async () => { + const sdk = await import('../sdk/dist/query/plan-scan.js'); + + const fixtures = [ + { label: 'accepts PLAN-01-setup.md', name: 'PLAN-01-setup.md', expected: true }, + { label: 'accepts 1-PLAN-01-setup.md', name: '1-PLAN-01-setup.md', expected: true }, + { label: 'rejects PLAN-OUTLINE.md', name: 'PLAN-01-OUTLINE.md', expected: false }, + { label: 'rejects .pre-bounce.md', name: 'PLAN-01.pre-bounce.md', expected: false }, + { label: 'rejects bare PLAN.md', name: 'PLAN.md', expected: false }, + { label: 'rejects unrelated file', name: 'SUMMARY-01-setup.md', expected: false }, + ]; + + for (const { label, name, expected } of fixtures) { + test(label, () => { + const sdkResult = sdk.isNestedPlanFile(name); + const cjsResult = cjs.isNestedPlanFile(name); + assert.strictEqual(sdkResult, expected, `SDK: ${label}`); + assert.strictEqual(cjsResult, expected, `CJS: ${label}`); + assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`); + }); + } +}); + +// ── isRootSummaryFile ───────────────────────────────────────────────────── + +describe('plan-scan-generator parity: isRootSummaryFile', async () => { + const sdk = await import('../sdk/dist/query/plan-scan.js'); + + const fixtures = [ + { label: 'accepts bare SUMMARY.md', name: 'SUMMARY.md', expected: true }, + { label: 'accepts 01-01-SUMMARY.md', name: '01-01-SUMMARY.md', expected: true }, + { label: 'rejects PLAN.md', name: 'PLAN.md', expected: false }, + { label: 'rejects unrelated file', name: 'README.md', expected: false }, + ]; + + for (const { label, name, expected } of fixtures) { + test(label, () => { + const sdkResult = sdk.isRootSummaryFile(name); + const cjsResult = cjs.isRootSummaryFile(name); + assert.strictEqual(sdkResult, expected, `SDK: ${label}`); + assert.strictEqual(cjsResult, expected, `CJS: ${label}`); + assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`); + }); + } +}); + +// ── isNestedSummaryFile ─────────────────────────────────────────────────── + +describe('plan-scan-generator parity: isNestedSummaryFile', async () => { + const sdk = await import('../sdk/dist/query/plan-scan.js'); + + const fixtures = [ + { label: 'accepts SUMMARY-01-summary.md', name: 'SUMMARY-01-summary.md', expected: true }, + { label: 'accepts 1-SUMMARY-01.md', name: '1-SUMMARY-01.md', expected: true }, + { label: 'rejects bare SUMMARY.md', name: 'SUMMARY.md', expected: false }, + { label: 'rejects PLAN file', name: 'PLAN-01-setup.md', expected: false }, + ]; + + for (const { label, name, expected } of fixtures) { + test(label, () => { + const sdkResult = sdk.isNestedSummaryFile(name); + const cjsResult = cjs.isNestedSummaryFile(name); + assert.strictEqual(sdkResult, expected, `SDK: ${label}`); + assert.strictEqual(cjsResult, expected, `CJS: ${label}`); + assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`); + }); + } +}); + +// ── scanPhasePlans ──────────────────────────────────────────────────────── + +describe('plan-scan-generator parity: scanPhasePlans (non-existent dir)', async () => { + const sdk = await import('../sdk/dist/query/plan-scan.js'); + + test('returns zero counts for non-existent directory', () => { + const nonExistent = uniqueMissingPath('gsd-plan-scan-nonexistent'); + const sdkResult = sdk.scanPhasePlans(nonExistent); + const cjsResult = cjs.scanPhasePlans(nonExistent); + assert.deepStrictEqual(sdkResult, { + planCount: 0, + summaryCount: 0, + completed: false, + hasNestedPlans: false, + planFiles: [], + summaryFiles: [], + }); + assert.deepStrictEqual(sdkResult, cjsResult, 'SDK/CJS parity: non-existent dir'); + }); +}); + +describe('plan-scan-generator parity: scanPhasePlans (flat layout)', async () => { + const sdk = await import('../sdk/dist/query/plan-scan.js'); + + test('detects flat plan and summary files', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-plan-scan-test-')); + try { + fs.writeFileSync(path.join(tmpDir, '01-01-PLAN.md'), '# Plan'); + fs.writeFileSync(path.join(tmpDir, '01-01-SUMMARY.md'), '# Summary'); + fs.writeFileSync(path.join(tmpDir, 'README.md'), '# Readme'); + + const sdkResult = sdk.scanPhasePlans(tmpDir); + const cjsResult = cjs.scanPhasePlans(tmpDir); + + assert.strictEqual(sdkResult.planCount, 1, 'SDK: planCount'); + assert.strictEqual(sdkResult.summaryCount, 1, 'SDK: summaryCount'); + assert.strictEqual(sdkResult.completed, true, 'SDK: completed'); + assert.strictEqual(sdkResult.hasNestedPlans, false, 'SDK: hasNestedPlans'); + assert.deepStrictEqual(sdkResult, cjsResult, 'SDK/CJS parity: flat layout'); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } + }); +}); + +describe('plan-scan-generator parity: module.exports call style', async () => { + test('default export is callable as function (CJS caller pattern)', () => { + // CJS callers do: const scanPhasePlans = require('./plan-scan.cjs') + // then call it directly: scanPhasePlans(phaseDir) + assert.strictEqual(typeof cjs, 'function', 'default export is a function'); + const result = cjs(uniqueMissingPath('gsd-plan-scan-cjs-default')); + assert.deepStrictEqual(result, { + planCount: 0, + summaryCount: 0, + completed: false, + hasNestedPlans: false, + planFiles: [], + summaryFiles: [], + }); + }); +}); diff --git a/tests/roadmap-command-router.test.cjs b/tests/roadmap-command-router.test.cjs index 14d2e7fcb..47e652e2b 100644 --- a/tests/roadmap-command-router.test.cjs +++ b/tests/roadmap-command-router.test.cjs @@ -1,10 +1,25 @@ 'use strict'; -const { describe, test } = require('node:test'); +const { describe, test, before, after } = require('node:test'); const assert = require('node:assert/strict'); const { routeRoadmapCommand } = require('../get-shit-done/bin/lib/roadmap-command-router.cjs'); +// These tests exercise the CJS dispatch path of the router. Since #3577 the +// router prefers the SDK bridge when sdk/dist is present, which would bypass +// the mocked `roadmap` handlers below. The router gates SDK dispatch on +// `process.env.GSD_WORKSTREAM` being unset, so set it here to deterministically +// take the CJS path that the mocks model. +let _prevWorkstream; +before(() => { + _prevWorkstream = process.env.GSD_WORKSTREAM; + process.env.GSD_WORKSTREAM = 'test-unit'; +}); +after(() => { + if (_prevWorkstream === undefined) delete process.env.GSD_WORKSTREAM; + else process.env.GSD_WORKSTREAM = _prevWorkstream; +}); + describe('roadmap-command-router', () => { test('routes roadmap analyze', () => { const calls = []; diff --git a/tests/schema-detect-generator.test.cjs b/tests/schema-detect-generator.test.cjs new file mode 100644 index 000000000..e8a2e2149 --- /dev/null +++ b/tests/schema-detect-generator.test.cjs @@ -0,0 +1,195 @@ +'use strict'; + +/** + * Parity test — verifies that schema-detect.generated.cjs produces identical + * results to the compiled SDK ESM output for all exported functions. + * + * SDK side: import('../sdk/dist/query/schema-detect.js') + * CJS side: require('../get-shit-done/bin/lib/schema-detect.generated.cjs') + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const { createRequire } = require('node:module'); + +const requireFromRoot = createRequire(__filename); + +// CJS side — direct require works fine +const cjs = requireFromRoot('../get-shit-done/bin/lib/schema-detect.generated.cjs'); + +// ── detectSchemaFiles ───────────────────────────────────────────────────── + +describe('schema-detect-generator parity: detectSchemaFiles', async () => { + const sdk = await import('../sdk/dist/query/schema-detect.js'); + + const fixtures = [ + { + label: 'detects prisma schema', + files: ['prisma/schema.prisma'], + expectedDetected: true, + expectedOrms: ['prisma'], + }, + { + label: 'detects drizzle schema', + files: ['drizzle/schema.ts'], + expectedDetected: true, + expectedOrms: ['drizzle'], + }, + { + label: 'detects supabase migration', + files: ['supabase/migrations/001_init.sql'], + expectedDetected: true, + expectedOrms: ['supabase'], + }, + { + label: 'detects payload collection', + files: ['src/collections/Users.ts'], + expectedDetected: true, + expectedOrms: ['payload'], + }, + { + label: 'detects typeorm entity', + files: ['src/entities/User.ts'], + expectedDetected: true, + expectedOrms: ['typeorm'], + }, + { + label: 'no schema files returns not detected', + files: ['src/components/Button.tsx', 'src/styles/main.css'], + expectedDetected: false, + expectedOrms: [], + }, + { + label: 'multiple ORMs detected', + files: ['prisma/schema.prisma', 'drizzle/schema.ts'], + expectedDetected: true, + expectedOrms: ['prisma', 'drizzle'], + }, + { + label: 'normalizes Windows backslash paths', + files: ['prisma\\schema.prisma'], + expectedDetected: true, + expectedOrms: ['prisma'], + }, + { + label: 'empty file list returns not detected', + files: [], + expectedDetected: false, + expectedOrms: [], + }, + ]; + + for (const { label, files, expectedDetected, expectedOrms } of fixtures) { + test(label, () => { + const sdkResult = sdk.detectSchemaFiles(files); + const cjsResult = cjs.detectSchemaFiles(files); + + assert.strictEqual(sdkResult.detected, expectedDetected, `SDK detected: ${label}`); + assert.deepStrictEqual(sdkResult.orms.sort(), expectedOrms.sort(), `SDK orms: ${label}`); + + assert.strictEqual(cjsResult.detected, expectedDetected, `CJS detected: ${label}`); + assert.deepStrictEqual(cjsResult.orms.sort(), expectedOrms.sort(), `CJS orms: ${label}`); + + // SDK and CJS must agree on detected and orms + assert.strictEqual(sdkResult.detected, cjsResult.detected, `SDK/CJS parity detected: ${label}`); + assert.deepStrictEqual(sdkResult.orms.sort(), cjsResult.orms.sort(), `SDK/CJS parity orms: ${label}`); + }); + } +}); + +// ── checkSchemaDrift ────────────────────────────────────────────────────── + +describe('schema-detect-generator parity: checkSchemaDrift', async () => { + const sdk = await import('../sdk/dist/query/schema-detect.js'); + + const fixtures = [ + { + label: 'no schema files — no drift', + changedFiles: ['src/components/Button.tsx'], + executionLog: '', + options: {}, + expectedDriftDetected: false, + expectedBlocking: false, + }, + { + label: 'prisma changed with push evidence — no drift', + changedFiles: ['prisma/schema.prisma'], + executionLog: 'running: npx prisma db push --accept-data-loss', + options: {}, + expectedDriftDetected: false, + expectedBlocking: false, + }, + { + label: 'prisma changed without push — drift blocking', + changedFiles: ['prisma/schema.prisma'], + executionLog: 'tsc && vitest run', + options: {}, + expectedDriftDetected: true, + expectedBlocking: true, + }, + { + label: 'drift with skipCheck=true — not blocking', + changedFiles: ['prisma/schema.prisma'], + executionLog: 'tsc && vitest run', + options: { skipCheck: true }, + expectedDriftDetected: true, + expectedBlocking: false, + }, + ]; + + for (const { label, changedFiles, executionLog, options, expectedDriftDetected, expectedBlocking } of fixtures) { + test(label, () => { + const sdkResult = sdk.checkSchemaDrift(changedFiles, executionLog, options); + const cjsResult = cjs.checkSchemaDrift(changedFiles, executionLog, options); + + assert.strictEqual(sdkResult.driftDetected, expectedDriftDetected, `SDK driftDetected: ${label}`); + assert.strictEqual(sdkResult.blocking, expectedBlocking, `SDK blocking: ${label}`); + + assert.strictEqual(cjsResult.driftDetected, expectedDriftDetected, `CJS driftDetected: ${label}`); + assert.strictEqual(cjsResult.blocking, expectedBlocking, `CJS blocking: ${label}`); + + // Full structural parity between SDK and CJS + assert.deepStrictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`); + }); + } +}); + +// ── detectSchemaOrm (CJS-only compat export) ────────────────────────────── + +describe('schema-detect-generator parity: detectSchemaOrm (CJS compat)', () => { + test('returns ORM info for known orm', () => { + const info = cjs.detectSchemaOrm('prisma'); + assert.ok(info !== null, 'prisma orm info should not be null'); + assert.ok(typeof info.pushCommand === 'string', 'pushCommand should be string'); + assert.ok(Array.isArray(info.evidencePatterns), 'evidencePatterns should be array'); + }); + + test('returns null for unknown orm', () => { + const info = cjs.detectSchemaOrm('unknown_orm'); + assert.strictEqual(info, null, 'unknown orm should return null'); + }); + + test('returns info for all 5 known ORMs', () => { + const orms = ['payload', 'prisma', 'drizzle', 'supabase', 'typeorm']; + for (const orm of orms) { + const info = cjs.detectSchemaOrm(orm); + assert.ok(info !== null, `${orm} info should not be null`); + } + }); +}); + +// ── SCHEMA_PATTERNS and ORM_INFO exports (compat) ──────────────────────── + +describe('schema-detect-generator: SCHEMA_PATTERNS and ORM_INFO exported', () => { + test('SCHEMA_PATTERNS is an array', () => { + assert.ok(Array.isArray(cjs.SCHEMA_PATTERNS), 'SCHEMA_PATTERNS should be an array'); + assert.ok(cjs.SCHEMA_PATTERNS.length > 0, 'SCHEMA_PATTERNS should not be empty'); + }); + + test('ORM_INFO has known orm keys', () => { + const orms = ['payload', 'prisma', 'drizzle', 'supabase', 'typeorm']; + for (const orm of orms) { + assert.ok(orm in cjs.ORM_INFO, `ORM_INFO should have key: ${orm}`); + } + }); +}); diff --git a/tests/secrets-generator.test.cjs b/tests/secrets-generator.test.cjs new file mode 100644 index 000000000..7b7cd9e1e --- /dev/null +++ b/tests/secrets-generator.test.cjs @@ -0,0 +1,136 @@ +'use strict'; + +/** + * Parity test — verifies that secrets.generated.cjs produces identical + * results to the compiled SDK ESM output for all exported functions. + * + * SDK side: import('../sdk/dist/query/secrets.js') + * CJS side: require('../get-shit-done/bin/lib/secrets.generated.cjs') + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const { createRequire } = require('node:module'); + +const requireFromRoot = createRequire(__filename); + +// CJS side — direct require works fine +const cjs = requireFromRoot('../get-shit-done/bin/lib/secrets.generated.cjs'); + +// ── SECRET_CONFIG_KEYS ──────────────────────────────────────────────────── + +describe('secrets-generator parity: SECRET_CONFIG_KEYS', async () => { + const sdk = await import('../sdk/dist/query/secrets.js'); + + test('contains same keys as SDK', () => { + const sdkKeys = [...sdk.SECRET_CONFIG_KEYS].sort(); + const cjsKeys = [...cjs.SECRET_CONFIG_KEYS].sort(); + assert.deepStrictEqual(cjsKeys, sdkKeys, 'SDK/CJS parity: SECRET_CONFIG_KEYS'); + }); + + test('contains brave_search', () => { + assert.ok(cjs.SECRET_CONFIG_KEYS.has('brave_search')); + assert.ok(sdk.SECRET_CONFIG_KEYS.has('brave_search')); + }); + + test('contains firecrawl', () => { + assert.ok(cjs.SECRET_CONFIG_KEYS.has('firecrawl')); + assert.ok(sdk.SECRET_CONFIG_KEYS.has('firecrawl')); + }); + + test('contains exa_search', () => { + assert.ok(cjs.SECRET_CONFIG_KEYS.has('exa_search')); + assert.ok(sdk.SECRET_CONFIG_KEYS.has('exa_search')); + }); +}); + +// ── isSecretKey ──────────────────────────────────────────────────────────── + +describe('secrets-generator parity: isSecretKey', async () => { + const sdk = await import('../sdk/dist/query/secrets.js'); + + const fixtures = [ + { label: 'brave_search is secret', key: 'brave_search', expected: true }, + { label: 'firecrawl is secret', key: 'firecrawl', expected: true }, + { label: 'exa_search is secret', key: 'exa_search', expected: true }, + { label: 'non-secret key returns false', key: 'model', expected: false }, + { label: 'empty string returns false', key: '', expected: false }, + { label: 'unrelated string returns false', key: 'api_key', expected: false }, + ]; + + for (const { label, key, expected } of fixtures) { + test(label, () => { + const sdkResult = sdk.isSecretKey(key); + const cjsResult = cjs.isSecretKey(key); + assert.strictEqual(sdkResult, expected, `SDK: ${label}`); + assert.strictEqual(cjsResult, expected, `CJS: ${label}`); + assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`); + }); + } +}); + +// ── maskSecret ──────────────────────────────────────────────────────────── + +describe('secrets-generator parity: maskSecret', async () => { + const sdk = await import('../sdk/dist/query/secrets.js'); + + const fixtures = [ + { label: 'null returns (unset)', value: null, expected: '(unset)' }, + { label: 'undefined returns (unset)', value: undefined, expected: '(unset)' }, + { label: 'empty string returns (unset)', value: '', expected: '(unset)' }, + { label: 'short string (< 8) returns ****', value: 'abc', expected: '****' }, + { label: '7-char string returns ****', value: '1234567', expected: '****' }, + { label: '8-char string returns ****', value: '12345678', expected: '****5678' }, + { label: 'long string returns ****', value: 'sk-ant-abc123def456', expected: '****f456' }, + ]; + + for (const { label, value, expected } of fixtures) { + test(label, () => { + const sdkResult = sdk.maskSecret(value); + const cjsResult = cjs.maskSecret(value); + assert.strictEqual(sdkResult, expected, `SDK: ${label}`); + assert.strictEqual(cjsResult, expected, `CJS: ${label}`); + assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`); + }); + } +}); + +// ── maskIfSecret ────────────────────────────────────────────────────────── + +describe('secrets-generator parity: maskIfSecret', async () => { + const sdk = await import('../sdk/dist/query/secrets.js'); + + const fixtures = [ + { + label: 'secret key gets masked', + key: 'brave_search', + value: 'sk-ant-12345678', + expectedType: 'string', + expectedValue: '****5678', + }, + { + label: 'non-secret key returns value unchanged', + key: 'model', + value: 'claude-opus-4-5', + expectedType: 'string', + expectedValue: 'claude-opus-4-5', + }, + { + label: 'secret key with null value returns (unset)', + key: 'firecrawl', + value: null, + expectedType: 'string', + expectedValue: '(unset)', + }, + ]; + + for (const { label, key, value, expectedValue } of fixtures) { + test(label, () => { + const sdkResult = sdk.maskIfSecret(key, value); + const cjsResult = cjs.maskIfSecret(key, value); + assert.strictEqual(sdkResult, expectedValue, `SDK: ${label}`); + assert.strictEqual(cjsResult, expectedValue, `CJS: ${label}`); + assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`); + }); + } +}); diff --git a/tests/workstream-name-policy-generator.test.cjs b/tests/workstream-name-policy-generator.test.cjs new file mode 100644 index 000000000..f1301a9d0 --- /dev/null +++ b/tests/workstream-name-policy-generator.test.cjs @@ -0,0 +1,145 @@ +'use strict'; + +/** + * Parity test: workstream-name-policy.generated.cjs vs sdk/src/workstream-name-policy.ts + * + * Verifies that the generated CJS artifact matches the SDK source-of-truth + * for all exports: toWorkstreamSlug, hasInvalidPathSegment, isValidActiveWorkstreamName, + * validateWorkstreamName. + * + * Covers: Phase 6 (#3575) MIGRATE_ME resolution for workstream-name-policy.cjs. + */ + +const assert = require('assert'); +const { describe, test } = require('node:test'); +const { + toWorkstreamSlug, + hasInvalidPathSegment, + isValidActiveWorkstreamName, + validateWorkstreamName, +} = require('../get-shit-done/bin/lib/workstream-name-policy.cjs'); + +// ─── toWorkstreamSlug ──────────────────────────────────────────────────────── + +describe('workstream-name-policy — toWorkstreamSlug', () => { + test('lowercases and collapses non-alphanumeric to hyphens', () => { + assert.strictEqual(toWorkstreamSlug('My Feature Branch'), 'my-feature-branch'); + assert.strictEqual(toWorkstreamSlug('hello_world'), 'hello-world'); + assert.strictEqual(toWorkstreamSlug('API v2'), 'api-v2'); + }); + + test('strips leading/trailing hyphens', () => { + assert.strictEqual(toWorkstreamSlug('--foo--'), 'foo'); + assert.strictEqual(toWorkstreamSlug(' spaces '), 'spaces'); + }); + + test('handles empty and nullish values', () => { + assert.strictEqual(toWorkstreamSlug(''), ''); + assert.strictEqual(toWorkstreamSlug(null), ''); + assert.strictEqual(toWorkstreamSlug(undefined), ''); + }); + + test('handles already-valid slug', () => { + assert.strictEqual(toWorkstreamSlug('my-feature'), 'my-feature'); + assert.strictEqual(toWorkstreamSlug('v2'), 'v2'); + }); +}); + +// ─── hasInvalidPathSegment ─────────────────────────────────────────────────── + +describe('workstream-name-policy — hasInvalidPathSegment', () => { + test('returns true for names with forward slash', () => { + assert.strictEqual(hasInvalidPathSegment('foo/bar'), true); + }); + + test('returns true for names with backslash', () => { + assert.strictEqual(hasInvalidPathSegment('foo\\bar'), true); + }); + + test('returns true for bare dot', () => { + assert.strictEqual(hasInvalidPathSegment('.'), true); + }); + + test('returns true for double dot', () => { + assert.strictEqual(hasInvalidPathSegment('..'), true); + }); + + test('returns true for names containing dot-dot sequence', () => { + assert.strictEqual(hasInvalidPathSegment('foo..bar'), true); + assert.strictEqual(hasInvalidPathSegment('../etc'), true); + }); + + test('returns false for valid workstream names', () => { + assert.strictEqual(hasInvalidPathSegment('my-feature'), false); + assert.strictEqual(hasInvalidPathSegment('v2'), false); + assert.strictEqual(hasInvalidPathSegment('feature.experimental'), false); + assert.strictEqual(hasInvalidPathSegment('alpha_1'), false); + }); + + test('handles empty and nullish values', () => { + assert.strictEqual(hasInvalidPathSegment(''), false); + assert.strictEqual(hasInvalidPathSegment(null), false); + assert.strictEqual(hasInvalidPathSegment(undefined), false); + }); +}); + +// ─── isValidActiveWorkstreamName ───────────────────────────────────────────── + +describe('workstream-name-policy — isValidActiveWorkstreamName', () => { + test('returns true for valid alphanumeric names', () => { + assert.strictEqual(isValidActiveWorkstreamName('feature'), true); + assert.strictEqual(isValidActiveWorkstreamName('v2'), true); + assert.strictEqual(isValidActiveWorkstreamName('my-branch'), true); + assert.strictEqual(isValidActiveWorkstreamName('feature.experimental'), true); + assert.strictEqual(isValidActiveWorkstreamName('alpha_1'), true); + assert.strictEqual(isValidActiveWorkstreamName('A1'), true); + }); + + test('returns false for names starting with non-alphanumeric', () => { + assert.strictEqual(isValidActiveWorkstreamName('-feature'), false); + assert.strictEqual(isValidActiveWorkstreamName('.feature'), false); + assert.strictEqual(isValidActiveWorkstreamName('_feature'), false); + }); + + test('returns false for names with path traversal', () => { + assert.strictEqual(isValidActiveWorkstreamName('..'), false); + assert.strictEqual(isValidActiveWorkstreamName('../etc'), false); + assert.strictEqual(isValidActiveWorkstreamName('foo..bar'), false); + }); + + test('returns false for names with slashes', () => { + assert.strictEqual(isValidActiveWorkstreamName('foo/bar'), false); + assert.strictEqual(isValidActiveWorkstreamName('foo\\bar'), false); + }); + + test('returns false for names with spaces', () => { + assert.strictEqual(isValidActiveWorkstreamName('my feature'), false); + }); + + test('returns false for empty string', () => { + assert.strictEqual(isValidActiveWorkstreamName(''), false); + }); + + test('returns false for nullish values', () => { + assert.strictEqual(isValidActiveWorkstreamName(null), false); + assert.strictEqual(isValidActiveWorkstreamName(undefined), false); + }); +}); + +// ─── validateWorkstreamName (SDK alias) ────────────────────────────────────── + +describe('workstream-name-policy — validateWorkstreamName (SDK alias)', () => { + test('is an alias for isValidActiveWorkstreamName', () => { + const testCases = [ + 'feature', 'v2', 'my-branch', '-bad', '', null, undefined, + 'foo/bar', '..', 'foo..bar', 'A1', 'alpha_1', + ]; + for (const tc of testCases) { + assert.strictEqual( + validateWorkstreamName(tc), + isValidActiveWorkstreamName(tc), + `validateWorkstreamName and isValidActiveWorkstreamName should agree on: ${JSON.stringify(tc)}`, + ); + } + }); +});