Merge remote-tracking branch 'upstream/next' into kimi-runtime-support
This commit is contained in:
5
.changeset/651-verification-status-consolidation.md
Normal file
5
.changeset/651-verification-status-consolidation.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 755
|
||||
---
|
||||
**Verification status routing is now owned by a single queryable seam** — `ship.md` and `execute-phase.md` both consume `gsd_run query verification.status` instead of re-deriving the `passed`/`gaps_found`/`human_needed` routing independently; the query returns `next_action` and `next_command` so per-status prose no longer needs to be kept in sync across files. This also fixes the broad-grep status misread in `execute-phase.md` where a body `status:` line (in a code block or copied artifact) could concatenate with the frontmatter value and misroute a valid passed phase; a parity test fails if a new verifier status value lacks a route. (#651)
|
||||
5
.changeset/703-plan-phase-granularity-flag.md
Normal file
5
.changeset/703-plan-phase-granularity-flag.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 750
|
||||
---
|
||||
`/gsd:plan-phase` now accepts a `--granularity <coarse|standard|fine>` flag to override the configured planning granularity for a single invocation. The flag takes precedence over `granularities.planning`, top-level `granularity`, and `planning.granularity` config. Invalid values are rejected. (#703)
|
||||
5
.changeset/730-roadmap-milestone-phase-details-scope.md
Normal file
5
.changeset/730-roadmap-milestone-phase-details-scope.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 748
|
||||
---
|
||||
The roadmap parser now resolves fresh phases of the current milestone in multi-milestone roadmaps. `extractCurrentMilestone()` scoped the current-milestone window to its `## Phases` checklist subsection and stopped at the milestone's own `## Milestone … (Phase Details)` heading, so the `### Phase N:` detail headers fell out of scope. Any command backed by the parser — `init.phase-op` (and therefore `/gsd:discuss-phase` and `/gsd:plan-phase`), `state`, `roadmap list`, and `validate health` (W006) — could not resolve phases of any milestone after the first until a `.planning/phases/` directory already existed, blocking discuss/plan. The parser now also includes the current milestone's `(Phase Details)` section in scope, anchored to the selected milestone's version token so sibling sub-milestones do not cross-pollinate. (#730)
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Security
|
||||
pr: 752
|
||||
---
|
||||
**`gsd-tools config-set` prototype-pollution guard hardened and regression-tested.** The guard that blocks `__proto__`, `prototype`, and `constructor` segments in dotted config keys now uses inline literal comparisons at each property-write site (instead of a pre-loop `Set` check), so CodeQL's `js/prototype-pollution-utility` analysis recognises it as a sanitising barrier and code-scanning alert #26 clears. Runtime behaviour is unchanged from #663. Added regression tests that drive schema-valid dynamic-prefix keys (`agent_skills.__proto__`, `agent_skills.constructor`, `features.__proto__`, `review.models.constructor`) all the way to the guard — these reach `setConfigValue` past the schema gate and were previously the guard's only untested attack surface. (#751)
|
||||
5
.changeset/gentle-orcas-click.md
Normal file
5
.changeset/gentle-orcas-click.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 753
|
||||
---
|
||||
The `gsd-verifier` agent no longer re-runs the full workspace test suite once per must-have during Step 7b spot-checks — it enumerates tests to prove existence and runs a single named test to prove a pass, invoking the full suite at most once per verification.
|
||||
5
.changeset/noble-cranes-sing.md
Normal file
5
.changeset/noble-cranes-sing.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 749
|
||||
---
|
||||
**Phase execution no longer halts with `exit 42` (worktree base mismatch) when run on a branch diverged from the default branch (#683).** Claude Code forks worktree-isolated executors off the repository default branch (`origin/HEAD`), so running `/gsd-execute-phase` on an unmerged milestone/feature branch left every executor without the phase's plan files and tripped the `worktree-branch-check` guard (100% reproducible, all OSes). Execute-phase now detects this before dispatch and automatically degrades to sequential execution on the main working tree, recommending the permanent fix `worktree.baseRef:"head"`. Both fresh installs and upgrades of GSD Core set `worktree.baseRef:"head"` in `.claude/settings.local.json` automatically (no-clobber) when `workflow.use_worktrees` is enabled (the default); `gsd-tools worktree set-baseref` remains available for manual use (e.g. after toggling worktrees on later). The `exit 42` guard remains as a backstop.
|
||||
5
.changeset/rapid-mice-bark.md
Normal file
5
.changeset/rapid-mice-bark.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 754
|
||||
---
|
||||
**New `agent_skills_security.trusted_global_roots` config** — opt-in allowlist of trusted root directories so symlinked `global:` agent skills whose real path resolves outside the default skills dir (e.g. `~/.claude/skills`) are accepted; default `[]` is byte-identical and preserves the symlink-escape guard.
|
||||
7
.changeset/vivid-badgers-zip.md
Normal file
7
.changeset/vivid-badgers-zip.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 747
|
||||
---
|
||||
**Codex slash-command conversion no longer corrupts inline-wrapped `/gsd-…` file paths** — the install-time converter now identifies a real `/gsd-<command>` mention by positive boundaries (opening delimiter + no path continuation) instead of an unbounded preceding-character denylist, closing the path-corruption class (#637 → #704) by construction while still converting legitimate backtick-wrapped mentions.
|
||||
|
||||
<!-- docs-exempt: internal Codex install-time converter; no command, flag, output, or user-doc surface to update -->
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -106,6 +106,8 @@ build/
|
||||
/gsd-core/bin/lib/state-document.cjs
|
||||
/gsd-core/bin/lib/shell-command-projection.cjs
|
||||
/gsd-core/bin/lib/security.cjs
|
||||
/gsd-core/bin/lib/verification.cjs
|
||||
/gsd-core/bin/lib/verification-command-router.cjs
|
||||
/gsd-core/bin/lib/command-aliases.cjs
|
||||
/gsd-core/bin/lib/config-schema.cjs
|
||||
/gsd-core/bin/lib/model-profiles.cjs
|
||||
@@ -118,6 +120,7 @@ build/
|
||||
/gsd-core/bin/lib/install-profiles.cjs
|
||||
/gsd-core/bin/lib/intel.cjs
|
||||
/gsd-core/bin/lib/installer-migrations.cjs
|
||||
/gsd-core/bin/lib/worktree-base-ref.cjs
|
||||
/gsd-core/bin/lib/worktree-safety.cjs
|
||||
/gsd-core/bin/lib/planning-workspace.cjs
|
||||
/gsd-core/bin/lib/runtime-artifact-layout.cjs
|
||||
|
||||
@@ -526,6 +526,15 @@ The canonical lint infrastructure adopted in ADR 452 (`docs/adr/452-eslint-lint-
|
||||
`DEFECT.GENERATIVE-FIX=for any new constant/array/parser shared between two parallel surfaces (two workflow surfaces, or a generated artifact and its hand-authored source), the same commit MUST add a parity assertion that fails when the two diverge`
|
||||
`DEFECT.GENERATIVE-EXEMPLAR=tests/runtime-launcher-parity.test.cjs (asserts every workflow bash block uses the canonical gsd_run launcher — the in-repo pattern for enforcing equality across parallel surfaces)`
|
||||
|
||||
`DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.symptom=a YAML-frontmatter scalar (e.g. VERIFICATION.md status) read with grep "^key:" over the WHOLE markdown report instead of the frontmatter block; a key: line in the body (code block, copied artifact, example) returns extra matches that concatenate after cut|tr into a value matching no expected token, so a valid state is misrouted`
|
||||
`DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.examples=#586/PR #650 ship.md verification gate — grep "^status:" also matched body status: lines, yielding passed+gaps_found+human_needed instead of passed and blocking a passed phase; the same broad-grep still lives in execute-phase.md (consolidation tracked by #651)`
|
||||
`DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.detect=grep "^<key>:" on a *.md whose result is compared to exact tokens, with no frontmatter scoping and no -m1; one body line beginning <key>: is enough to break it`
|
||||
`DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.fix-forward=scope to the leading frontmatter block and take the first match: sed -n '/^---$/,/^---$/p' "$f" | grep -m1 "^<key>:" | cut -d: -f2 | tr -d ' '; fix every parallel copy in the same change or consolidate behind one queryable seam (#651)`
|
||||
`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.symptom=a test that parses a workflow bash block out of a *.md and runs it via execFileSync('bash',...) breaks on Windows two ways: the fence regex uses a literal \n after the bash fence that will not match CRLF and trips windows-test-parity-guard (fenceRegexLiteralNewline); and git-bash exists so a bash-presence probe is true, but an os.tmpdir() Windows path (C:\...) is un-globbable in bash so the pipeline returns empty and assertions fail`
|
||||
`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.examples=#586/PR #650 tests/ship-586-verification-routing.test.cjs — the fence \n offender failed ubuntu-24/macos/coverage, then the Windows tmpdir-path glob failed full test (windows-latest,22) at fail 3; both were invisible to file-scoped gsd-test-both runs because the parity guard is only scanned by the full suite`
|
||||
`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.detect=test does readFileSync(md).match for a bash fence with literal \n, OR execFileSync('bash',...) gated only on a bash-presence probe; also verifying a new test with a file-scoped run instead of the full suite hides repo-wide static guards`
|
||||
`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.fix-forward=match the fence with \r?\n and normalize the captured block to LF; gate pipeline execution on process.platform !== 'win32' && hasBash since the extraction LOGIC is platform-independent and POSIX coverage suffices; run the full suite (or the parity/lint guards) before push when adding a test file`
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -407,6 +407,8 @@ Plans should complete within ~50% context (not 80%). No context anxiety, quality
|
||||
|
||||
## Granularity Calibration
|
||||
|
||||
The resolved granularity is provided in the planning context as `**Granularity:** <value>`. Read that value and apply the corresponding row below. When no explicit value is present, default to Standard.
|
||||
|
||||
| Granularity | Typical Plans/Phase | Tasks/Plan |
|
||||
|-------------|---------------------|------------|
|
||||
| Coarse | 1-3 | 2-3 |
|
||||
|
||||
@@ -466,8 +466,11 @@ ls $BUILD_OUTPUT_DIR/*.{js,css} 2>/dev/null | wc -l
|
||||
# Module exports expected functions
|
||||
node -e "const m = require('$MODULE_PATH'); console.log(typeof m.$FUNCTION_NAME)" 2>/dev/null | grep -q "function"
|
||||
|
||||
# Test suite passes (if tests exist for this phase's code)
|
||||
npm test -- --grep "$PHASE_TEST_PATTERN" 2>&1 | grep -q "passing"
|
||||
# A test EXISTS (existence proof — enumerate, do NOT run the suite)
|
||||
cargo test -- --list 2>/dev/null | grep -q "$PHASE_TEST_PATTERN" # pytest --collect-only -q · npx vitest list · go test -list '.*'
|
||||
|
||||
# A specific test PASSES (run ONE named test, never the whole suite)
|
||||
cargo test "$TEST_NAME" -- --exact # pytest -k "$TEST_NAME" · npx vitest run -t "$TEST_NAME"
|
||||
```
|
||||
|
||||
2. **Run each check** and record pass/fail:
|
||||
@@ -487,6 +490,7 @@ npm test -- --grep "$PHASE_TEST_PATTERN" 2>&1 | grep -q "passing"
|
||||
- Each check must complete in under 10 seconds
|
||||
- Do not start servers or services — only test what's already runnable
|
||||
- Do not modify state (no writes, no mutations, no side effects)
|
||||
- **Run the full workspace test command at most once per verification.** Never filter a full run per must-have (`<full-suite> 2>&1 | grep X` repeated per truth) — it re-runs everything and yields no new evidence. Prove a test exists by enumeration (`--list` / `--collect-only`); prove one passes via a single named test. If a full run is genuinely required, run it once and `grep` the saved output.
|
||||
- If the project has no runnable entry points yet, skip with: "Step 7b: SKIPPED (no runnable entry points)"
|
||||
|
||||
## Step 7c: Probe Execution
|
||||
@@ -572,6 +576,8 @@ Classify status using this decision tree IN ORDER (most restrictive first):
|
||||
|
||||
**passed is ONLY valid when the human verification section is empty.** If you identified items requiring human testing in Step 8, status MUST be human_needed.
|
||||
|
||||
> **Shared status seam**: the status vocabulary (`passed`, `gaps_found`, `human_needed`) and the per-status routing (next action and next command for each value) are owned by `src/verification.cts` via `gsd_run query verification.status`. This agent is the single emitter of the frontmatter status field; consumers (ship.md, execute-phase.md) read routing from that query instead of re-deriving it.
|
||||
|
||||
**Score:** `verified_truths / total_truths`
|
||||
|
||||
## Step 9b: Filter Deferred Items
|
||||
|
||||
@@ -33,6 +33,10 @@ const {
|
||||
getGlobalConfigDir,
|
||||
getGlobalSkillsBase,
|
||||
} = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
||||
const {
|
||||
applyWorktreeBaseRef,
|
||||
readBaseRefFromSettings,
|
||||
} = require('../gsd-core/bin/lib/worktree-base-ref.cjs');
|
||||
|
||||
/**
|
||||
* Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies
|
||||
@@ -3165,17 +3169,22 @@ function convertClaudeAgentToClineAgent(content) {
|
||||
// ── End Cline converters ─────────────────────────────────────────────────────
|
||||
|
||||
function convertSlashCommandsToCodexSkillMentions(content) {
|
||||
// Convert colon-style skill invocations to Codex $ prefix
|
||||
// Colon-style /gsd: never appears as a filesystem path segment, so no boundary guard is needed (unlike the hyphen-style below).
|
||||
let converted = content.replace(/\/gsd:([a-z0-9-]+)/gi, (_, commandName) => {
|
||||
return `$gsd-${String(commandName).toLowerCase()}`;
|
||||
});
|
||||
// Convert hyphen-style command references (workflow output) to Codex $ prefix.
|
||||
// Negative lookbehind excludes shell path contexts where `/gsd-` is a path
|
||||
// segment, not a slash-command mention:
|
||||
// - word chars / dot / slash: `bin/gsd-tools.cjs`, `.claude/gsd-core/`
|
||||
// - `}`: shell variable expressions `${VAR}/gsd-core/` (#704)
|
||||
// - `)`: command-substitution paths `$(cmd)/gsd-local-patches` (#704)
|
||||
converted = converted.replace(/(?<![a-zA-Z0-9./})])\/gsd-([a-z0-9-]+)/gi, (_, commandName) => {
|
||||
// A real /gsd-<cmd> MENTION is defined positively by two boundaries, so any
|
||||
// in-path occurrence is excluded by construction (no denylist of preceding
|
||||
// chars to maintain — see #712, supersedes the #637/#704 lookbehind treadmill):
|
||||
// 1. Left boundary: opens at start-of-string, whitespace, or an inline-prose
|
||||
// delimiter (backtick/quote/paren/bracket) — e.g. `/gsd-execute-phase`.
|
||||
// 2. Right boundary: the command token is NOT followed by a path separator
|
||||
// `/` (a path continues: `/gsd-core/bin/...`; a command does not). The
|
||||
// `(?![a-z0-9/-])` also blocks regex backtracking to a shorter command.
|
||||
// This converts backtick-wrapped MENTIONS (`/gsd-foo`) while leaving backtick-
|
||||
// wrapped PATHS (`/gsd-core/workflows/update.md`) untouched (#712).
|
||||
converted = converted.replace(/(?<=^|[\s`"'([])\/gsd-([a-z0-9-]+)(?![a-z0-9/-])/gi, (_, commandName) => {
|
||||
return `$gsd-${String(commandName).toLowerCase()}`;
|
||||
});
|
||||
return converted;
|
||||
@@ -9012,6 +9021,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
// agentsSrc is declared here (let, not const) because installCodexConfig() inside the
|
||||
// Codex config block below also references it, and that block is outside the try scope.
|
||||
let agentsSrc = path.join(src, 'agents');
|
||||
// Capture upgrade signal BEFORE files are written (#683). Must be declared at function
|
||||
// scope (outside the try block below) so it is accessible in the settings section later.
|
||||
// Absent VERSION = fresh install; present VERSION = upgrade/re-install.
|
||||
const priorInstallExisted = fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION'));
|
||||
try {
|
||||
installerMigrationResult = runInstallerMigrations({
|
||||
configDir: targetDir,
|
||||
@@ -10572,6 +10585,68 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
? buildHookCommand(targetDir, 'gsd-update-banner.js', hookOpts)
|
||||
: localCmd('gsd-update-banner.js'));
|
||||
|
||||
// #683: Set worktree.baseRef:"head" in settings.local.json for local Claude installs.
|
||||
// Both fresh and upgrade paths apply only when worktrees are enabled for the project.
|
||||
// Never applies to global installs, non-Claude runtimes, or when the user already
|
||||
// has an explicit baseRef in EITHER settings.local.json OR settings.json (no-clobber).
|
||||
// Guard: skip entirely when settings is not a plain object (e.g. parsed to [] or primitive)
|
||||
// to avoid crashing applyWorktreeBaseRef on unexpected top-level shapes.
|
||||
if (isLocalClaude && settings !== null && typeof settings === 'object' && !Array.isArray(settings)) {
|
||||
// Read shared settings.json baseRef so no-clobber spans both files (#683 FIX 1).
|
||||
// shared settings.json no-clobber is checked here; settings.local.json no-clobber
|
||||
// is enforced inside applyWorktreeBaseRef itself.
|
||||
const sharedSettingsForBaseRef = readSettings(path.join(targetDir, 'settings.json')) || {};
|
||||
const sharedBaseRef = readBaseRefFromSettings(sharedSettingsForBaseRef);
|
||||
|
||||
// Compute worktrees-enabled ONCE for both fresh and upgrade paths (FIX A: DRY + consistency).
|
||||
// Read workflow.use_worktrees from .planning/config.json by walking up from
|
||||
// targetDir (same walk-up pattern as readGsdRuntimeProfileResolver). Defaults
|
||||
// to enabled (true) when the file is missing, unreadable, or the key is absent;
|
||||
// only boolean false disables (string "false" stays enabled).
|
||||
let worktreesEnabled = true; // default: enabled
|
||||
try {
|
||||
let probeDir = path.resolve(targetDir);
|
||||
for (let depth = 0; depth < 8; depth += 1) {
|
||||
const candidate = path.join(probeDir, '.planning', 'config.json');
|
||||
if (fs.existsSync(candidate)) {
|
||||
try {
|
||||
const parsed = JSON.parse(stripJsonComments(fs.readFileSync(candidate, 'utf-8')));
|
||||
if (parsed && typeof parsed === 'object' &&
|
||||
parsed.workflow && parsed.workflow.use_worktrees === false) {
|
||||
worktreesEnabled = false;
|
||||
}
|
||||
} catch {
|
||||
// Malformed config.json — treat as enabled (safe fallback).
|
||||
}
|
||||
break;
|
||||
}
|
||||
const parent = path.dirname(probeDir);
|
||||
if (parent === probeDir) break;
|
||||
probeDir = parent;
|
||||
}
|
||||
} catch {
|
||||
// Any unexpected error reading .planning — default to enabled.
|
||||
}
|
||||
|
||||
if (worktreesEnabled && sharedBaseRef === null) {
|
||||
if (!priorInstallExisted) {
|
||||
// Fresh install — apply no-clobber baseRef set.
|
||||
// canonical no-clobber logic: src/worktree-base-ref.cts applyWorktreeBaseRef (#683)
|
||||
const { changed } = applyWorktreeBaseRef(settings);
|
||||
if (changed) {
|
||||
console.log(` ${green}✓${reset} Set worktree.baseRef:"head" for Claude Code worktrees (forks phase worktrees off HEAD; #683)`);
|
||||
}
|
||||
} else {
|
||||
// Upgrade — auto-apply no-clobber baseRef set when worktrees are enabled.
|
||||
const { changed } = applyWorktreeBaseRef(settings);
|
||||
if (changed) {
|
||||
console.log(` ${green}✓${reset} Enabled worktree.baseRef:"head" for Claude Code worktrees (forks phase worktrees off HEAD; #683)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// When worktreesEnabled is false: do nothing, print nothing (both fresh and upgrade).
|
||||
}
|
||||
|
||||
persistActiveProfileMarker();
|
||||
return {
|
||||
settingsPath,
|
||||
@@ -11426,6 +11501,7 @@ module.exports = {
|
||||
install,
|
||||
installAllRuntimes,
|
||||
uninstall,
|
||||
convertSlashCommandsToCodexSkillMentions,
|
||||
convertClaudeCommandToCodexSkill,
|
||||
convertClaudeCommandToKimiSkill,
|
||||
convertKimiToolName,
|
||||
|
||||
@@ -292,6 +292,7 @@ GSD uses a multi-agent architecture where thin orchestrators (workflow files) sp
|
||||
- Logs issues for `/gsd-verify-work` to address
|
||||
- Milestone scope filtering: gaps addressed in later phases are marked as "deferred", not reported as failures (v1.32)
|
||||
- **Test quality audit** (v1.32): verifies that tests prove what they claim by checking for disabled/skipped tests on requirements, circular test patterns (system generating its own expected values), assertion strength (existence vs. value vs. behavioral), and expected value provenance. Blockers from test quality audit override an otherwise passing verification
|
||||
- Runs the full workspace test suite at most once per verification — proves a test *exists* by enumeration and that it *passes* via a single named test, never re-running the whole suite per must-have.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -421,6 +421,34 @@ node gsd-tools.cjs websearch <query> [--limit N] [--freshness day|week|month]
|
||||
|
||||
---
|
||||
|
||||
## Worktree Commands
|
||||
|
||||
Diagnose and configure the worktree fork base used by Claude Code's `isolation="worktree"` executor dispatch. These commands address the branch-divergence condition described in [Fix the worktree base-mismatch (exit 42) error](how-to/fix-worktree-base-mismatch.md).
|
||||
|
||||
```bash
|
||||
# Check whether the current HEAD has diverged from the worktree fork base.
|
||||
# Returns JSON: { shouldDegrade, reason, message, headSha, forkRef, forkSha }
|
||||
node gsd-tools.cjs worktree base-check
|
||||
|
||||
# Write worktree.baseRef:"head" into .claude/settings.local.json (no-clobber).
|
||||
# Returns JSON: { changed, skipped, previous, baseRef, file }
|
||||
node gsd-tools.cjs worktree set-baseref
|
||||
```
|
||||
|
||||
**`worktree base-check`** reads `worktree.baseRef` from `.claude/settings.local.json` (then `.claude/settings.json`) and compares the current `HEAD` SHA against `origin/HEAD`. The `shouldDegrade` field is `true` when the execute-phase orchestrator will fall back to sequential execution. Possible `reason` values:
|
||||
|
||||
| `reason` | `shouldDegrade` | Meaning |
|
||||
|---|---|---|
|
||||
| `baseref-head` | `false` | `worktree.baseRef:"head"` is set; no mismatch possible |
|
||||
| `head-matches-fork` | `false` | HEAD and `origin/HEAD` are the same commit |
|
||||
| `head-diverged-from-fork` | `true` | Branch is ahead of or diverged from `origin/HEAD` |
|
||||
| `fork-ref-unknown` | `true` | `origin/HEAD` could not be resolved |
|
||||
| `no-head` | `false` | Not in a git repo (no `HEAD`) |
|
||||
|
||||
**`worktree set-baseref`** applies a no-clobber write of `worktree.baseRef:"head"` to `.claude/settings.local.json`. If the file already contains an explicit `baseRef` value other than `"head"`, the existing value is preserved and `skipped:"explicit-other"` is returned. Malformed JSON causes an error rather than a silent overwrite. Both fresh installs and upgrades of GSD Core run this automatically when `workflow.use_worktrees` is enabled (the default); the command is also available for manual use — for example, to apply the setting when worktrees were toggled on after installation, or to re-apply it after a settings change.
|
||||
|
||||
---
|
||||
|
||||
## Graphify
|
||||
|
||||
Build, query, and inspect the project knowledge graph in `.planning/graphs/`. Requires `graphify.enabled: true` in `config.json` (see [Configuration Reference](CONFIGURATION.md#graphify-settings)).
|
||||
@@ -471,6 +499,7 @@ User-facing entry point: `/gsd-graphify` (see [Command Reference](COMMANDS.md#gs
|
||||
| Audit | `lib/audit.cjs` | Phase/milestone audit queue handlers; `audit-open` helper |
|
||||
| GSD2 Import | `lib/gsd2-import.cjs` | Reverse-migration importer from GSD-2 projects (backs `/gsd-import --from-gsd2`) |
|
||||
| Intel | `lib/intel.cjs` | Queryable codebase intelligence index (backs `/gsd-map-codebase --query`) |
|
||||
| Worktree Base Ref | `lib/worktree-base-ref.cjs` | Worktree fork-base detection and `worktree base-check` / `set-baseref` commands (#683) |
|
||||
|
||||
---
|
||||
|
||||
@@ -498,4 +527,5 @@ API keys configured via `/gsd-settings` (`brave_search`, `firecrawl`, `exa_searc
|
||||
- [Commands](COMMANDS.md)
|
||||
- [Configuration](CONFIGURATION.md)
|
||||
- [Architecture](ARCHITECTURE.md)
|
||||
- [Fix the worktree base-mismatch (exit 42) error](how-to/fix-worktree-base-mismatch.md)
|
||||
- [docs index](README.md)
|
||||
|
||||
@@ -157,6 +157,7 @@ Research, plan, and verify a phase.
|
||||
| `--skip-bounce` | Skip plan bounce even if enabled in config |
|
||||
| `--mvp` | Vertical MVP mode — planner organizes tasks as feature slices (UI→API→DB) instead of horizontal layers. On Phase 1 of a new project with no prior phase summaries, also emits `SKELETON.md` (Walking Skeleton). Can be persisted on a phase via `**Mode:** mvp` in ROADMAP.md, which applies `--mvp` automatically without the flag. |
|
||||
| `--tdd` | TDD mode — planner applies `type: tdd` to eligible behavior-adding tasks so each begins with a failing test. Composable with `--mvp`: `--mvp --tdd` produces vertical slices where every behavior-adding task starts red-green. |
|
||||
| `--granularity <coarse\|standard\|fine>` | Override the planning granularity for this invocation, ignoring config. Valid values: `coarse`, `standard`, `fine`. Takes precedence over `granularities.planning`, top-level `granularity`, and `planning.granularity` config. |
|
||||
|
||||
**Prerequisites:** `.planning/ROADMAP.md` exists
|
||||
**Produces:** `{phase}-RESEARCH.md`, `{phase}-{N}-PLAN.md`, `{phase}-VALIDATION.md`; `{phase}/SKELETON.md` when Walking Skeleton mode fires
|
||||
|
||||
@@ -115,6 +115,9 @@ GSD stores project settings in `.planning/config.json`. Created during `/gsd-new
|
||||
},
|
||||
"project_code": null,
|
||||
"agent_skills": {},
|
||||
"agent_skills_security": {
|
||||
"trusted_global_roots": []
|
||||
},
|
||||
"response_language": null,
|
||||
"features": {
|
||||
"thinking_partner": false,
|
||||
@@ -245,7 +248,7 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin
|
||||
| `workflow.max_discuss_passes` | number | `3` | Maximum number of question rounds in discuss-phase before the workflow stops asking. Useful in headless/auto mode to prevent infinite discussion loops. |
|
||||
| `workflow.skip_discuss` | boolean | `false` | When `true`, `/gsd-autonomous` bypasses the discuss-phase entirely, writing minimal CONTEXT.md from the ROADMAP phase goal. Useful for projects where developer preferences are fully captured in PROJECT.md/REQUIREMENTS.md. Added in v1.28 |
|
||||
| `workflow.text_mode` | boolean | `false` | Replaces AskUserQuestion TUI menus with plain-text numbered lists. Required for Claude Code remote sessions (`/rc` mode) where TUI menus don't render. Can also be set per-session with `--text` flag on discuss-phase. Added in v1.28 |
|
||||
| `workflow.use_worktrees` | boolean | `true` | When `false`, disables git worktree isolation for parallel execution. Users who prefer sequential execution or whose environment does not support worktrees can disable this. Added in v1.31 |
|
||||
| `workflow.use_worktrees` | boolean | `true` | When `false`, disables git worktree isolation for parallel execution. Users who prefer sequential execution or whose environment does not support worktrees can disable this. Added in v1.31. **Branch-divergence note:** when your branch is ahead of `origin/HEAD`, GSD auto-degrades to sequential and prints a warning. Set `worktree.baseRef:"head"` in `.claude/settings.local.json` (run `node gsd-tools.cjs worktree set-baseref`) to restore parallel execution. See [Fix the worktree base-mismatch (exit 42) error](how-to/fix-worktree-base-mismatch.md). |
|
||||
| `workflow.worktree_skip_hooks` | boolean | `false` | When `true`, executor agents in worktree mode pass `--no-verify` (skipping pre-commit hooks) and post-wave hook validation runs against the merged result instead. Opt-in escape hatch for projects whose hooks cannot run in agent worktrees. Default `false` runs hooks on every commit (#2924). |
|
||||
| `workflow.code_review` | boolean | `true` | Enable `/gsd-code-review` and `/gsd-code-review --fix` commands. When `false`, the commands exit with a configuration gate message. Added in v1.34 |
|
||||
| `workflow.code_review_depth` | string | `standard` | Default review depth for `/gsd-code-review`: `quick` (pattern-matching only), `standard` (per-file analysis), or `deep` (cross-file with import graphs). Can be overridden per-run with `--depth=`. Added in v1.34 |
|
||||
@@ -395,6 +398,7 @@ Inject custom skill files into GSD subagent prompts. Skills are read by agents a
|
||||
| Setting | Type | Default | Description |
|
||||
|---------|------|---------|-------------|
|
||||
| `agent_skills` | object | `{}` | Map of agent types to skill directory paths |
|
||||
| `agent_skills_security.trusted_global_roots` | array of strings | `[]` | Opt-in allowlist of additional trusted directories for `global:` skills. See [Trusted global skill roots](#trusted-global-skill-roots-agent_skills_securitytrusted_global_roots) |
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -454,6 +458,50 @@ gsd-tools query config-set agent_skills.gsd-executor '["skills/my-skill"]'
|
||||
|
||||
---
|
||||
|
||||
## Trusted Global Skill Roots (`agent_skills_security.trusted_global_roots`)
|
||||
|
||||
Widen the symlink-safety boundary for `global:` skills by declaring additional trusted root directories.
|
||||
|
||||
### Purpose
|
||||
|
||||
By default, a `global:<name>` skill whose `SKILL.md` real path (after resolving symlinks) escapes the runtime's global skills directory (e.g. `~/.claude/skills/`) is rejected as a symlink-escape. `agent_skills_security.trusted_global_roots` lets you declare additional trusted root directories so symlinked skills whose real target lives under one of them are accepted.
|
||||
|
||||
Common use case: a single source-of-truth skills directory elsewhere on disk (e.g. `~/shared/skills`) symlinked into `~/.claude/skills/` so `git pull` or `rsync` keeps a team's skills up to date without maintaining copies.
|
||||
|
||||
### Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_skills_security": {
|
||||
"trusted_global_roots": [
|
||||
"~/shared/skills",
|
||||
"/opt/shared-skills"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
- **Default `[]`** — behavior is byte-identical to omitting the option entirely: only skills whose real `SKILL.md` path resolves inside the default global skills directory are accepted.
|
||||
- **Absolute or tilde-prefixed paths only.** Each entry must be an absolute path (`/opt/shared-skills`) or a `~`/`~/`-prefixed path (tilde expands to your home directory). Project-relative paths are rejected, so an untrusted repo's `.planning/config.json` cannot point trust at a directory inside itself.
|
||||
- **`realpathSync` at load time.** Each declared root is resolved with `realpathSync` on every run, so trust follows the real target and cannot silently drift if a root itself later becomes a symlink. Non-existent or unreadable roots are dropped without error.
|
||||
- **Dangerously broad roots are refused.** The filesystem root (`/`), drive or UNC roots, and your home directory itself cannot be declared as trusted roots — these would make the allowlist meaningless.
|
||||
- **Acceptance rule.** A skill is accepted if and only if its real `SKILL.md` path lies inside the default global skills directory OR inside one of the resolved trusted roots. Skills resolving outside all of these are still rejected.
|
||||
- **Audit note.** When a skill is accepted via a trusted root rather than the default global skills directory, a `[agent-skills] NOTE:` line is written to stderr so the widened boundary remains visible.
|
||||
|
||||
> **Security note:** `trusted_global_roots` is read from the project-local `.planning/config.json`. Only add roots you control and trust. Declaring a broad shared directory widens which symlinked global skills will load for every agent in this project.
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
gsd config-set agent_skills_security.trusted_global_roots '["~/shared/skills"]'
|
||||
```
|
||||
|
||||
Setting the parent object (`agent_skills_security`) directly is not supported; use the dot-notation leaf form shown above.
|
||||
|
||||
---
|
||||
|
||||
## Feature Flags
|
||||
|
||||
Toggle optional capabilities via the `features.*` config namespace. Feature flags default to `false` (disabled) — enabling a flag opts into new behavior without affecting existing workflows.
|
||||
|
||||
@@ -925,7 +925,7 @@ continues. Drift detection cannot fail verification.
|
||||
| `granularity` | enum | `standard` | `coarse`, `standard`, or `fine` |
|
||||
| `model_profile` | enum | `balanced` | `quality`, `balanced`, `budget`, or `inherit` |
|
||||
| `models.<phase_type>` | enum | (none) | Per-phase-type tier override (`planning`, `discuss`, `research`, `execution`, `verification`, `completion`). Values: `opus`, `sonnet`, `haiku`, `inherit`. Coarse phase-level tuning that wins over `model_profile` but loses to per-agent `model_overrides`. See [CONFIGURATION.md](CONFIGURATION.md#per-phase-type-models-models--added-in-v140). Added in v1.40 |
|
||||
| `granularities.<phase_type>` | enum | (none) | Per-phase-type granularity override (`planning`, `discuss`, `research`, `execution`, `verification`, `completion`). Values: `coarse`, `standard`, `fine`. Mirrors `models.<phase_type>` for granularity. See [CONFIGURATION.md](CONFIGURATION.md#core-settings). Added in v1.43 ([#68](https://github.com/open-gsd/gsd-core/issues/68)) |
|
||||
| `granularities.<phase_type>` | enum | (none) | Per-phase-type granularity override (`planning`, `discuss`, `research`, `execution`, `verification`, `completion`). Values: `coarse`, `standard`, `fine`. Mirrors `models.<phase_type>` for granularity. See [CONFIGURATION.md](CONFIGURATION.md#core-settings). Added in v1.43 ([#68](https://github.com/open-gsd/gsd-core/issues/68)). `/gsd:plan-phase --granularity <coarse\|standard\|fine>` overrides all config-based granularity for a single invocation (takes precedence over `granularities.planning`, top-level `granularity`, and `planning.granularity`). ([#703](https://github.com/open-gsd/gsd-core/issues/703)) |
|
||||
| `dynamic_routing.enabled` | boolean | `false` | Master switch for failure-tier escalation. When `true`, agents resolve to `tier_models[default_tier]` and escalate one tier on orchestrator-detected soft failure. Capped by `max_escalations`. See [CONFIGURATION.md](CONFIGURATION.md#dynamic-routing-with-failure-tier-escalation-dynamic_routing--added-in-v140). Added in v1.40 |
|
||||
| `workflow.research` | boolean | `true` | Domain research before planning |
|
||||
| `workflow.plan_check` | boolean | `true` | Plan verification loop |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"generated": "2026-06-06",
|
||||
"generated": "2026-06-07",
|
||||
"families": {
|
||||
"agents": [
|
||||
"gsd-advisor-researcher",
|
||||
@@ -344,12 +344,15 @@
|
||||
"update-context.cjs",
|
||||
"validate-command-router.cjs",
|
||||
"validate.cjs",
|
||||
"verification-command-router.cjs",
|
||||
"verification.cjs",
|
||||
"verify-command-router.cjs",
|
||||
"verify.cjs",
|
||||
"workstream-inventory-builder.cjs",
|
||||
"workstream-inventory.cjs",
|
||||
"workstream-name-policy.cjs",
|
||||
"workstream.cjs",
|
||||
"worktree-base-ref.cjs",
|
||||
"worktree-safety.cjs"
|
||||
],
|
||||
"hooks": [
|
||||
|
||||
@@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
|
||||
|
||||
---
|
||||
|
||||
## CLI Modules (86 shipped)
|
||||
## CLI Modules (89 shipped)
|
||||
|
||||
Full listing: `gsd-core/bin/lib/*.cjs`.
|
||||
|
||||
@@ -455,12 +455,15 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
|
||||
| `update-context.cjs` | Pure install-context resolver for `/gsd:update` — runtime/scope/config-dir/version detection (LOCAL/GLOBAL/UNKNOWN) ported from update.md bash; backs `gsd-tools update-context` (#498) |
|
||||
| `validate-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools validate` |
|
||||
| `validate.cjs` | Pure phase variant normalization helpers (`phaseVariants`, `buildRoadmapPhaseVariants`, `buildNotStartedPhaseVariants`) used by `verify.cjs` for W006/W007 checks; no I/O, no async |
|
||||
| `verification-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools verification` |
|
||||
| `verification.cjs` | Verification-status routing — consolidates pass/gaps_found/human_needed status from phase verifier-emitted VERIFICATION.md frontmatter (#651) |
|
||||
| `verify-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools verify` |
|
||||
| `verify.cjs` | Plan structure, phase completeness, reference, commit validation |
|
||||
| `workstream-inventory-builder.cjs` | Pure workstream inventory projection builder |
|
||||
| `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.cjs` |
|
||||
| `workstream-name-policy.cjs` | Canonical workstream name validation (`isValidActiveWorkstreamName`, `hasInvalidPathSegment`, `validateWorkstreamName`) and slug normalization (`toWorkstreamSlug`) |
|
||||
| `workstream.cjs` | Workstream CRUD, migration, session-scoped active pointer |
|
||||
| `worktree-base-ref.cjs` | Worktree base-ref drift detection and degrade decision (`evaluateWorktreeBaseDegrade`) plus no-clobber `worktree.baseRef` settings management for the `base-check`/`set-baseref` subcommands (#683) |
|
||||
| `worktree-safety.cjs` | Worktree-root resolution and non-destructive prune policy decisions; owns W017 health-check logic |
|
||||
|
||||
[`docs/CLI-TOOLS.md`](CLI-TOOLS.md) may describe a subset of these modules; when it disagrees with the filesystem, this table and the directory listing are authoritative.
|
||||
|
||||
@@ -33,6 +33,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
|
||||
- [Migrate from GSD 2](how-to/migrate-from-gsd-2.md) — upgrade an existing GSD 2 project to GSD Core
|
||||
- [Update GSD](how-to/update-gsd.md) — re-run the installer to pick up the latest release
|
||||
- [Clean up get-shit-done-cc](cleanup-get-shit-done-cc.md) — remove leftover old-package artifacts that cause a spurious `⬆ /gsd:update` indicator after migrating to `@opengsd/gsd-core`
|
||||
- [Fix the worktree base-mismatch (exit 42) error](how-to/fix-worktree-base-mismatch.md) — resolve the branch-divergence condition that halts parallel phase execution
|
||||
- [Recover and troubleshoot](how-to/recover-and-troubleshoot.md) — fix common problems, rebuild context, and uninstall
|
||||
|
||||
---
|
||||
|
||||
143
docs/how-to/fix-worktree-base-mismatch.md
Normal file
143
docs/how-to/fix-worktree-base-mismatch.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# How to fix the worktree base-mismatch (exit 42) error
|
||||
|
||||
**Goal:** Understand why `/gsd-execute-phase` halts with `FATAL: worktree base mismatch` / exit 42 when your branch is ahead of the default branch, and choose the right fix to restore normal — or parallel — execution.
|
||||
|
||||
**Prerequisites:** GSD Core is installed and you have an active project. You have run `/gsd-execute-phase` and either seen the exit-42 error or the one-line `⚠ Worktree base mismatch` warning.
|
||||
|
||||
---
|
||||
|
||||
## What you will see
|
||||
|
||||
When you run `/gsd-execute-phase` on a branch that is ahead of the repository's default branch (for example, an unmerged milestone branch, a long-lived feature branch, or a branch with commits not yet in `origin/HEAD`), you may see one of two messages:
|
||||
|
||||
**Automatic-degrade warning (phase still completes):**
|
||||
|
||||
```
|
||||
⚠ Worktree base mismatch: HEAD (abc12345) differs from origin/HEAD (def67890).
|
||||
Running this phase sequentially on the main working tree.
|
||||
To keep parallel worktrees, set worktree.baseRef:"head" in
|
||||
.claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.
|
||||
```
|
||||
|
||||
The phase runs to completion sequentially; nothing is blocked. This is the runtime mitigation.
|
||||
|
||||
**Exit-42 halt (older installs or misconfigured environments):**
|
||||
|
||||
```
|
||||
FATAL: worktree base mismatch
|
||||
```
|
||||
|
||||
All worktree-isolated executors halt immediately. Zero progress is made.
|
||||
|
||||
---
|
||||
|
||||
## Why this happens
|
||||
|
||||
Claude Code's `isolation="worktree"` forks executor worktrees from the repository's default branch (`origin/HEAD`), not from your current `HEAD`. When your branch contains commits that `origin/HEAD` does not have — plan files, new source files, anything added since the branch diverged — those files are absent inside each worktree. GSD's `worktree-branch-check` safety guard correctly refuses to act on a worktree that does not match the orchestrator's state, and exits with code 42.
|
||||
|
||||
This is the guard working as designed: it prevents silent data loss or phantom edits in the wrong tree. The error is a branch-state condition, not an OS-specific or hardware issue.
|
||||
|
||||
---
|
||||
|
||||
## Option 1 — Do nothing (you are already unblocked)
|
||||
|
||||
If you saw the `⚠ Worktree base mismatch` warning rather than an exit-42 halt, GSD has already automatically degraded to sequential execution on the main working tree for this run. The phase will complete. No action is required.
|
||||
|
||||
Use this option when:
|
||||
|
||||
- You are on a diverged branch temporarily
|
||||
- You do not care about parallel execution for this phase
|
||||
- You want to merge back to the default branch soon
|
||||
|
||||
---
|
||||
|
||||
## Option 2 — Permanent fix: set `worktree.baseRef: "head"` (recommended)
|
||||
|
||||
This option restores parallel worktree execution on diverged branches. It tells Claude Code to fork executor worktrees from your current `HEAD` instead of `origin/HEAD`, so the plan files and branch-only commits are present in every worktree.
|
||||
|
||||
Run the convenience command from your project root:
|
||||
|
||||
```bash
|
||||
node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" worktree set-baseref
|
||||
```
|
||||
|
||||
This writes `worktree.baseRef: "head"` into `.claude/settings.local.json` in your project root. It is no-clobber: if you already have an explicit `baseRef` set to something else, it leaves your value in place and tells you.
|
||||
|
||||
To verify the result:
|
||||
|
||||
```bash
|
||||
node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" worktree base-check
|
||||
```
|
||||
|
||||
The output is JSON. When `shouldDegrade` is `false` and `reason` is `"baseref-head"`, parallel worktrees will work on any branch.
|
||||
|
||||
Alternatively, set the value by hand in `.claude/settings.local.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"worktree": {
|
||||
"baseRef": "head"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Fresh installs and upgrades of GSD Core both set `worktree.baseRef:"head"` automatically in `.claude/settings.local.json` (no-clobber) when `workflow.use_worktrees` is enabled (the default). You can also apply or re-apply it manually at any time with `gsd-tools worktree set-baseref` — for example, if you toggled worktrees on after the initial install.
|
||||
|
||||
Use this option when:
|
||||
|
||||
- You regularly work on long-lived or milestone branches
|
||||
- You want parallel phase execution (faster, lower context-window pressure)
|
||||
- You are a solo developer or team working on a feature branch for an extended period
|
||||
|
||||
---
|
||||
|
||||
## Option 3 — Fallback: disable worktrees entirely
|
||||
|
||||
If worktrees are causing persistent problems beyond the base-mismatch (for example, your environment does not support them), disable them permanently for this project:
|
||||
|
||||
Add or edit `.planning/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"workflow": {
|
||||
"use_worktrees": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All executor agents will then run sequentially on the main working tree for every phase. This is equivalent to what the automatic degrade does, but permanent.
|
||||
|
||||
Use this option when:
|
||||
|
||||
- Worktrees are consistently problematic in your environment
|
||||
- You prefer sequential execution for auditability or tooling reasons
|
||||
- You are on a platform or CI setup that does not support git worktrees
|
||||
|
||||
See also: [`workflow.use_worktrees`](../CONFIGURATION.md#workflow-toggles) in the configuration reference.
|
||||
|
||||
---
|
||||
|
||||
## The exit-42 backstop
|
||||
|
||||
The `worktree-branch-check` guard (exit 42) remains active in all execution modes as a safety backstop. It fires only when an executor worktree's branch does not match the expected orchestrator state — a condition that should not arise once you have applied one of the options above. If you continue to see exit 42 after setting `worktree.baseRef: "head"`, run `/gsd-forensics` to investigate.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Situation | Recommended action |
|
||||
|-----------|-------------------|
|
||||
| Saw the warning, phase completed | Nothing — degrade handled it automatically |
|
||||
| Regularly on diverged branches, want parallel execution | `worktree set-baseref` (Option 2) |
|
||||
| Worktrees consistently problematic | Set `workflow.use_worktrees: false` (Option 3) |
|
||||
| Still seeing exit 42 after fixes | Run `/gsd-forensics "exit 42 after fix"` |
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [Recover and troubleshoot](recover-and-troubleshoot.md)
|
||||
- [Debug a failed execution](debug-a-failed-execution.md)
|
||||
- [Configuration reference — workflow toggles](../CONFIGURATION.md#workflow-toggles)
|
||||
- [CLI Tools reference — worktree commands](../CLI-TOOLS.md#worktree-commands)
|
||||
- [docs index](../README.md)
|
||||
@@ -153,6 +153,18 @@ Check whether the plan is too ambitious. Plans should have two or three tasks at
|
||||
|
||||
For systematic diagnosis of what went wrong, see [Debug a failed execution](debug-a-failed-execution.md).
|
||||
|
||||
### If you see "FATAL: worktree base mismatch" or the exit-42 warning
|
||||
|
||||
This happens when your current branch is ahead of the repository's default branch (for example, an unmerged milestone or feature branch). Claude Code forks executor worktrees from `origin/HEAD`, not your `HEAD`, so plan files that exist only on your branch are absent inside the worktree.
|
||||
|
||||
Since the fix landed, GSD automatically degrades to sequential execution on the main working tree and prints a one-line warning — the phase will complete without any action from you. To restore parallel execution permanently, run:
|
||||
|
||||
```bash
|
||||
node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" worktree set-baseref
|
||||
```
|
||||
|
||||
For a full explanation and all available options, see [Fix the worktree base-mismatch (exit 42) error](fix-worktree-base-mismatch.md).
|
||||
|
||||
### If parallel execution causes build lock errors or pre-commit hook failures
|
||||
|
||||
This is caused by multiple agents triggering build tools simultaneously. GSD handles this automatically since v1.26. If you are on an older version, or still seeing contention, disable parallel execution:
|
||||
@@ -312,12 +324,14 @@ Also audit which MCP servers are enabled. Every enabled MCP server injects its t
|
||||
| Update broke local changes | `/gsd-update --reapply` |
|
||||
| Want session summary | `/gsd-pause-work --report` |
|
||||
| Parallel execution build errors | Update GSD or set `parallelization.enabled: false` |
|
||||
| Worktree base mismatch / exit 42 | Auto-degraded to sequential (no action needed); run `worktree set-baseref` to restore parallelism |
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [Debug a failed execution](debug-a-failed-execution.md)
|
||||
- [Fix the worktree base-mismatch (exit 42) error](fix-worktree-base-mismatch.md)
|
||||
- [Install on your runtime](install-on-your-runtime.md)
|
||||
- [Commands](../COMMANDS.md)
|
||||
- [docs index](../README.md)
|
||||
|
||||
@@ -83,6 +83,7 @@ export default tseslint.config(
|
||||
'gsd-core/bin/lib/intel.cjs',
|
||||
'gsd-core/bin/lib/installer-migrations.cjs',
|
||||
'gsd-core/bin/lib/worktree-safety.cjs',
|
||||
'gsd-core/bin/lib/worktree-base-ref.cjs',
|
||||
'gsd-core/bin/lib/planning-workspace.cjs',
|
||||
'gsd-core/bin/lib/runtime-artifact-layout.cjs',
|
||||
'gsd-core/bin/lib/command-routing-hub.cjs',
|
||||
@@ -95,6 +96,8 @@ export default tseslint.config(
|
||||
'gsd-core/bin/lib/config-types.cjs',
|
||||
'gsd-core/bin/lib/phases-command-router.cjs',
|
||||
'gsd-core/bin/lib/verify-command-router.cjs',
|
||||
'gsd-core/bin/lib/verification.cjs',
|
||||
'gsd-core/bin/lib/verification-command-router.cjs',
|
||||
'gsd-core/bin/lib/init-command-router.cjs',
|
||||
'gsd-core/bin/lib/agent-command-router.cjs',
|
||||
'gsd-core/bin/lib/task-command-router.cjs',
|
||||
|
||||
@@ -198,6 +198,8 @@ const learnings = require('./lib/learnings.cjs');
|
||||
const gapChecker = require('./lib/gap-checker.cjs');
|
||||
const { routeStateCommand } = require('./lib/state-command-router.cjs');
|
||||
const { routeVerifyCommand } = require('./lib/verify-command-router.cjs');
|
||||
const { routeVerificationCommand } = require('./lib/verification-command-router.cjs');
|
||||
const verification = require('./lib/verification.cjs');
|
||||
const { routeInitCommand } = require('./lib/init-command-router.cjs');
|
||||
const { routePhaseCommand } = require('./lib/phase-command-router.cjs');
|
||||
const { routePhasesCommand } = require('./lib/phases-command-router.cjs');
|
||||
@@ -547,7 +549,22 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
}
|
||||
|
||||
case 'resolve-granularity': {
|
||||
commands.cmdResolveGranularity(cwd, args[1], raw);
|
||||
// Parse optional --granularity <val> flag (space form only); positional is phase-type.
|
||||
// The =form (--granularity=<val>) is intentionally not supported: parseNamedArgs and
|
||||
// the /gsd:plan-phase + init plan-phase paths accept only the space form, so supporting
|
||||
// = here alone would create an inconsistency (#703).
|
||||
const granArgs = args.slice(1);
|
||||
let granOverride;
|
||||
const granPositionals = [];
|
||||
for (let i = 0; i < granArgs.length; i++) {
|
||||
const a = granArgs[i];
|
||||
if (a === '--granularity' && granArgs[i + 1] !== undefined && !granArgs[i + 1].startsWith('--')) {
|
||||
if (granOverride === undefined) { granOverride = granArgs[++i]; } else { ++i; }
|
||||
} else {
|
||||
granPositionals.push(a);
|
||||
}
|
||||
}
|
||||
commands.cmdResolveGranularity(cwd, granPositionals[0], raw, granOverride);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -761,6 +778,26 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
break;
|
||||
}
|
||||
|
||||
// ─── Verification Status ───────────────────────────────────────────────
|
||||
//
|
||||
// verification status <phaseDir>
|
||||
// Read the first *-VERIFICATION.md in phaseDir and return
|
||||
// { status, next_action, next_command } routing result.
|
||||
//
|
||||
// Note: `verification` (reads verifier-emitted status) is distinct from
|
||||
// `verify` (runs verification checks like plan-structure/artifacts).
|
||||
|
||||
case 'verification': {
|
||||
routeVerificationCommand({
|
||||
verification,
|
||||
args,
|
||||
cwd,
|
||||
raw,
|
||||
error,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'generate-slug': {
|
||||
// Phase 6 (#3575): dispatch via SDK executeForCjs when available.
|
||||
// SDK handler: generateSlug in sdk/src/query/utils.ts.
|
||||
@@ -1232,8 +1269,12 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
worktreeSafety.cmdWorktreeCleanupWave(cwd, args.slice(2));
|
||||
} else if (subcommand === 'reap-orphans') {
|
||||
worktreeSafety.cmdWorktreeReapOrphans(cwd);
|
||||
} else if (subcommand === 'base-check') {
|
||||
require('./lib/worktree-base-ref.cjs').cmdWorktreeBaseCheck(cwd, args.slice(2));
|
||||
} else if (subcommand === 'set-baseref') {
|
||||
require('./lib/worktree-base-ref.cjs').cmdWorktreeSetBaseRef(cwd, args.slice(2));
|
||||
} else {
|
||||
error('Unknown worktree subcommand. Available: cleanup-wave, reap-orphans', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
||||
error('Unknown worktree subcommand. Available: cleanup-wave, reap-orphans, base-check, set-baseref', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -106,7 +106,8 @@
|
||||
"model_policy.budget",
|
||||
"model_policy.high",
|
||||
"model_policy.medium",
|
||||
"model_policy.low"
|
||||
"model_policy.low",
|
||||
"agent_skills_security.trusted_global_roots"
|
||||
],
|
||||
"runtimeStateKeys": [
|
||||
"workflow._auto_chain_active"
|
||||
|
||||
@@ -34,7 +34,7 @@ Configuration options for `.planning/` directory behavior.
|
||||
| `git.phase_branch_template` | `"gsd/phase-{phase}-{slug}"` | Branch template for phase strategy |
|
||||
| `git.milestone_branch_template` | `"gsd/{milestone}-{slug}"` | Branch template for milestone strategy |
|
||||
| `git.quick_branch_template` | `null` | Optional branch template for quick-task runs |
|
||||
| `workflow.use_worktrees` | `true` | Whether executor agents run in isolated git worktrees. Set to `false` to disable worktrees — agents execute sequentially on the main working tree instead. Recommended for solo developers or when worktree merges cause issues. |
|
||||
| `workflow.use_worktrees` | `true` | Whether executor agents run in isolated git worktrees. Set to `false` to disable worktrees — agents execute sequentially on the main working tree instead. Recommended for solo developers or when worktree merges cause issues. Note: if your branch is ahead of `origin/HEAD` (a diverged milestone or feature branch), GSD auto-degrades to sequential and prints a warning; set `worktree.baseRef:"head"` in `.claude/settings.local.json` to restore parallel execution. See the branch-divergence note below. |
|
||||
| `workflow.subagent_timeout` | `300000` | Timeout in milliseconds for parallel subagent tasks (e.g. codebase mapping). Increase for large codebases or slower models. Default: 300000 (5 minutes). |
|
||||
| `workflow.inline_plan_threshold` | `2` | Plans with this many tasks or fewer execute inline (Pattern C) instead of spawning a subagent. Avoids ~14K token spawn overhead for small plans. Set to `0` to always spawn subagents. |
|
||||
| `manager.flags.discuss` | `""` | Flags passed to `/gsd:discuss-phase` when dispatched from manager (e.g. `"--auto --analyze"`) |
|
||||
@@ -385,6 +385,8 @@ Several config fields affect each other or trigger special behavior:
|
||||
|
||||
8. **`sub_repos` auto-sync** -- On every config load, GSD scans for child directories with `.git` and updates the `sub_repos` array if the filesystem has changed. Legacy `multiRepo: true` is automatically migrated to a detected `sub_repos` array.
|
||||
|
||||
9. **`workflow.use_worktrees` and branch divergence** -- When `use_worktrees` is `true` (default), executor worktrees are forked from `origin/HEAD` by the Claude Code harness. If your current branch has commits that `origin/HEAD` does not (for example an unmerged milestone or feature branch), GSD automatically degrades to sequential execution for that run and prints a one-line `⚠ Worktree base mismatch` warning. To restore parallel execution permanently, set `worktree.baseRef:"head"` in `.claude/settings.local.json` (run `node gsd-tools.cjs worktree set-baseref`). This makes the harness fork worktrees from the live HEAD instead of `origin/HEAD`. Both fresh installs and upgrades of GSD Core set this automatically (no-clobber) when `use_worktrees` is enabled; you can also run the command manually at any time. Setting `workflow.use_worktrees: false` is the alternative if worktrees are not needed at all.
|
||||
|
||||
---
|
||||
|
||||
## Example Configurations
|
||||
|
||||
@@ -92,6 +92,16 @@ if [ "$RUNTIME" = "codex" ] && [ "$USE_WORKTREES" != "false" ]; then
|
||||
fi
|
||||
# Sweep orphaned locked worktrees from prior crashed sessions before spawning executors (#3707).
|
||||
[ "$USE_WORKTREES" != "false" ] && gsd_run query worktree.reap-orphans 2>/dev/null || true
|
||||
# Auto-degrade to sequential if HEAD has diverged from the worktree fork base (#683).
|
||||
# Only applies to Claude Code (isolation="worktree" is Claude-Code-specific).
|
||||
if [ "$RUNTIME" = "claude" ] && [ "$USE_WORKTREES" != "false" ]; then
|
||||
_SHOULD_DEGRADE=$(gsd_run query worktree.base-check --pick shouldDegrade 2>/dev/null || true)
|
||||
if [ "$_SHOULD_DEGRADE" = "true" ]; then
|
||||
_DEGRADE_MSG=$(gsd_run query worktree.base-check --pick message 2>/dev/null || true)
|
||||
[ -n "$_DEGRADE_MSG" ] && printf '%s\n' "$_DEGRADE_MSG" >&2
|
||||
USE_WORKTREES=false
|
||||
fi
|
||||
fi
|
||||
```
|
||||
Codex maps subagents to `spawn_agent`, which has no direct Codex mapping for Claude Code's `isolation="worktree"` parameter. Failing closed prevents main-checkout edits while the workflow believes agents are isolated.
|
||||
|
||||
@@ -111,6 +121,8 @@ fi
|
||||
|
||||
When `USE_WORKTREES` (project-level) is `false`, all executor agents run without `isolation="worktree"` — they execute sequentially on the main working tree instead of in parallel worktrees. The per-plan decision below has no effect when worktrees are project-disabled.
|
||||
|
||||
`USE_WORKTREES` is also automatically set to `false` for the duration of a run when `worktree base-check` detects that the orchestrator HEAD has diverged from the worktree fork base (the #683 condition — e.g. an unmerged milestone or feature branch). This check runs only when `RUNTIME=claude` because `isolation="worktree"` is a Claude Code-specific feature; other runtimes do not use it. The auto-degrade prints a one-line warning to stderr and falls through to the sequential path so executors do not hit the exit-42 worktree-branch-check halt. To restore parallel worktree execution, set `worktree.baseRef:"head"` in `.claude/settings.local.json` (or run `gsd-tools worktree set-baseref`) — this makes the fork base track the live HEAD instead of a fixed remote ref. The `worktree-branch-check` exit-42 guard inside each executor remains in place as a backstop.
|
||||
|
||||
Read context window size for adaptive prompt enrichment:
|
||||
|
||||
```bash
|
||||
@@ -1413,16 +1425,15 @@ ${VERIFIER_SKILLS}",
|
||||
|
||||
> **ORCHESTRATOR RULE — CODEX RUNTIME**: After calling Agent() above, stop working on this task immediately. Do not read more files, edit code, or run tests related to this task while the subagent is active. Wait for the subagent to return its result. This prevents duplicate work, conflicting edits, and wasted context. Only resume when the subagent result is available.
|
||||
|
||||
Read status:
|
||||
Read status via the canonical query (scoped to frontmatter, covers missing/unknown cases):
|
||||
```bash
|
||||
grep "^status:" "$PHASE_DIR"/*-VERIFICATION.md | cut -d: -f2 | tr -d ' '
|
||||
VERIFICATION=$(gsd_run query verification.status "$PHASE_DIR" 2>/dev/null)
|
||||
STATUS=$(printf '%s' "$VERIFICATION" | jq -r '.status' 2>/dev/null || echo "")
|
||||
NEXT_ACTION=$(printf '%s' "$VERIFICATION" | jq -r '.next_action' 2>/dev/null || echo "")
|
||||
NEXT_COMMAND=$(printf '%s' "$VERIFICATION" | jq -r '.next_command' 2>/dev/null || echo "")
|
||||
```
|
||||
|
||||
| Status | Action |
|
||||
|--------|--------|
|
||||
| `passed` | → update_roadmap |
|
||||
| `human_needed` | Persist and present human testing items; keep phase pending until verification reruns as `passed` |
|
||||
| `gaps_found` | Present gap summary, offer `/gsd:plan-phase {phase} --gaps ${GSD_WS}` |
|
||||
Route on `$STATUS`: if `passed`, proceed to update_roadmap. Otherwise keep the phase pending — present `$NEXT_ACTION` to the user and, when `$NEXT_COMMAND` is non-empty, show it as the next command to run. The query covers all cases including missing files (`missing`) and unexpected values (`unknown`), so no per-status arm needs to be listed here.
|
||||
|
||||
**If human_needed:**
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@ Load all context in one call (paths only to minimize orchestrator context):
|
||||
|
||||
```bash
|
||||
_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi
|
||||
INIT=$(gsd_run query init.plan-phase "$PHASE")
|
||||
GRAN_PARAM=""; if [[ "$ARGUMENTS" =~ (^|[[:space:]])--granularity[[:space:]]+([^[:space:]-][^[:space:]]*) ]]; then GRAN_PARAM="--granularity ${BASH_REMATCH[2]}"; fi
|
||||
INIT=$(gsd_run query init.plan-phase "$PHASE" $GRAN_PARAM)
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
AGENT_SKILLS_RESEARCHER=$(gsd_run query agent-skills gsd-phase-researcher)
|
||||
AGENT_SKILLS_PLANNER=$(gsd_run query agent-skills gsd-planner)
|
||||
@@ -46,7 +47,7 @@ When `TDD_MODE` is `true`, the planner agent is instructed to apply `type: tdd`
|
||||
|
||||
When `CONTEXT_WINDOW >= 500000`, the planner prompt includes the 3 most recent prior phase CONTEXT.md and SUMMARY.md files PLUS any phases explicitly listed in the current phase's `Depends on:` field in ROADMAP.md. Explicit dependencies always load regardless of recency (e.g., Phase 7 declaring `Depends on: Phase 2` always sees Phase 2's context). Bounded recency keeps the planner's context budget focused on recent work.
|
||||
|
||||
Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_enabled`, `plan_checker_enabled`, `nyquist_validation_enabled`, `commit_docs`, `text_mode`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_reviews`, `has_plans`, `plan_count`, `phase_status` (#3569), `planning_exists`, `roadmap_exists`, `phase_req_ids`, `response_language`.
|
||||
Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_enabled`, `plan_checker_enabled`, `nyquist_validation_enabled`, `commit_docs`, `text_mode`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_reviews`, `has_plans`, `plan_count`, `phase_status` (#3569), `planning_exists`, `roadmap_exists`, `phase_req_ids`, `response_language`, `granularity`.
|
||||
|
||||
**If `response_language` is set:** Include `response_language: {value}` in all spawned subagent prompts so any user-facing output stays in the configured language.
|
||||
|
||||
@@ -99,7 +100,7 @@ The gate fires only on `Complete`. `Executed` and `Needs Review` are not gated
|
||||
|
||||
## 2. Parse and Normalize Arguments
|
||||
|
||||
Extract from $ARGUMENTS: phase number (integer or decimal like `2.1`), flags (`--research`, `--skip-research`, `--research-phase <N>`, `--gaps`, `--skip-verify`, `--skip-ui`, `--prd <filepath>`, `--ingest <path-or-glob>`, `--ingest-format <auto|nygard|madr|narrative>`, `--reviews`, `--text`, `--bounce`, `--skip-bounce`, `--chunked`, `--mvp`, `--tdd`, `--force` (override closed-phase gate, see §1.5)).
|
||||
Extract from $ARGUMENTS: phase number (integer or decimal like `2.1`), flags (`--research`, `--skip-research`, `--research-phase <N>`, `--gaps`, `--skip-verify`, `--skip-ui`, `--prd <filepath>`, `--ingest <path-or-glob>`, `--ingest-format <auto|nygard|madr|narrative>`, `--reviews`, `--text`, `--bounce`, `--skip-bounce`, `--chunked`, `--mvp`, `--tdd`, `--granularity <coarse|standard|fine>`, `--force` (override closed-phase gate, see §1.5)).
|
||||
|
||||
**`--research-phase <N>` — research-only mode (#3042 + #3044).** When this flag is present, parse `<N>` as the phase number (overrides any positional phase argument), set `RESEARCH_ONLY=true`, and treat the rest of this workflow as a research-dispatch only — the planner spawn (step 8), plan-checker, verification, gaps, bounce, and post-planning-gaps blocks all skip on `RESEARCH_ONLY`. Use this for cross-phase research, doc review before committing to a planning approach, and correction-without-replanning loops. Replaces the deleted `/gsd-research-phase` command.
|
||||
|
||||
@@ -120,6 +121,8 @@ if $RESEARCH_ONLY && [[ "$ARGUMENTS" =~ (^|[[:space:]])--view([[:space:]]|$) ]];
|
||||
fi
|
||||
```
|
||||
|
||||
**`--granularity <coarse|standard|fine>` — CLI override (#703).** When present, this value is the resolved granularity passed to the planner — it wins over any per-phase `granularities.<type>` config, top-level `granularity` config, or project defaults. The init JSON always includes a `granularity` field reflecting the resolved value; read it from there. Invalid values (anything other than `coarse`, `standard`, `fine`) cause an error at the CLI boundary.
|
||||
|
||||
Set `TEXT_MODE=true` if `--text` is present in $ARGUMENTS OR `text_mode` from init JSON is `true`. When `TEXT_MODE` is active, replace every `AskUserQuestion` call with a plain-text numbered list and ask the user to type their choice number. This is required for Claude Code remote sessions (`/rc` mode) where TUI menus don't work through the Claude App.
|
||||
|
||||
**MVP_MODE resolution.** Resolve `MVP_MODE` once via the centralized `phase.mvp-mode` query verb. Precedence (first hit wins): CLI flag → ROADMAP.md `**Mode:** mvp` → `workflow.mvp_mode` config → false. The verb is the single source of truth — do not re-implement the chain.
|
||||
@@ -935,6 +938,7 @@ Each TDD plan gets one feature with RED/GREEN/REFACTOR gate sequence.
|
||||
|
||||
**MVP_MODE:** ${MVP_MODE} (when true, follow vertical-slice rules from `~/.claude/gsd-core/references/planner-mvp-mode.md`; when false, ignore MVP guidance entirely.)
|
||||
**WALKING_SKELETON:** ${WALKING_SKELETON} (when true, the first deliverable must be a Walking Skeleton — Read the template at `~/.claude/gsd-core/references/skeleton-template.md` and produce SKELETON.md alongside PLAN.md.)
|
||||
**Granularity:** {granularity}
|
||||
|
||||
${MVP_MODE === 'true' ? `
|
||||
<mvp_mode_active>
|
||||
|
||||
@@ -41,15 +41,12 @@ Verify the work is ready to ship:
|
||||
|
||||
1. **Verification passed?**
|
||||
```bash
|
||||
VERIFICATION_FILE=$(ls ${PHASE_DIR}/*-VERIFICATION.md 2>/dev/null | head -1)
|
||||
STATUS=$(sed -n '/^---$/,/^---$/p' "${VERIFICATION_FILE}" 2>/dev/null | grep -m1 "^status:" | cut -d: -f2 | tr -d ' ')
|
||||
VERIFICATION=$(gsd_run query verification.status "${PHASE_DIR}" 2>/dev/null)
|
||||
STATUS=$(printf '%s' "$VERIFICATION" | jq -r '.status' 2>/dev/null || echo "")
|
||||
NEXT_ACTION=$(printf '%s' "$VERIFICATION" | jq -r '.next_action' 2>/dev/null || echo "")
|
||||
NEXT_COMMAND=$(printf '%s' "$VERIFICATION" | jq -r '.next_command' 2>/dev/null || echo "")
|
||||
```
|
||||
The verifier emits exactly `passed`, `gaps_found`, or `human_needed` (see the status table in `execute-phase.md`); only `passed` may ship. Route on `${STATUS}` — on any non-`passed` value, block with `PHASE_VERIFICATION_INCOMPLETE` and state the matching next action:
|
||||
- `passed` → verification complete; continue to the next preflight check.
|
||||
- `gaps_found` → run `/gsd:plan-phase ${PHASE_NUMBER} --gaps` to plan the fixes, then re-run `/gsd:execute-phase` before shipping.
|
||||
- `human_needed` → complete the manual tests in `${PHASE_DIR}/*-UAT.md`, then re-run the verify step until status is `passed`.
|
||||
- empty (no `*-VERIFICATION.md`) → the verify step never completed; re-run `/gsd:execute-phase`.
|
||||
- any other value → unexpected status `${STATUS}`; re-run `/gsd:execute-phase` verification.
|
||||
Only `passed` may ship. If `$STATUS` is `passed`, verification is complete — continue to the next preflight check. Any other value (including `gaps_found`, `human_needed`, `missing`, and `unknown`) blocks with `PHASE_VERIFICATION_INCOMPLETE`: present `$NEXT_ACTION` to the user and, when `$NEXT_COMMAND` is non-empty, show it as the command to run next. The query already handles missing files and unexpected values, so no per-status arm is needed.
|
||||
|
||||
2. **Clean working tree?**
|
||||
```bash
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
},
|
||||
"milestone": {
|
||||
"files": [
|
||||
"bug-730-milestone-phase-details-scope.test.cjs",
|
||||
"milestone-archive.test.cjs",
|
||||
"milestone-helper.test.cjs",
|
||||
"milestone-prefixed-convention.test.cjs",
|
||||
|
||||
@@ -34,6 +34,7 @@ const {
|
||||
getRoadmapPhaseInternal,
|
||||
extractPhaseToken,
|
||||
resolveGranularityInternal,
|
||||
assertValidGranularityOverride,
|
||||
} = core;
|
||||
import { renderEffortForRuntime, RUNTIMES_WITH_FAST_MODE } from './model-catalog.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
@@ -340,11 +341,12 @@ function cmdResolveModel(cwd: string, agentType: string | undefined, raw: boolea
|
||||
output(result, raw, model);
|
||||
}
|
||||
|
||||
function cmdResolveGranularity(cwd: string, phaseType: string | undefined, raw: boolean): void {
|
||||
function cmdResolveGranularity(cwd: string, phaseType: string | undefined, raw: boolean, override?: string): void {
|
||||
if (!phaseType) {
|
||||
error('phase-type required');
|
||||
}
|
||||
const granularity = resolveGranularityInternal(cwd, phaseType);
|
||||
assertValidGranularityOverride(override, error);
|
||||
const granularity = resolveGranularityInternal(cwd, phaseType, override);
|
||||
const result = (VALID_PHASE_TYPES).has(phaseType!)
|
||||
? { granularity, phase_type: phaseType }
|
||||
: { granularity, phase_type: phaseType, unknown_phase_type: true };
|
||||
|
||||
@@ -429,22 +429,31 @@ function setConfigValue(cwd: string, keyPath: string, parsedValue: unknown): Set
|
||||
error('Failed to read config.json: ' + (err as Error).message, ERROR_REASON.CONFIG_PARSE_FAILED);
|
||||
}
|
||||
|
||||
// Set nested value using dot notation (e.g., "workflow.research")
|
||||
// Set nested value using dot notation (e.g., "workflow.research").
|
||||
// Prototype-pollution guard: reject dangerous segments via inline literal
|
||||
// comparisons on the exact key used to index `current`, immediately before
|
||||
// each write. The inline comparison is the barrier CodeQL's
|
||||
// js/prototype-pollution-utility query recognises — the previous Set-based
|
||||
// pre-loop check was functionally correct but not traced through, so
|
||||
// code-scanning alert #26 kept firing. Behaviour is unchanged from #663.
|
||||
const keys = keyPath.split('.');
|
||||
const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
|
||||
if (keys.some((k) => FORBIDDEN_KEYS.has(k))) {
|
||||
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
|
||||
}
|
||||
let current: Record<string, unknown> = config;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i];
|
||||
if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
|
||||
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
|
||||
}
|
||||
if (current[key] === undefined || typeof current[key] !== 'object') {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key] as Record<string, unknown>;
|
||||
}
|
||||
const previousValue = current[keys[keys.length - 1]]; // Capture previous value before overwriting
|
||||
current[keys[keys.length - 1]] = parsedValue;
|
||||
const lastKey = keys[keys.length - 1];
|
||||
if (lastKey === '__proto__' || lastKey === 'prototype' || lastKey === 'constructor') {
|
||||
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
|
||||
}
|
||||
const previousValue = current[lastKey]; // Capture previous value before overwriting
|
||||
current[lastKey] = parsedValue;
|
||||
|
||||
// Write back
|
||||
try {
|
||||
|
||||
119
src/core.cts
119
src/core.cts
@@ -536,6 +536,7 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
|
||||
effort: (parsed['effort']) || null,
|
||||
fast_mode: (parsed['fast_mode']) || null,
|
||||
agent_skills: (parsed['agent_skills']) || {},
|
||||
agent_skills_security: (parsed['agent_skills_security']) || null,
|
||||
manager: (parsed['manager']) || {},
|
||||
response_language: get('response_language') || null,
|
||||
claude_md_path: get('claude_md_path') || null,
|
||||
@@ -1085,37 +1086,40 @@ function extractCurrentMilestone(content: string, cwd?: string): string {
|
||||
|
||||
const sectionStart = selected.index;
|
||||
|
||||
const sectionMatch = selected;
|
||||
const headingLevel = (sectionMatch[1].match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
|
||||
const restContent = content.slice(sectionStart + sectionMatch[0].length);
|
||||
const nextMilestonePattern = new RegExp(
|
||||
`^#{1,${headingLevel}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`,
|
||||
'i'
|
||||
);
|
||||
|
||||
let sectionEnd = content.length;
|
||||
let fenceChar: string | null = null;
|
||||
let fenceLen = 0;
|
||||
let charOffset = 0;
|
||||
for (const line of restContent.split('\n')) {
|
||||
const fenceMatch = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
|
||||
if (fenceMatch) {
|
||||
const char = fenceMatch[1][0];
|
||||
const len = fenceMatch[1].length;
|
||||
const trailing = fenceMatch[2] || '';
|
||||
if (!fenceChar) {
|
||||
fenceChar = char;
|
||||
fenceLen = len;
|
||||
} else if (char === fenceChar && len >= fenceLen && /^\s*$/.test(trailing)) {
|
||||
fenceChar = null;
|
||||
fenceLen = 0;
|
||||
const computeSectionEnd = (headingText: string, headingStart: number): number => {
|
||||
const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
|
||||
const rest = content.slice(headingStart + headingText.length);
|
||||
const stopPattern = new RegExp(
|
||||
`^#{1,${level}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`,
|
||||
'i',
|
||||
);
|
||||
let end = content.length;
|
||||
let fc: string | null = null;
|
||||
let fl = 0;
|
||||
let off = 0;
|
||||
for (const line of rest.split('\n')) {
|
||||
const fm = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
|
||||
if (fm) {
|
||||
const ch = fm[1][0];
|
||||
const ln = fm[1].length;
|
||||
const trailing = fm[2] || '';
|
||||
if (!fc) {
|
||||
fc = ch;
|
||||
fl = ln;
|
||||
} else if (ch === fc && ln >= fl && /^\s*$/.test(trailing)) {
|
||||
fc = null;
|
||||
fl = 0;
|
||||
}
|
||||
} else if (!fc && stopPattern.test(line)) {
|
||||
end = headingStart + headingText.length + off;
|
||||
break;
|
||||
}
|
||||
} else if (!fenceChar && nextMilestonePattern.test(line)) {
|
||||
sectionEnd = sectionStart + sectionMatch[0].length + charOffset;
|
||||
break;
|
||||
off += line.length + 1;
|
||||
}
|
||||
charOffset += line.length + 1;
|
||||
}
|
||||
return end;
|
||||
};
|
||||
|
||||
const sectionEnd = computeSectionEnd(selected[0], sectionStart);
|
||||
|
||||
const anyMilestonePattern = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧)/im;
|
||||
const firstMilestoneMatch = content.match(anyMilestonePattern);
|
||||
@@ -1125,12 +1129,46 @@ function extractCurrentMilestone(content: string, cwd?: string): string {
|
||||
const beforeMilestones = content.slice(0, preambleCutoff);
|
||||
const currentSection = content.slice(sectionStart, sectionEnd);
|
||||
|
||||
// Multi-milestone roadmaps split each added milestone across two version-bearing
|
||||
// headings: a `## Phases` checklist subsection (early) and a dedicated
|
||||
// `## Milestone … (Phase Details)` section (late) holding the `### Phase N:`
|
||||
// detail headers. The scope window above stops at the next version-bearing
|
||||
// heading — the current milestone's OWN Phase Details heading — leaving those
|
||||
// detail headers outside `currentSection`. Append that section so phase
|
||||
// resolution and counting see the current milestone's phases. Anchor the lookup
|
||||
// to the SELECTED heading's specific version token (boundary-aware, so a
|
||||
// `v3.0` state does not match a `v3.0-A` sub-milestone) so sibling milestones
|
||||
// that share a version prefix do not cross-pollinate. (#730)
|
||||
const selectedVersionToken = selected[1].match(
|
||||
/v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i,
|
||||
)?.[0];
|
||||
const detailsVersionBoundary = selectedVersionToken
|
||||
? new RegExp(`${escapeRegex(selectedVersionToken)}(?![\\w.-])`, 'i')
|
||||
: null;
|
||||
let detailsSection = '';
|
||||
const detailsMatch = allMatches.find(
|
||||
(m) =>
|
||||
/\(Phase\s+Details\)/i.test(m[1]) &&
|
||||
!isClosed(m[1]) &&
|
||||
(!detailsVersionBoundary || detailsVersionBoundary.test(m[1])) &&
|
||||
(m.index ?? 0) >= sectionEnd,
|
||||
);
|
||||
if (detailsMatch) {
|
||||
const detailsStart = detailsMatch.index ?? 0;
|
||||
detailsSection = content.slice(
|
||||
detailsStart,
|
||||
computeSectionEnd(detailsMatch[0], detailsStart),
|
||||
);
|
||||
}
|
||||
|
||||
const preamble = beforeMilestones
|
||||
.replace(/<details>[\s\S]*?<\/details>/gi, '')
|
||||
.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
|
||||
.replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
|
||||
|
||||
return preamble + currentSection;
|
||||
return detailsSection
|
||||
? preamble + currentSection + '\n' + detailsSection
|
||||
: preamble + currentSection;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1553,7 +1591,12 @@ const VALID_GRANULARITIES = new Set(['coarse', 'standard', 'fine']);
|
||||
/**
|
||||
* Resolve the planning granularity for a phase type (#68).
|
||||
*/
|
||||
function resolveGranularityInternal(cwd: string, phaseType: string | null | undefined): string {
|
||||
function resolveGranularityInternal(cwd: string, phaseType: string | null | undefined, override?: string | null): string {
|
||||
if (override !== undefined && override !== null && override !== '') {
|
||||
if (VALID_GRANULARITIES.has(override)) {
|
||||
return override;
|
||||
}
|
||||
}
|
||||
const config = loadConfig(cwd);
|
||||
const configGranularities = config['granularities'] as Record<string, string> | null | undefined;
|
||||
const perPhase = (phaseType && configGranularities && typeof configGranularities === 'object')
|
||||
@@ -1573,6 +1616,19 @@ function resolveGranularityInternal(cwd: string, phaseType: string | null | unde
|
||||
return 'standard';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a CLI granularity override at the command boundary. Empty/null/undefined
|
||||
* are treated as "no override" (no-op). An invalid non-empty value calls `fail`.
|
||||
*/
|
||||
function assertValidGranularityOverride(
|
||||
override: string | null | undefined,
|
||||
fail: (msg: string) => never,
|
||||
): void {
|
||||
if (override !== undefined && override !== null && override !== '' && !VALID_GRANULARITIES.has(override)) {
|
||||
fail(`invalid granularity '${override}' (valid: ${[...VALID_GRANULARITIES].join(', ')})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #3024 — Resolve a model for a specific dynamic-routing attempt.
|
||||
*/
|
||||
@@ -2163,6 +2219,7 @@ export = {
|
||||
resolveModelForTier,
|
||||
resolveGranularityInternal,
|
||||
VALID_GRANULARITIES,
|
||||
assertValidGranularityOverride,
|
||||
resolveEffortInternal,
|
||||
resolveFastModeInternal,
|
||||
resolveEffortForTier,
|
||||
|
||||
@@ -66,8 +66,8 @@ function routeInitCommand({ init, args, cwd, raw, error }: RouteInitCommandOptio
|
||||
init.cmdInitExecutePhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'] });
|
||||
},
|
||||
'plan-phase': () => {
|
||||
const namedArgs = parseNamedArgs(args, [], ['validate', 'tdd']);
|
||||
init.cmdInitPlanPhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'] });
|
||||
const namedArgs = parseNamedArgs(args, ['granularity'], ['validate', 'tdd']);
|
||||
init.cmdInitPlanPhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'], granularity: namedArgs['granularity'] });
|
||||
},
|
||||
'new-project': () => init.cmdInitNewProject(cwd, raw),
|
||||
'new-milestone': () => init.cmdInitNewMilestone(cwd, raw),
|
||||
|
||||
30
src/init.cts
30
src/init.cts
@@ -21,7 +21,7 @@ import { stateExtractField } from './state-document.cjs';
|
||||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- commands.cjs is an export= CommonJS module
|
||||
import commandsMod = require('./commands.cjs');
|
||||
import { validatePath } from './security.cjs';
|
||||
import { validatePath, loadTrustedGlobalRoots } from './security.cjs';
|
||||
import { getGlobalSkillDir, getGlobalSkillDisplayPath, getGlobalSkillsBase } from './runtime-homes.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
||||
import frontmatterMod = require('./frontmatter.cjs');
|
||||
@@ -29,6 +29,8 @@ import frontmatterMod = require('./frontmatter.cjs');
|
||||
const {
|
||||
loadConfig,
|
||||
resolveModelInternal,
|
||||
resolveGranularityInternal,
|
||||
assertValidGranularityOverride,
|
||||
findPhaseInternal,
|
||||
getRoadmapPhaseInternal,
|
||||
pathExistsInternal,
|
||||
@@ -375,12 +377,17 @@ function cmdInitPlanPhase(
|
||||
}
|
||||
}
|
||||
|
||||
const granularityOverride = options['granularity'] as string | undefined;
|
||||
assertValidGranularityOverride(granularityOverride, error);
|
||||
const granularity = resolveGranularityInternal(cwd, 'planning', granularityOverride || undefined);
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
researcher_model: resolveModelInternal(cwd, 'gsd-phase-researcher'),
|
||||
planner_model: resolveModelInternal(cwd, 'gsd-planner'),
|
||||
checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'),
|
||||
|
||||
tdd_mode: options['tdd'] || config.tdd_mode || false,
|
||||
granularity,
|
||||
research_enabled: config.research,
|
||||
plan_checker_enabled: config.plan_checker,
|
||||
nyquist_validation_enabled: config.nyquist_validation,
|
||||
@@ -1863,6 +1870,12 @@ function buildAgentSkillsBlock(
|
||||
if (typeof skillPaths === 'string') skillPaths = [skillPaths];
|
||||
if (!Array.isArray(skillPaths) || skillPaths.length === 0) return '';
|
||||
|
||||
// Hoist trusted roots computation before the loop: loadTrustedGlobalRoots does
|
||||
// realpathSync I/O and should run at most once per call, not once per failing skill.
|
||||
// It returns [] cheaply when no roots are configured, so the realpath cost only
|
||||
// occurs when the caller has actually set trusted_global_roots.
|
||||
const trustedGlobalRoots = loadTrustedGlobalRoots(config);
|
||||
|
||||
const validPaths: { ref: string; display: string }[] = [];
|
||||
for (const skillPath of skillPaths) {
|
||||
if (typeof skillPath !== 'string') continue;
|
||||
@@ -1898,10 +1911,17 @@ function buildAgentSkillsBlock(
|
||||
}
|
||||
const pathCheck = validatePath(globalSkillMd, globalSkillsBase, { allowAbsolute: true }) as unknown as Record<string, unknown>;
|
||||
if (!pathCheck['safe']) {
|
||||
process.stderr.write(
|
||||
`[agent-skills] WARNING: Global skill "${skillName}" failed path check (symlink escape?) — skipping\n`,
|
||||
);
|
||||
continue;
|
||||
const acceptedViaTrustedRoot = trustedGlobalRoots.some((root) => {
|
||||
const rootCheck = validatePath(globalSkillMd, root, { allowAbsolute: true }) as unknown as Record<string, unknown>;
|
||||
return Boolean(rootCheck['safe']);
|
||||
});
|
||||
if (!acceptedViaTrustedRoot) {
|
||||
process.stderr.write(
|
||||
`[agent-skills] WARNING: Global skill "${skillName}" failed path check (symlink escape?) — skipping\n`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
process.stderr.write(`[agent-skills] NOTE: Global skill "${skillName}" accepted via trusted_global_roots (resolves outside the default skills dir)\n`);
|
||||
}
|
||||
validPaths.push({ ref: `${globalSkillDir}/SKILL.md`, display: displayPath });
|
||||
continue;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
// ─── Path Traversal Prevention ──────────────────────────────────────────────
|
||||
@@ -75,6 +76,74 @@ export function validatePath(filePath: unknown, baseDir: unknown, opts: { allowA
|
||||
return { safe: true, resolved: resolvedPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the opt-in trusted global roots allowlist from config.
|
||||
*
|
||||
* Reads `config.agent_skills_security.trusted_global_roots` (an array of
|
||||
* path strings). Each entry is canonicalized via realpathSync: non-strings
|
||||
* are dropped, leading `~/` is expanded to `os.homedir()`, entries that are
|
||||
* not absolute after expansion are dropped (project-relative paths are
|
||||
* rejected as a security boundary), and entries that do not exist on disk are
|
||||
* dropped (a non-existent root is not trustworthy). The canonical realpath is
|
||||
* used for all subsequent checks and as the stored value — this closes the
|
||||
* case-insensitive bypass on macOS APFS (`/users/alice` vs `/Users/alice`)
|
||||
* and ensures trust doesn't drift across re-invocations if a root is
|
||||
* re-created at a different target. Results are de-duplicated by canonical path.
|
||||
*/
|
||||
export function loadTrustedGlobalRoots(config: unknown): string[] {
|
||||
const roots = (config as Record<string, unknown> | null | undefined)
|
||||
?.['agent_skills_security'] as Record<string, unknown> | undefined;
|
||||
const raw = roots?.['trusted_global_roots'];
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
// Compute canonical homedir once for case-insensitive-safe comparison.
|
||||
let realHome: string;
|
||||
try {
|
||||
realHome = fs.realpathSync(os.homedir());
|
||||
} catch {
|
||||
realHome = os.homedir();
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry !== 'string') continue;
|
||||
let expanded: string;
|
||||
if (entry === '~') {
|
||||
expanded = os.homedir();
|
||||
} else if (entry.startsWith('~/')) {
|
||||
expanded = path.join(os.homedir(), entry.slice(2));
|
||||
} else {
|
||||
expanded = entry;
|
||||
}
|
||||
if (!path.isAbsolute(expanded)) continue; // reject project-relative
|
||||
|
||||
// Canonicalize: resolve symlinks and normalise case. If the path doesn't
|
||||
// exist or can't be read, skip it — a non-existent root is not trustworthy.
|
||||
let real: string;
|
||||
try {
|
||||
real = fs.realpathSync(expanded);
|
||||
} catch {
|
||||
continue; // non-existent or unreadable — skip
|
||||
}
|
||||
|
||||
// Reject dangerously broad roots: filesystem root (e.g. '/' or 'C:\' or UNC '\\server\share').
|
||||
// Normalize both sides by stripping trailing path separators before comparing so that
|
||||
// Windows UNC shares (where path.parse().root includes a trailing separator) are caught.
|
||||
const stripTrailingSep = (p: string): string => p.replace(/[\\/]+$/, '');
|
||||
if (stripTrailingSep(path.parse(real).root) === stripTrailingSep(real)) continue;
|
||||
// Reject homedir itself (canonical compare closes case-insensitive bypass).
|
||||
// Apply stripTrailingSep for robustness on platforms where realpathSync may
|
||||
// or may not include a trailing separator on the homedir path.
|
||||
if (stripTrailingSep(real) === stripTrailingSep(realHome)) continue;
|
||||
|
||||
if (seen.has(real)) continue;
|
||||
seen.add(real);
|
||||
result.push(real);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a file path and throw on traversal attempt.
|
||||
* Convenience wrapper around validatePath for use in CLI commands.
|
||||
|
||||
56
src/verification-command-router.cts
Normal file
56
src/verification-command-router.cts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Verification-status subcommand router.
|
||||
* Routes `verification.status <phaseDir>` to verification.cmdVerificationStatus.
|
||||
*
|
||||
* Note: `verification` (reads verifier-emitted status) is distinct from `verify`
|
||||
* (runs verification checks like plan-structure/artifacts). Keep them separate.
|
||||
*
|
||||
* ADR-457 build-at-publish: source in src/verification-command-router.cts,
|
||||
* compiled to gsd-core/bin/lib/verification-command-router.cjs (gitignored).
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import cjsCommandRouterAdapter = require('./cjs-command-router-adapter.cjs');
|
||||
const { routeCjsCommandFamily } = cjsCommandRouterAdapter;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface VerificationModule {
|
||||
cmdVerificationStatus(cwd: string, phaseDirArg: string | undefined, raw: boolean): void;
|
||||
}
|
||||
|
||||
interface RouteVerificationCommandOptions {
|
||||
verification: VerificationModule;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
raw: boolean;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
// ─── Implementation ───────────────────────────────────────────────────────────
|
||||
|
||||
const VERIFICATION_SUBCOMMANDS = ['status'];
|
||||
|
||||
function routeVerificationCommand({
|
||||
verification,
|
||||
args,
|
||||
cwd,
|
||||
raw,
|
||||
error,
|
||||
}: RouteVerificationCommandOptions): void {
|
||||
routeCjsCommandFamily({
|
||||
args,
|
||||
subcommands: VERIFICATION_SUBCOMMANDS,
|
||||
unsupported: {},
|
||||
error,
|
||||
unknownMessage: (_subcommand: string, available: string[]) =>
|
||||
`Unknown verification subcommand. Available: ${available.join(', ')}`,
|
||||
handlers: {
|
||||
status: () => verification.cmdVerificationStatus(cwd, args[2], raw),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export = {
|
||||
routeVerificationCommand,
|
||||
};
|
||||
234
src/verification.cts
Normal file
234
src/verification.cts
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Verification Status — single queryable home for verification-status routing.
|
||||
*
|
||||
* Issue #651: consolidate the pass/gaps_found/human_needed routing that was
|
||||
* previously scattered across ship.md and execute-phase.md into a single
|
||||
* tested module. Both workflow files will later consume this module's routing
|
||||
* table as the single source of truth.
|
||||
*
|
||||
* ADR-457 build-at-publish: source in src/verification.cts, compiled to
|
||||
* gsd-core/bin/lib/verification.cjs (gitignored).
|
||||
*
|
||||
* DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix: status extraction is scoped to
|
||||
* the leading YAML frontmatter block only. A `status:` line in the body (e.g.
|
||||
* inside a fenced code block) is ignored — this is the exact failure mode that
|
||||
* issue #586 / PR #650 identified. The shared extractFrontmatter parser anchors
|
||||
* its regex at byte 0 of the document, which provides this guarantee.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core.cjs is an export= CommonJS module
|
||||
import core = require('./core.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
||||
import frontmatterMod = require('./frontmatter.cjs');
|
||||
|
||||
const { output, extractPhaseToken } = core;
|
||||
const { extractFrontmatter } = frontmatterMod;
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The set of status values that the gsd-verifier agent emits. */
|
||||
const VERIFIER_STATUSES: ReadonlyArray<string> = ['passed', 'gaps_found', 'human_needed'];
|
||||
|
||||
// ─── Routing table ────────────────────────────────────────────────────────────
|
||||
|
||||
interface VerificationRoute {
|
||||
status: string;
|
||||
next_action: string;
|
||||
next_command: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical routing table for verification statuses.
|
||||
*
|
||||
* This is the single source of truth — ship.md and execute-phase.md will
|
||||
* later import from here instead of embedding their own message strings.
|
||||
*
|
||||
* INTERNAL SENTINELS: 'missing' and 'unknown' are operational states constructed
|
||||
* internally — the verifier (gsd-verifier.md) never emits them. The verifier only
|
||||
* emits values in VERIFIER_STATUSES (passed|gaps_found|human_needed). The guard in
|
||||
* readVerificationStatus excludes 'missing' and 'unknown' from raw-status table
|
||||
* lookup so they can only be reached via internal construction paths.
|
||||
*
|
||||
* For 'gaps_found', next_command is built at call time in readVerificationStatus
|
||||
* by substituting the phase number — it is NOT stored as a function in the table.
|
||||
*/
|
||||
const VERIFICATION_ROUTING_TABLE: Record<string, VerificationRoute> = {
|
||||
passed: {
|
||||
status: 'passed',
|
||||
next_action: 'Verification passed — continue.',
|
||||
next_command: '',
|
||||
},
|
||||
gaps_found: {
|
||||
status: 'gaps_found',
|
||||
next_action: 'Gaps found. Plan the fixes, then re-run execute-phase before shipping.',
|
||||
// next_command is computed at call time; this entry is never returned directly.
|
||||
next_command: '',
|
||||
},
|
||||
human_needed: {
|
||||
status: 'human_needed',
|
||||
next_action: "Human verification required. Complete the manual tests in the phase's *-UAT.md, then re-run the verify step until status is passed.",
|
||||
next_command: '',
|
||||
},
|
||||
// INTERNAL SENTINEL: constructed when no *-VERIFICATION.md file exists or when
|
||||
// the file has no parseable frontmatter status. Never emitted by the verifier.
|
||||
missing: {
|
||||
status: 'missing',
|
||||
next_action: 'No verification report found — the verify step never completed. Re-run execute-phase.',
|
||||
next_command: '/gsd:execute-phase',
|
||||
},
|
||||
// INTERNAL SENTINEL: constructed when the file has a status value not in
|
||||
// VERIFIER_STATUSES. Never emitted by the verifier.
|
||||
unknown: {
|
||||
status: 'unknown',
|
||||
next_action: '', // filled in dynamically with the raw value
|
||||
next_command: '/gsd:execute-phase',
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface FsLike {
|
||||
readdirSync(dir: string): string[];
|
||||
readFileSync(filePath: string, encoding: 'utf-8'): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 'missing' result from the routing table.
|
||||
* Used for two early-return paths: no *-VERIFICATION.md file found, and
|
||||
* file present but no parseable frontmatter status.
|
||||
*/
|
||||
function missingResult(): VerificationStatusResult {
|
||||
const route = VERIFICATION_ROUTING_TABLE['missing'];
|
||||
return {
|
||||
status: route.status,
|
||||
next_action: route.next_action,
|
||||
next_command: route.next_command,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface ReadVerificationStatusOptions {
|
||||
fs?: FsLike;
|
||||
}
|
||||
|
||||
interface VerificationStatusResult {
|
||||
status: string;
|
||||
next_action: string;
|
||||
next_command: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the verification status from the first `*-VERIFICATION.md` file in
|
||||
* phaseDir and return the routing result.
|
||||
*
|
||||
* Behavior:
|
||||
* 1. Find the first file matching `*-VERIFICATION.md` (sorted, take first).
|
||||
* If none → status 'missing'.
|
||||
* 2. Extract `status` from FRONTMATTER ONLY via the shared extractFrontmatter
|
||||
* parser (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix — parser anchors at byte 0).
|
||||
* If no frontmatter block or no `status` key → status 'missing'.
|
||||
* 3. Map to routing table. Unknown non-empty value → status 'unknown'.
|
||||
*
|
||||
* @param phaseDir - Absolute path to the phase directory.
|
||||
* @param opts - Options. `opts.fs` allows test injection (defaults to node:fs).
|
||||
*/
|
||||
function readVerificationStatus(
|
||||
phaseDir: string,
|
||||
opts: ReadVerificationStatusOptions = {},
|
||||
): VerificationStatusResult {
|
||||
const fsImpl: FsLike = opts.fs ?? fs;
|
||||
|
||||
// Phase token for the gaps_found command
|
||||
const baseName = path.basename(phaseDir);
|
||||
const phaseToken = extractPhaseToken(baseName);
|
||||
const phaseNumber = phaseToken.length > 0 ? phaseToken : baseName;
|
||||
|
||||
// 1. Find *-VERIFICATION.md
|
||||
let verificationFile: string | null = null;
|
||||
try {
|
||||
const entries = fsImpl.readdirSync(phaseDir);
|
||||
const candidates = entries.filter((f) => f.endsWith('-VERIFICATION.md')).sort();
|
||||
verificationFile = candidates.length > 0 ? candidates[0] : null;
|
||||
} catch {
|
||||
// Directory unreadable → treat as missing
|
||||
verificationFile = null;
|
||||
}
|
||||
|
||||
if (!verificationFile) {
|
||||
return missingResult();
|
||||
}
|
||||
|
||||
// 2. Read and parse frontmatter using the shared parser.
|
||||
// extractFrontmatter anchors at byte 0, so body `status:` lines are ignored.
|
||||
const filePath = path.join(phaseDir, verificationFile);
|
||||
let rawStatus: string | null = null;
|
||||
try {
|
||||
const content = fsImpl.readFileSync(filePath, 'utf-8');
|
||||
const fm = extractFrontmatter(content);
|
||||
const statusVal = fm['status'];
|
||||
// status is always a scalar string in a well-formed VERIFICATION.md frontmatter;
|
||||
// only accept string values — arrays and objects are not valid status values.
|
||||
if (typeof statusVal === 'string') {
|
||||
const trimmed = statusVal.trim();
|
||||
rawStatus = trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
} catch {
|
||||
rawStatus = null;
|
||||
}
|
||||
|
||||
if (!rawStatus) {
|
||||
return missingResult();
|
||||
}
|
||||
|
||||
// 3. Route — exclude internal sentinels from raw-file lookup (they are
|
||||
// constructed internally above, never written by the verifier).
|
||||
if (rawStatus in VERIFICATION_ROUTING_TABLE && rawStatus !== 'missing' && rawStatus !== 'unknown') {
|
||||
const entry = VERIFICATION_ROUTING_TABLE[rawStatus];
|
||||
// gaps_found: build the phase-specific command here rather than in the table.
|
||||
const next_command =
|
||||
rawStatus === 'gaps_found'
|
||||
? `/gsd:plan-phase ${phaseNumber} --gaps`
|
||||
: entry.next_command;
|
||||
return {
|
||||
status: entry.status,
|
||||
next_action: entry.next_action,
|
||||
next_command,
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown value
|
||||
const unknownRoute = VERIFICATION_ROUTING_TABLE['unknown'];
|
||||
return {
|
||||
status: unknownRoute.status,
|
||||
next_action: `Unexpected verification status '${rawStatus}'. Re-run execute-phase verification.`,
|
||||
next_command: unknownRoute.next_command,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI command handler: resolve phaseDir against cwd, call readVerificationStatus,
|
||||
* emit via core.output().
|
||||
*
|
||||
* @param cwd - Current working directory (used to resolve phaseDirArg).
|
||||
* @param phaseDirArg - Phase directory path (absolute or relative to cwd).
|
||||
* @param raw - Whether to emit raw (non-JSON) output.
|
||||
*/
|
||||
function cmdVerificationStatus(cwd: string, phaseDirArg: string | undefined, raw: boolean): void {
|
||||
if (!phaseDirArg) {
|
||||
core.error('phase directory required for verification.status');
|
||||
return;
|
||||
}
|
||||
const phaseDir = path.resolve(cwd, phaseDirArg);
|
||||
const result = readVerificationStatus(phaseDir);
|
||||
output(result, raw);
|
||||
}
|
||||
|
||||
export = {
|
||||
VERIFIER_STATUSES,
|
||||
VERIFICATION_ROUTING_TABLE,
|
||||
readVerificationStatus,
|
||||
cmdVerificationStatus,
|
||||
};
|
||||
368
src/worktree-base-ref.cts
Normal file
368
src/worktree-base-ref.cts
Normal file
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* Worktree base-ref detection and degradation logic (issue #683).
|
||||
*
|
||||
* Determines whether a worktree's HEAD has drifted from the fork base that the
|
||||
* Claude Code harness would use to create a 'fresh' parallel worktree. When
|
||||
* drift is detected the caller should fall back to sequential execution on the
|
||||
* main working tree to avoid a base mismatch.
|
||||
*
|
||||
* Pure/testable module: all I/O is injectable via the `deps` argument so unit
|
||||
* tests can run without touching the real filesystem or spawning real git.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { execGit as execGitSeam } from './shell-command-projection.cjs';
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Strip JSONC comments (line and block forms) from a string to produce valid JSON.
|
||||
* Handles comments inside strings correctly (does not strip them).
|
||||
* Mirrors the same logic in bin/install.js:stripJsonComments.
|
||||
*/
|
||||
function stripJsonComments(text: string): string {
|
||||
let result = '';
|
||||
let i = 0;
|
||||
let inString = false;
|
||||
let stringChar = '';
|
||||
while (i < text.length) {
|
||||
// Handle string literals — don't strip comments inside strings
|
||||
if (inString) {
|
||||
if (text[i] === '\\') {
|
||||
result += text[i] + (text[i + 1] || '');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (text[i] === stringChar) {
|
||||
inString = false;
|
||||
}
|
||||
result += text[i];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// Start of string
|
||||
if (text[i] === '"' || text[i] === "'") {
|
||||
inString = true;
|
||||
stringChar = text[i];
|
||||
result += text[i];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// Line comment
|
||||
if (text[i] === '/' && text[i + 1] === '/') {
|
||||
// Skip to end of line
|
||||
while (i < text.length && text[i] !== '\n') i++;
|
||||
continue;
|
||||
}
|
||||
// Block comment
|
||||
if (text[i] === '/' && text[i + 1] === '*') {
|
||||
i += 2;
|
||||
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++;
|
||||
i += 2; // skip closing */
|
||||
continue;
|
||||
}
|
||||
result += text[i];
|
||||
i++;
|
||||
}
|
||||
// Remove trailing commas before } or ] (common in JSONC)
|
||||
return result.replace(/,\s*([}\]])/g, '$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a string as JSONC (JSON with comments). Returns the parsed value or
|
||||
* throws a SyntaxError if the content is genuinely malformed.
|
||||
*/
|
||||
function parseJsonc(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return JSON.parse(stripJsonComments(text));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internal types ───────────────────────────────────────────────────────────
|
||||
|
||||
type ExecGitFn = (
|
||||
args: string[],
|
||||
opts?: { cwd?: string; env?: Record<string, string>; timeout?: number }
|
||||
) => { exitCode: number | null; stdout: string; stderr: string; signal: string | null; error: unknown };
|
||||
|
||||
// ─── Message constants (verbatim — downstream docs/tests depend on these) ─────
|
||||
|
||||
function buildMsgDiverged(headSha: string | null, forkRef: string | null, forkSha: string | null): string {
|
||||
return `⚠ Worktree base mismatch: HEAD (${shortSha(headSha)}) differs from ${forkRef} (${shortSha(forkSha)}). Running this phase sequentially on the main working tree. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`;
|
||||
}
|
||||
|
||||
const MSG_UNKNOWN = `⚠ Cannot determine the worktree fork base (origin/HEAD unresolved). Running this phase sequentially on the main working tree to avoid a base mismatch. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`;
|
||||
|
||||
// ─── Exports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the first 8 characters of a SHA, or '' if null/empty.
|
||||
*/
|
||||
export function shortSha(sha: string | null): string {
|
||||
if (!sha) return '';
|
||||
return sha.slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts settings.worktree.baseRef if it is a string; otherwise null.
|
||||
* Defensive: settings may be null/undefined, worktree may be missing or
|
||||
* not an object.
|
||||
*/
|
||||
export function readBaseRefFromSettings(settings: unknown): string | null {
|
||||
if (settings == null || typeof settings !== 'object') return null;
|
||||
const s = settings as Record<string, unknown>;
|
||||
if (s.worktree == null || typeof s.worktree !== 'object' || Array.isArray(s.worktree)) return null;
|
||||
const worktree = s.worktree as Record<string, unknown>;
|
||||
if (typeof worktree.baseRef !== 'string') return null;
|
||||
return worktree.baseRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* No-clobber application of worktree.baseRef = 'head'.
|
||||
*
|
||||
* - If baseRef is absent/null/undefined → set to 'head', return changed:true.
|
||||
* - If already 'head' → skip, return skipped:'already-head'.
|
||||
* - If any other string → skip without overwriting, return skipped:'explicit-other'.
|
||||
*
|
||||
* Mutates `settings` in place and also returns it.
|
||||
*/
|
||||
export function applyWorktreeBaseRef(settings: Record<string, unknown>): {
|
||||
changed: boolean;
|
||||
settings: object;
|
||||
skipped: null | 'already-head' | 'explicit-other';
|
||||
previous: string | null;
|
||||
} {
|
||||
// Defensive: caller must pass a plain object — reject null, arrays, and primitives.
|
||||
if (settings === null || Array.isArray(settings) || typeof settings !== 'object') {
|
||||
throw new TypeError(`applyWorktreeBaseRef: expected a plain object, got ${settings === null ? 'null' : Array.isArray(settings) ? 'array' : typeof settings}`);
|
||||
}
|
||||
// Ensure worktree object exists, preserving any existing keys
|
||||
if (settings.worktree == null || typeof settings.worktree !== 'object' || Array.isArray(settings.worktree)) {
|
||||
settings.worktree = {};
|
||||
}
|
||||
const worktree = settings.worktree as Record<string, unknown>;
|
||||
const current = typeof worktree.baseRef === 'string' ? worktree.baseRef : null;
|
||||
|
||||
if (current === 'head') {
|
||||
return { changed: false, settings, skipped: 'already-head', previous: 'head' };
|
||||
}
|
||||
if (current !== null) {
|
||||
// Some other explicit string value — don't overwrite
|
||||
return { changed: false, settings, skipped: 'explicit-other', previous: current };
|
||||
}
|
||||
// Absent/null/undefined → set to 'head'
|
||||
worktree.baseRef = 'head';
|
||||
return { changed: true, settings, skipped: null, previous: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads settings.local.json then settings.json under claudeDir, extracts
|
||||
* worktree.baseRef from the first file that provides a non-null string value.
|
||||
*
|
||||
* deps.readFile(path) must return the file contents or null on any error.
|
||||
*/
|
||||
export function resolveEffectiveBaseRef(
|
||||
claudeDir: string,
|
||||
deps?: { readFile?: (p: string) => string | null }
|
||||
): string | null {
|
||||
const readFile: (p: string) => string | null = deps?.readFile ?? ((p: string) => {
|
||||
try {
|
||||
return fs.readFileSync(p, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const localPath = path.join(claudeDir, 'settings.local.json');
|
||||
const sharedPath = path.join(claudeDir, 'settings.json');
|
||||
|
||||
function parseBaseRef(filePath: string): string | null {
|
||||
const contents = readFile(filePath);
|
||||
if (contents == null) return null;
|
||||
try {
|
||||
const parsed: unknown = parseJsonc(contents);
|
||||
return readBaseRefFromSettings(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const localRef = parseBaseRef(localPath);
|
||||
if (localRef !== null) return localRef;
|
||||
|
||||
return parseBaseRef(sharedPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI command: check current worktree base-ref degradation status.
|
||||
*
|
||||
* Reads effective baseRef from <cwd>/.claude settings, runs degradation
|
||||
* evaluation, writes JSON result to stdout (or injected write), and returns
|
||||
* the result object.
|
||||
*/
|
||||
export function cmdWorktreeBaseCheck(
|
||||
cwd: string,
|
||||
_args: string[],
|
||||
deps?: { execGit?: ExecGitFn; readFile?: (p: string) => string | null; write?: (s: string) => void }
|
||||
): ReturnType<typeof evaluateWorktreeBaseDegrade> {
|
||||
const claudeDir = path.join(cwd, '.claude');
|
||||
const effectiveBaseRef = resolveEffectiveBaseRef(
|
||||
claudeDir,
|
||||
deps?.readFile ? { readFile: deps.readFile } : undefined
|
||||
);
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
cwd,
|
||||
effectiveBaseRef,
|
||||
execGit: deps?.execGit,
|
||||
});
|
||||
const write = deps?.write ?? ((s: string) => process.stdout.write(s));
|
||||
write(JSON.stringify(result, null, 2) + '\n');
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI command: write worktree.baseRef = 'head' into <cwd>/.claude/settings.local.json.
|
||||
*
|
||||
* No-clobber: if the file already has an explicit baseRef that is not 'head',
|
||||
* the existing value is preserved and output reflects skipped:'explicit-other'.
|
||||
* If the file contains malformed JSON, throws a clear error rather than
|
||||
* silently clobbering the user's file.
|
||||
*/
|
||||
export function cmdWorktreeSetBaseRef(
|
||||
cwd: string,
|
||||
_args: string[],
|
||||
deps?: {
|
||||
readFile?: (p: string) => string | null;
|
||||
writeFile?: (p: string, content: string) => void;
|
||||
mkdir?: (p: string, opts: { recursive: boolean }) => void;
|
||||
existsSync?: (p: string) => boolean;
|
||||
write?: (s: string) => void;
|
||||
}
|
||||
): { changed: boolean; skipped: null | 'already-head' | 'explicit-other'; previous: string | null; file: string; baseRef: string } {
|
||||
const file = path.join(cwd, '.claude', 'settings.local.json');
|
||||
const readFile: (p: string) => string | null = deps?.readFile ??
|
||||
((p: string) => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } });
|
||||
|
||||
const raw = readFile(file);
|
||||
let settings: Record<string, unknown> = {};
|
||||
if (raw != null) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = parseJsonc(raw);
|
||||
} catch {
|
||||
throw new Error(`Refusing to modify ${file}: existing JSON is malformed`);
|
||||
}
|
||||
if (parsed === null || Array.isArray(parsed) || typeof parsed !== 'object') {
|
||||
throw new Error(`Refusing to modify ${file}: expected a JSON object at the top level`);
|
||||
}
|
||||
settings = parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const apply = applyWorktreeBaseRef(settings);
|
||||
|
||||
if (apply.changed) {
|
||||
const dir = path.dirname(file);
|
||||
const existsSync: (p: string) => boolean = deps?.existsSync ?? fs.existsSync;
|
||||
const mkdirFn: (p: string, opts: { recursive: boolean }) => void = deps?.mkdir ??
|
||||
((p: string, opts: { recursive: boolean }) => { fs.mkdirSync(p, opts); });
|
||||
if (!existsSync(dir)) {
|
||||
mkdirFn(dir, { recursive: true });
|
||||
}
|
||||
const writeFile: (p: string, content: string) => void = deps?.writeFile ??
|
||||
((p: string, content: string) => { fs.writeFileSync(p, content, 'utf8'); });
|
||||
writeFile(file, JSON.stringify(settings, null, 2) + '\n');
|
||||
}
|
||||
|
||||
const output = {
|
||||
changed: apply.changed,
|
||||
skipped: apply.skipped,
|
||||
previous: apply.previous,
|
||||
baseRef: 'head' as const,
|
||||
file,
|
||||
};
|
||||
const write = deps?.write ?? ((s: string) => process.stdout.write(s));
|
||||
write(JSON.stringify(output, null, 2) + '\n');
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates whether the current worktree HEAD has diverged from the fork base
|
||||
* (origin/HEAD) that the Claude Code harness would use when creating a 'fresh'
|
||||
* parallel worktree.
|
||||
*
|
||||
* Returns a structured result with shouldDegrade, reason, and a user-visible
|
||||
* message when degradation is warranted.
|
||||
*/
|
||||
export function evaluateWorktreeBaseDegrade(deps?: {
|
||||
execGit?: ExecGitFn;
|
||||
effectiveBaseRef?: string | null;
|
||||
cwd?: string;
|
||||
}): {
|
||||
shouldDegrade: boolean;
|
||||
reason: string;
|
||||
message: string | null;
|
||||
headSha: string | null;
|
||||
forkRef: string | null;
|
||||
forkSha: string | null;
|
||||
} {
|
||||
const execGit: ExecGitFn = deps?.execGit ?? execGitSeam;
|
||||
const cwd = deps?.cwd;
|
||||
const cwdOpts = cwd ? { cwd } : {};
|
||||
|
||||
// a. If baseRef is explicitly 'head' the harness forks from HEAD — no mismatch possible.
|
||||
// Claude Code's worktree.baseRef accepts only "fresh" (= origin/HEAD, the default) or "head".
|
||||
// Therefore special-casing "head" here and otherwise comparing HEAD against origin/HEAD is
|
||||
// complete: any non-"head" value (including "fresh" and absent/null) has fresh/origin-HEAD
|
||||
// semantics and must be evaluated against origin/HEAD. (Reference: Claude Code worktrees docs, #683.)
|
||||
if (deps?.effectiveBaseRef === 'head') {
|
||||
return { shouldDegrade: false, reason: 'baseref-head', message: null, headSha: null, forkRef: null, forkSha: null };
|
||||
}
|
||||
|
||||
// b. Resolve HEAD sha.
|
||||
const headResult = execGit(['rev-parse', 'HEAD'], cwdOpts);
|
||||
const headStdout = headResult.stdout ? headResult.stdout.trim() : '';
|
||||
if (headResult.exitCode !== 0 || !headStdout) {
|
||||
return { shouldDegrade: false, reason: 'no-head', message: null, headSha: null, forkRef: null, forkSha: null };
|
||||
}
|
||||
const headSha = headStdout;
|
||||
|
||||
// c. Resolve fork base (what the harness forks 'fresh' worktrees from = origin/HEAD).
|
||||
let forkRef: string | null = null;
|
||||
let forkSha: string | null = null;
|
||||
|
||||
// Try direct origin/HEAD rev-parse first.
|
||||
const directResult = execGit(['rev-parse', '--verify', '--quiet', 'origin/HEAD'], cwdOpts);
|
||||
const directStdout = directResult.stdout ? directResult.stdout.trim() : '';
|
||||
if (directResult.exitCode === 0 && directStdout) {
|
||||
forkRef = 'origin/HEAD';
|
||||
forkSha = directStdout;
|
||||
} else {
|
||||
// Fall back via symbolic-ref → refs/remotes/origin/HEAD
|
||||
const symResult = execGit(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], cwdOpts);
|
||||
const symStdout = symResult.stdout ? symResult.stdout.trim() : '';
|
||||
if (symResult.exitCode === 0 && symStdout) {
|
||||
const ref = symStdout;
|
||||
const symShaResult = execGit(['rev-parse', '--verify', '--quiet', ref], cwdOpts);
|
||||
const symShaStdout = symShaResult.stdout ? symShaResult.stdout.trim() : '';
|
||||
if (symShaResult.exitCode === 0 && symShaStdout) {
|
||||
// Strip leading 'refs/remotes/' to get e.g. 'origin/next'
|
||||
forkRef = ref.replace(/^refs\/remotes\//, '');
|
||||
forkSha = symShaStdout;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// d. Evaluate.
|
||||
if (forkSha === null) {
|
||||
return { shouldDegrade: true, reason: 'fork-ref-unknown', message: MSG_UNKNOWN, headSha, forkRef: null, forkSha: null };
|
||||
}
|
||||
if (forkSha === headSha) {
|
||||
return { shouldDegrade: false, reason: 'head-matches-fork', message: null, headSha, forkRef, forkSha };
|
||||
}
|
||||
const message = buildMsgDiverged(headSha, forkRef, forkSha);
|
||||
return { shouldDegrade: true, reason: 'head-diverged-from-fork', message, headSha, forkRef, forkSha };
|
||||
}
|
||||
@@ -12,9 +12,52 @@
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
const { runGsdTools, createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION_ID: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Run gsd-tools and capture BOTH stdout and stderr on success.
|
||||
* Returns { success, stdout, stderr }.
|
||||
*/
|
||||
function runGsdToolsWithStderr(args, cwd, env) {
|
||||
const childEnv = { ...process.env, ...TEST_ENV_BASE, ...(env || {}) };
|
||||
try {
|
||||
const result = spawnSync(process.execPath, [TOOLS_PATH, ...args], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
env: childEnv,
|
||||
});
|
||||
return {
|
||||
success: result.status === 0,
|
||||
stdout: (result.stdout || '').trim(),
|
||||
stderr: (result.stderr || '').trim(),
|
||||
exitCode: result.status,
|
||||
};
|
||||
} catch (err) {
|
||||
return { success: false, stdout: '', stderr: String(err), exitCode: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
const { loadTrustedGlobalRoots, validatePath } = require('../gsd-core/bin/lib/security.cjs');
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -343,3 +386,390 @@ describe('agent-skills global: prefix', () => {
|
||||
assert.strictEqual(r.ir.block, '', 'block must be empty for empty global: prefix');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── loadTrustedGlobalRoots unit tests (#52) ──────────────────────────────────
|
||||
|
||||
describe('loadTrustedGlobalRoots', () => {
|
||||
test('returns [] for undefined config', () => {
|
||||
assert.deepStrictEqual(loadTrustedGlobalRoots(undefined), []);
|
||||
});
|
||||
|
||||
test('returns [] for null config', () => {
|
||||
assert.deepStrictEqual(loadTrustedGlobalRoots(null), []);
|
||||
});
|
||||
|
||||
test('returns [] when agent_skills_security is absent', () => {
|
||||
assert.deepStrictEqual(loadTrustedGlobalRoots({}), []);
|
||||
});
|
||||
|
||||
test('returns [] when trusted_global_roots is absent', () => {
|
||||
assert.deepStrictEqual(loadTrustedGlobalRoots({ agent_skills_security: {} }), []);
|
||||
});
|
||||
|
||||
test('returns [] when trusted_global_roots is not an array', () => {
|
||||
assert.deepStrictEqual(loadTrustedGlobalRoots({ agent_skills_security: { trusted_global_roots: '/some/path' } }), []);
|
||||
assert.deepStrictEqual(loadTrustedGlobalRoots({ agent_skills_security: { trusted_global_roots: 42 } }), []);
|
||||
assert.deepStrictEqual(loadTrustedGlobalRoots({ agent_skills_security: { trusted_global_roots: true } }), []);
|
||||
});
|
||||
|
||||
test('drops non-string entries from the array', () => {
|
||||
// Use a real temp dir so realpathSync succeeds; non-strings are still dropped
|
||||
const realDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tgr-ns-'));
|
||||
try {
|
||||
const realPath = fs.realpathSync(realDir);
|
||||
const config = { agent_skills_security: { trusted_global_roots: [42, null, realDir, true] } };
|
||||
assert.deepStrictEqual(loadTrustedGlobalRoots(config), [realPath]);
|
||||
} finally {
|
||||
cleanup(realDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('drops project-relative (non-absolute) entries', () => {
|
||||
const config = { agent_skills_security: { trusted_global_roots: ['foo/bar', 'relative/path'] } };
|
||||
assert.deepStrictEqual(loadTrustedGlobalRoots(config), []);
|
||||
});
|
||||
|
||||
test('keeps absolute paths — real dirs are kept and canonicalized', () => {
|
||||
// Non-existent dirs are dropped; use real temp dirs and compare against realpaths
|
||||
const dir1 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tgr-d1-'));
|
||||
const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tgr-d2-'));
|
||||
try {
|
||||
const real1 = fs.realpathSync(dir1);
|
||||
const real2 = fs.realpathSync(dir2);
|
||||
const config = { agent_skills_security: { trusted_global_roots: [dir1, dir2] } };
|
||||
assert.deepStrictEqual(loadTrustedGlobalRoots(config), [real1, real2]);
|
||||
} finally {
|
||||
cleanup(dir1);
|
||||
cleanup(dir2);
|
||||
}
|
||||
});
|
||||
|
||||
test('expands leading ~/ to os.homedir() — kept only if the dir exists', () => {
|
||||
// Create a real subdir under os.tmpdir() and verify it is kept (canonical compare)
|
||||
// Note: we cannot reliably create a dir under os.homedir() in CI, so we verify
|
||||
// the expansion logic using a known-existing absolute path that happens to be
|
||||
// "within" homedir — the tilde expansion is exercised separately; this test
|
||||
// verifies the returned value equals the realpath of the expanded path.
|
||||
const subdir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tgr-tilde-'));
|
||||
try {
|
||||
const realSub = fs.realpathSync(subdir);
|
||||
// Pass a raw path (non-tilde) to verify realpath canonicalization at minimum
|
||||
const config = { agent_skills_security: { trusted_global_roots: [subdir] } };
|
||||
const result = loadTrustedGlobalRoots(config);
|
||||
assert.deepStrictEqual(result, [realSub], 'result must equal realpath of existing dir');
|
||||
} finally {
|
||||
cleanup(subdir);
|
||||
}
|
||||
});
|
||||
|
||||
test('non-existent absolute root is dropped (returns [])', () => {
|
||||
const config = { agent_skills_security: { trusted_global_roots: ['/nonexistent-gsd-root-12345xyz'] } };
|
||||
assert.deepStrictEqual(loadTrustedGlobalRoots(config), [], 'non-existent root must be dropped');
|
||||
});
|
||||
|
||||
test('trusted root that is a symlink is canonicalized to the link target', () => {
|
||||
const realTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tgr-symtgt-'));
|
||||
const symlinkPath = path.join(os.tmpdir(), `gsd-tgr-symlink-${Date.now()}`);
|
||||
let symlinkCreated = false;
|
||||
try {
|
||||
try {
|
||||
fs.symlinkSync(realTarget, symlinkPath);
|
||||
symlinkCreated = true;
|
||||
} catch (err) {
|
||||
if (err.code === 'EPERM' || err.code === 'ENOSYS') {
|
||||
// symlinks not supported on this platform — skip
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const realResolved = fs.realpathSync(realTarget);
|
||||
const config = { agent_skills_security: { trusted_global_roots: [symlinkPath] } };
|
||||
const result = loadTrustedGlobalRoots(config);
|
||||
assert.deepStrictEqual(result, [realResolved], 'symlink root must be canonicalized to the link target');
|
||||
} finally {
|
||||
cleanup(realTarget);
|
||||
if (symlinkCreated) {
|
||||
try { fs.unlinkSync(symlinkPath); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('de-duplicates entries by canonical path', () => {
|
||||
// Both entries point to the same real dir — after canonicalization, only one is kept
|
||||
const realDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tgr-dedup-'));
|
||||
try {
|
||||
const realPath = fs.realpathSync(realDir);
|
||||
const config = { agent_skills_security: { trusted_global_roots: [realDir, realDir, realPath] } };
|
||||
assert.deepStrictEqual(loadTrustedGlobalRoots(config), [realPath]);
|
||||
} finally {
|
||||
cleanup(realDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('expands ~/ before absolute check — non-existent ~/x is dropped after expansion', () => {
|
||||
// ~/x becomes an absolute path after expansion, but if ~/x does not exist it is
|
||||
// dropped by the realpathSync guard (non-existent root is not trustworthy).
|
||||
const expandedX = path.join(os.homedir(), 'x-gsd-nonexistent-12345');
|
||||
// Ensure it really doesn't exist
|
||||
if (fs.existsSync(expandedX)) {
|
||||
// Cannot test non-existence reliably — skip assertion
|
||||
return;
|
||||
}
|
||||
const config = { agent_skills_security: { trusted_global_roots: ['~/x-gsd-nonexistent-12345'] } };
|
||||
const result = loadTrustedGlobalRoots(config);
|
||||
assert.deepStrictEqual(result, [], 'non-existent ~/x must be dropped after expansion');
|
||||
});
|
||||
|
||||
test('expands bare ~ to os.homedir()', () => {
|
||||
// Bare ~ (exactly) must expand to homedir — mirrors runtime-homes.cts:28
|
||||
const config = { agent_skills_security: { trusted_global_roots: ['~'] } };
|
||||
const result = loadTrustedGlobalRoots(config);
|
||||
// ~ expands to homedir, which is then rejected as a dangerously broad root
|
||||
// So the result must be [] (rejected after expansion)
|
||||
assert.deepStrictEqual(result, [], 'bare ~ expands to homedir and is then rejected as too broad');
|
||||
});
|
||||
|
||||
test('rejects filesystem root /', () => {
|
||||
const config = { agent_skills_security: { trusted_global_roots: ['/'] } };
|
||||
assert.deepStrictEqual(loadTrustedGlobalRoots(config), [], 'filesystem root must be rejected');
|
||||
});
|
||||
|
||||
test('rejects os.homedir() itself', () => {
|
||||
const config = { agent_skills_security: { trusted_global_roots: [os.homedir()] } };
|
||||
assert.deepStrictEqual(loadTrustedGlobalRoots(config), [], 'homedir itself must be rejected as too broad');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── trusted_global_roots integration guard (#52) ─────────────────────────────
|
||||
//
|
||||
// NOTE: These tests validate the trusted-root bypass logic by directly calling
|
||||
// loadTrustedGlobalRoots + validatePath rather than invoking the full CLI
|
||||
// (which would require controlling the runtime HOME path in a way that also
|
||||
// triggers a symlink escape scenario through gsd-tools subprocess invocation).
|
||||
// Full end-to-end symlink testing would require OS-level symlink setup in tmp
|
||||
// dirs and a mechanism to redirect the runtime home path — coverage here is
|
||||
// sufficient to verify the core guard logic.
|
||||
|
||||
describe('trusted_global_roots guard logic', () => {
|
||||
let tmpDir;
|
||||
let externalDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-52-trusted-'));
|
||||
externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-52-external-'));
|
||||
// Create a skill file in externalDir
|
||||
fs.writeFileSync(path.join(externalDir, 'SKILL.md'), '# External\n');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
cleanup(externalDir);
|
||||
});
|
||||
|
||||
test('validatePath rejects skill outside globalSkillsBase (baseline — no trusted roots)', () => {
|
||||
const skillMd = path.join(externalDir, 'SKILL.md');
|
||||
const result = validatePath(skillMd, tmpDir, { allowAbsolute: true });
|
||||
assert.ok(!result.safe, 'skill outside base must be rejected by validatePath');
|
||||
});
|
||||
|
||||
test('with trusted root matching real target dir — validatePath accepts', () => {
|
||||
// Simulate the trusted-root fallback: skill is outside base but inside trusted root
|
||||
const skillMd = path.join(externalDir, 'SKILL.md');
|
||||
const baseCheck = validatePath(skillMd, tmpDir, { allowAbsolute: true });
|
||||
assert.ok(!baseCheck.safe, 'base check must fail (prerequisite)');
|
||||
|
||||
// Trusted root fallback: check against externalDir
|
||||
const config = { agent_skills_security: { trusted_global_roots: [externalDir] } };
|
||||
const trustedRoots = loadTrustedGlobalRoots(config);
|
||||
const acceptedViaTrustedRoot = trustedRoots.some((root) => {
|
||||
const rootCheck = validatePath(skillMd, root, { allowAbsolute: true });
|
||||
return rootCheck.safe;
|
||||
});
|
||||
assert.ok(acceptedViaTrustedRoot, 'skill must be accepted when within a trusted root');
|
||||
});
|
||||
|
||||
test('with unrelated trusted root — skill still rejected', () => {
|
||||
const skillMd = path.join(externalDir, 'SKILL.md');
|
||||
const unrelatedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-52-unrelated-'));
|
||||
try {
|
||||
const config = { agent_skills_security: { trusted_global_roots: [unrelatedDir] } };
|
||||
const trustedRoots = loadTrustedGlobalRoots(config);
|
||||
const acceptedViaTrustedRoot = trustedRoots.some((root) => {
|
||||
const rootCheck = validatePath(skillMd, root, { allowAbsolute: true });
|
||||
return rootCheck.safe;
|
||||
});
|
||||
assert.ok(!acceptedViaTrustedRoot, 'skill must still be rejected when trusted root is unrelated');
|
||||
} finally {
|
||||
cleanup(unrelatedDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('with empty trusted_global_roots array — skill still rejected (byte-identical to today)', () => {
|
||||
const skillMd = path.join(externalDir, 'SKILL.md');
|
||||
const config = { agent_skills_security: { trusted_global_roots: [] } };
|
||||
const trustedRoots = loadTrustedGlobalRoots(config);
|
||||
assert.strictEqual(trustedRoots.length, 0, 'no roots loaded');
|
||||
const acceptedViaTrustedRoot = trustedRoots.some((root) => {
|
||||
const rootCheck = validatePath(skillMd, root, { allowAbsolute: true });
|
||||
return rootCheck.safe;
|
||||
});
|
||||
assert.ok(!acceptedViaTrustedRoot, 'skill must be rejected when trusted roots is empty');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── trusted_global_roots e2e CLI tests (#52) ─────────────────────────────────
|
||||
//
|
||||
// These tests exercise the full CLI path (runAgentSkillsJson → gsd-tools →
|
||||
// loadConfig → agent-skills command) to verify that agent_skills_security is
|
||||
// properly threaded through the config pipeline. Symlinks are created so a
|
||||
// global: skill's realpath escapes the ~/.claude/skills/ base, requiring a
|
||||
// trusted root to be accepted.
|
||||
|
||||
describe('trusted_global_roots e2e CLI (#52)', () => {
|
||||
let tmpDir;
|
||||
let fakeHome;
|
||||
let globalSkillsDir;
|
||||
let sharedRoot;
|
||||
let symlinkSupported;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-52-e2e-home-'));
|
||||
globalSkillsDir = path.join(fakeHome, '.claude', 'skills');
|
||||
fs.mkdirSync(globalSkillsDir, { recursive: true });
|
||||
sharedRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-52-e2e-shared-'));
|
||||
|
||||
// Create the shared skill directory OUTSIDE fakeHome
|
||||
const sharedSkillDir = path.join(sharedRoot, 'shared-skill');
|
||||
fs.mkdirSync(sharedSkillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(sharedSkillDir, 'SKILL.md'), '# Shared Skill\nContent from shared root.\n');
|
||||
|
||||
// Attempt to create a symlink inside globalSkillsDir pointing to the shared skill
|
||||
symlinkSupported = true;
|
||||
try {
|
||||
fs.symlinkSync(sharedSkillDir, path.join(globalSkillsDir, 'shared-skill'));
|
||||
} catch (err) {
|
||||
if (err.code === 'EPERM' || err.code === 'ENOSYS') {
|
||||
symlinkSupported = false;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
cleanup(fakeHome);
|
||||
cleanup(sharedRoot);
|
||||
});
|
||||
|
||||
test('REGRESSION: symlinked-escape skill with NO agent_skills_security in config → block is empty', (t) => {
|
||||
if (!symlinkSupported) {
|
||||
t.skip('symlinks not supported on this platform');
|
||||
return;
|
||||
}
|
||||
// No agent_skills_security in config — symlink escape must be blocked
|
||||
writeConfig(tmpDir, {
|
||||
runtime: 'claude',
|
||||
agent_skills: { 'gsd-executor': ['global:shared-skill'] },
|
||||
});
|
||||
|
||||
const r = runAgentSkillsJson(
|
||||
['agent-skills', 'gsd-executor'], tmpDir, { HOME: fakeHome, USERPROFILE: fakeHome }
|
||||
);
|
||||
assert.ok(r.success, `Command failed: ${r.error}`);
|
||||
assert.strictEqual(r.ir.block, '', 'block must be empty when symlink escapes base and no trusted root configured');
|
||||
});
|
||||
|
||||
test('FEATURE: symlink escape with matching trusted_global_roots → block includes skill', (t) => {
|
||||
if (!symlinkSupported) {
|
||||
t.skip('symlinks not supported on this platform');
|
||||
return;
|
||||
}
|
||||
// Configure the sharedRoot as a trusted global root
|
||||
writeConfig(tmpDir, {
|
||||
runtime: 'claude',
|
||||
agent_skills: { 'gsd-executor': ['global:shared-skill'] },
|
||||
agent_skills_security: { trusted_global_roots: [sharedRoot] },
|
||||
});
|
||||
|
||||
const r = runAgentSkillsJson(
|
||||
['agent-skills', 'gsd-executor'], tmpDir, { HOME: fakeHome, USERPROFILE: fakeHome }
|
||||
);
|
||||
assert.ok(r.success, `Command failed: ${r.error}`);
|
||||
assert.ok(r.ir.block.includes('<agent_skills>'), `block must contain <agent_skills> tag, got: ${r.ir.block}`);
|
||||
assert.ok(r.ir.block.includes('shared-skill/SKILL.md'), `block must include the shared skill, got: ${r.ir.block}`);
|
||||
assert.ok(r.ir.skills_count >= 1, 'skills_count must be at least 1');
|
||||
});
|
||||
|
||||
test('FEATURE NOTE: accepted-via-trusted-root emits NOTE on stderr', (t) => {
|
||||
if (!symlinkSupported) {
|
||||
t.skip('symlinks not supported on this platform');
|
||||
return;
|
||||
}
|
||||
// Capture stderr using spawnSync (runGsdTools only captures stderr on failure)
|
||||
writeConfig(tmpDir, {
|
||||
runtime: 'claude',
|
||||
agent_skills: { 'gsd-executor': ['global:shared-skill'] },
|
||||
agent_skills_security: { trusted_global_roots: [sharedRoot] },
|
||||
});
|
||||
|
||||
const r = runGsdToolsWithStderr(
|
||||
['agent-skills', '--json', 'gsd-executor'],
|
||||
tmpDir,
|
||||
{ HOME: fakeHome, USERPROFILE: fakeHome }
|
||||
);
|
||||
assert.ok(r.success, `Command failed (exit ${r.exitCode}): ${r.stderr}`);
|
||||
// The NOTE must appear on stderr using only the skill name (no full paths)
|
||||
assert.ok(
|
||||
r.stderr.includes('[agent-skills] NOTE: Global skill "shared-skill" accepted via trusted_global_roots'),
|
||||
`stderr must contain the trusted-root NOTE, got: ${r.stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('NEGATIVE: symlink escape with unrelated trusted root (existing dir) → block is empty', (t) => {
|
||||
if (!symlinkSupported) {
|
||||
t.skip('symlinks not supported on this platform');
|
||||
return;
|
||||
}
|
||||
// The unrelated dir MUST exist so it isn't dropped for the wrong reason (non-existence).
|
||||
// Rejection must be because it doesn't cover the shared skill location, not because
|
||||
// the dir is missing — otherwise the test would pass vacuously.
|
||||
const unrelatedRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-52-e2e-unrelated-'));
|
||||
// Verify the dir actually exists so the trusted root is loaded (not silently dropped)
|
||||
assert.ok(fs.existsSync(unrelatedRoot), 'unrelated root must exist so it enters the trusted roots list');
|
||||
try {
|
||||
writeConfig(tmpDir, {
|
||||
runtime: 'claude',
|
||||
agent_skills: { 'gsd-executor': ['global:shared-skill'] },
|
||||
agent_skills_security: { trusted_global_roots: [unrelatedRoot] },
|
||||
});
|
||||
|
||||
const r = runAgentSkillsJson(
|
||||
['agent-skills', 'gsd-executor'], tmpDir, { HOME: fakeHome, USERPROFILE: fakeHome }
|
||||
);
|
||||
assert.ok(r.success, `Command failed: ${r.error}`);
|
||||
assert.strictEqual(r.ir.block, '', 'block must be empty when trusted root does not cover the shared skill location');
|
||||
} finally {
|
||||
cleanup(unrelatedRoot);
|
||||
}
|
||||
});
|
||||
|
||||
test('HARDENING: trusted_global_roots: ["/"] → block is empty (broad root rejected)', (t) => {
|
||||
if (!symlinkSupported) {
|
||||
t.skip('symlinks not supported on this platform');
|
||||
return;
|
||||
}
|
||||
writeConfig(tmpDir, {
|
||||
runtime: 'claude',
|
||||
agent_skills: { 'gsd-executor': ['global:shared-skill'] },
|
||||
agent_skills_security: { trusted_global_roots: ['/'] },
|
||||
});
|
||||
|
||||
const r = runAgentSkillsJson(
|
||||
['agent-skills', 'gsd-executor'], tmpDir, { HOME: fakeHome, USERPROFILE: fakeHome }
|
||||
);
|
||||
assert.ok(r.success, `Command failed: ${r.error}`);
|
||||
assert.strictEqual(r.ir.block, '', 'block must be empty when "/" is the trusted root (rejected as too broad)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,10 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { convertClaudeCommandToCodexSkill } = require('../bin/install.js');
|
||||
const {
|
||||
convertClaudeCommandToCodexSkill,
|
||||
convertSlashCommandsToCodexSkillMentions,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
// The canonical launcher snippet path that was being corrupted
|
||||
const RUNTIME_ROOT_PATH = '${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}';
|
||||
@@ -148,11 +151,10 @@ describe('#704 — Codex global install launcher path corruption', () => {
|
||||
// Walk gsd-core/workflows/ and assert that no file produces $gsd-core
|
||||
// inside a shell variable expansion context after Codex conversion.
|
||||
//
|
||||
// NOTE: The regex `/(?<![a-zA-Z0-9./}])\/gsd-/` still converts backtick-
|
||||
// wrapped prose paths like `\`/gsd-core/workflows/update.md\`` (a pre-existing
|
||||
// issue separate from #704 — update.md's backtick is not in the lookbehind
|
||||
// set). That prose-path case is intentionally excluded from this assertion
|
||||
// (tracked separately; the primary #704 bug is the shell-variable expansion).
|
||||
// NOTE: The backtick-wrapped prose-path case (`/gsd-core/workflows/update.md`)
|
||||
// was a pre-existing gap with the #704 lookbehind fix and is now addressed by
|
||||
// the positive-boundary regex introduced in #712. That case is covered by the
|
||||
// "#712" describe block below.
|
||||
//
|
||||
// We probe for the specific shell-context pattern from the issue report:
|
||||
// BAD: ${_GSD_RUNTIME_ROOT}$gsd-core/bin/
|
||||
@@ -235,3 +237,182 @@ describe('#704 — Codex global install launcher path corruption', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#712: positive-boundary slash-command conversion', () => {
|
||||
// Tests call convertSlashCommandsToCodexSkillMentions directly so the regex
|
||||
// is exercised in isolation — no frontmatter wrapping, no ADAPTER_CLOSE
|
||||
// stripping, no .claude→.codex rewrite masking the result.
|
||||
|
||||
// ── MUST-NOT-CONVERT (negative) cases ─────────────────────────────────────
|
||||
// These inputs must be returned UNCHANGED — no $gsd-* substitution.
|
||||
|
||||
test('backtick-wrapped path: `/gsd-core/workflows/update.md` is NOT converted (THE new fix)', () => {
|
||||
const input = 'See `/gsd-core/workflows/update.md` for details.';
|
||||
const result = convertSlashCommandsToCodexSkillMentions(input);
|
||||
assert.strictEqual(
|
||||
result,
|
||||
input,
|
||||
`Expected backtick-wrapped path to be unchanged. Got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('backtick-wrapped path deeper: `/gsd-pi/bin/foo.cjs` is NOT converted', () => {
|
||||
const input = 'Run `/gsd-pi/bin/foo.cjs` directly.';
|
||||
const result = convertSlashCommandsToCodexSkillMentions(input);
|
||||
assert.strictEqual(
|
||||
result,
|
||||
input,
|
||||
`Expected deep backtick-wrapped path to be unchanged. Got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('shell var expansion: ${_GSD_RUNTIME_ROOT}/gsd-core/bin/x is NOT converted (regression guard)', () => {
|
||||
const input = 'PATH="${_GSD_RUNTIME_ROOT}/gsd-core/bin/x"';
|
||||
const result = convertSlashCommandsToCodexSkillMentions(input);
|
||||
assert.ok(
|
||||
!result.includes('$gsd-core'),
|
||||
`Expected no $gsd-core substitution in shell var path. Got: ${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.includes('/gsd-core/bin/x'),
|
||||
`Expected original path to be preserved. Got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('command substitution: $(expand_home ~/.claude)/gsd-local-patches is NOT converted (regression guard)', () => {
|
||||
const input = 'candidate="$(expand_home ~/.claude)/gsd-local-patches"';
|
||||
const result = convertSlashCommandsToCodexSkillMentions(input);
|
||||
assert.ok(
|
||||
!result.includes(')$gsd-local'),
|
||||
`Expected no )$gsd-local substitution. Got: ${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.includes(')/gsd-local-patches'),
|
||||
`Expected original path to be preserved. Got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('plain path segment: bin/gsd-tools.cjs is NOT converted', () => {
|
||||
const input = 'node bin/gsd-tools.cjs --help';
|
||||
const result = convertSlashCommandsToCodexSkillMentions(input);
|
||||
assert.strictEqual(
|
||||
result,
|
||||
input,
|
||||
`Expected plain path segment to be unchanged. Got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('plain path segment: .claude/gsd-core/agents — /gsd-core portion is NOT slash-command converted', () => {
|
||||
// Tests the regex in isolation: the .claude→.codex path rewrite that happens
|
||||
// inside convertClaudeToCodexMarkdown does NOT run here. We assert directly
|
||||
// that the slash-command regex leaves /gsd-core after the slash intact —
|
||||
// i.e. the `e` in `/gsd-core` is NOT treated as a command boundary.
|
||||
const input = 'Look in .claude/gsd-core/agents for the agent files.';
|
||||
const result = convertSlashCommandsToCodexSkillMentions(input);
|
||||
assert.ok(
|
||||
!result.includes('$gsd-core'),
|
||||
`Expected no $gsd-core substitution in .claude/gsd-core path. Got: ${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.includes('/gsd-core/agents'),
|
||||
`Expected /gsd-core/agents to remain as a path segment. Got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── MUST-CONVERT (positive) cases ─────────────────────────────────────────
|
||||
// These inputs contain legitimate /gsd-<cmd> mentions that MUST be converted.
|
||||
|
||||
test('space-preceded prose: Use /gsd-discuss-phase to start. → $gsd-discuss-phase', () => {
|
||||
const input = 'Use /gsd-discuss-phase to start.';
|
||||
const result = convertSlashCommandsToCodexSkillMentions(input);
|
||||
assert.ok(
|
||||
result.includes('$gsd-discuss-phase'),
|
||||
`Expected /gsd-discuss-phase to be converted. Got: ${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('/gsd-discuss-phase'),
|
||||
`Expected original /gsd-discuss-phase to be replaced. Got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('backtick-WRAPPED MENTION (single segment): Run `/gsd-execute-phase` now → `$gsd-execute-phase`', () => {
|
||||
// A backtick-wrapped COMMAND (single segment, no path continuation) MUST
|
||||
// still be converted — this guards against a naive whitespace-only fix.
|
||||
const input = 'Run `/gsd-execute-phase` now.';
|
||||
const result = convertSlashCommandsToCodexSkillMentions(input);
|
||||
assert.ok(
|
||||
result.includes('`$gsd-execute-phase`'),
|
||||
`Expected backtick-wrapped command to be converted to \`$gsd-execute-phase\`. Got: ${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('`/gsd-execute-phase`'),
|
||||
`Expected original \`/gsd-execute-phase\` to be replaced. Got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('parenthetical/backtick list like CONTEXT.md:59: (`/gsd-plan-phase`, `/gsd-progress`) → converted', () => {
|
||||
const input = 'Available commands: (`/gsd-plan-phase`, `/gsd-progress`) — pick one.';
|
||||
const result = convertSlashCommandsToCodexSkillMentions(input);
|
||||
assert.ok(
|
||||
result.includes('`$gsd-plan-phase`'),
|
||||
`Expected /gsd-plan-phase to be converted. Got: ${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.includes('`$gsd-progress`'),
|
||||
`Expected /gsd-progress to be converted. Got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('start-of-string: /gsd-manager runs → $gsd-manager runs (exercises the ^ branch of lookbehind)', () => {
|
||||
// This case is IMPOSSIBLE to test through the frontmatter-wrapping pipeline
|
||||
// (the body always has preceding chars). Direct call exercises the ^ branch.
|
||||
const input = '/gsd-manager runs the pipeline.';
|
||||
const result = convertSlashCommandsToCodexSkillMentions(input);
|
||||
assert.ok(
|
||||
result.includes('$gsd-manager'),
|
||||
`Expected /gsd-manager to be converted. Got: ${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('/gsd-manager'),
|
||||
`Expected original /gsd-manager to be replaced. Got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('double-quote wrapped: "/gsd-resume" → "$gsd-resume"', () => {
|
||||
const input = 'Call "/gsd-resume" to continue.';
|
||||
const result = convertSlashCommandsToCodexSkillMentions(input);
|
||||
assert.ok(
|
||||
result.includes('"$gsd-resume"'),
|
||||
`Expected "/gsd-resume" to be converted to "$gsd-resume". Got: ${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('"/gsd-resume"'),
|
||||
`Expected original "/gsd-resume" to be replaced. Got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── End-to-end: headline #712 bug through the real install pipeline ────────
|
||||
|
||||
test('end-to-end: backtick-wrapped path `/gsd-core/workflows/update.md` survives full Codex install pipeline', () => {
|
||||
// Uses convertClaudeCommandToCodexSkill (same pattern as #704 tests above)
|
||||
// to prove the real install path does not corrupt prose references to repo paths.
|
||||
const input = [
|
||||
'---',
|
||||
'description: Test',
|
||||
'---',
|
||||
'',
|
||||
'See `/gsd-core/workflows/update.md` for the update workflow.',
|
||||
].join('\n');
|
||||
|
||||
const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-712-e2e');
|
||||
|
||||
assert.ok(
|
||||
!output.includes('$gsd-core'),
|
||||
`Expected no $gsd-core in converted output. Got:\n${output}`,
|
||||
);
|
||||
assert.ok(
|
||||
output.includes('/gsd-core/workflows/update.md'),
|
||||
`Expected backtick-wrapped path to survive conversion. Got:\n${output}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
275
tests/bug-730-milestone-phase-details-scope.test.cjs
Normal file
275
tests/bug-730-milestone-phase-details-scope.test.cjs
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Regression test for bug #730: phase details defined under a milestone-scoped
|
||||
* "## Milestone vX.Y — … (Phase Details)" section are invisible to phase
|
||||
* resolution (getRoadmapPhaseInternal / init phase-op) when the flat shared
|
||||
* "## Phase Details" section for an earlier milestone sits between the shared
|
||||
* ## Phases checklist and the per-milestone Phase Details section.
|
||||
*
|
||||
* The bug manifests ONLY before any .planning/phases/ directory exists because
|
||||
* findPhaseInternal masks it once the dir is created. RED step — tests 1 and 3
|
||||
* are expected to fail against current code.
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { runGsdTools, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixture content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STATE_CONTENT = `---
|
||||
milestone: v1.1
|
||||
---
|
||||
`;
|
||||
|
||||
const ROADMAP_CONTENT = `# Roadmap: Example
|
||||
|
||||
## Phases
|
||||
|
||||
- [x] **Phase 1: Setup** — initial scaffold
|
||||
|
||||
### Milestone v1.1 — Second milestone (added 2026-01-01)
|
||||
|
||||
- [ ] **Phase 2: Feature** — the new thing
|
||||
|
||||
## Phase Details
|
||||
|
||||
### Phase 1: Setup
|
||||
**Goal:** scaffold the app.
|
||||
|
||||
## Milestone v1.1 — Second milestone (Phase Details)
|
||||
|
||||
### Phase 2: Feature
|
||||
**Goal:** build the new thing.
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: create a bare project with .planning/ but NO .planning/phases/ dir
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createBareProject() {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-'));
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suite
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bug #730 — milestone (Phase Details) section scope resolution', () => {
|
||||
let dir;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = createBareProject();
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'STATE.md'), STATE_CONTENT, 'utf-8');
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), ROADMAP_CONTENT, 'utf-8');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(dir);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 1 (AC1): init phase-op resolves phase defined only under its
|
||||
// per-milestone "(Phase Details)" section
|
||||
// -------------------------------------------------------------------------
|
||||
test('init phase-op resolves a current-milestone phase defined only under its (Phase Details) section', () => {
|
||||
const r = runGsdTools('init phase-op 2', dir);
|
||||
assert.ok(r.success, `init phase-op 2 failed: ${r.error}`);
|
||||
|
||||
const out = JSON.parse(r.output);
|
||||
assert.strictEqual(out.phase_found, true, `phase_found should be true; got phase_found=${out.phase_found}, expected_phase_dir=${out.expected_phase_dir}`);
|
||||
assert.strictEqual(out.phase_name, 'Feature', `phase_name should be 'Feature'; got '${out.phase_name}'`);
|
||||
assert.strictEqual(out.padded_phase, '02', `padded_phase should be '02'; got '${out.padded_phase}'`);
|
||||
assert.strictEqual(out.expected_phase_dir, '.planning/phases/02-feature', `expected_phase_dir should be '.planning/phases/02-feature'; got '${out.expected_phase_dir}'`);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 2 (AC4): first-milestone phase still resolves via the flat
|
||||
// "## Phase Details" section — no regression
|
||||
// -------------------------------------------------------------------------
|
||||
test('init phase-op still resolves a first-milestone phase (no regression on flat Phase Details)', () => {
|
||||
const r = runGsdTools('init phase-op 1', dir);
|
||||
assert.ok(r.success, `init phase-op 1 failed: ${r.error}`);
|
||||
|
||||
const out = JSON.parse(r.output);
|
||||
assert.strictEqual(out.phase_found, true, `phase_found should be true for phase 1; got ${out.phase_found}`);
|
||||
assert.strictEqual(out.phase_name, 'Setup', `phase_name should be 'Setup'; got '${out.phase_name}'`);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 3 (AC5): getRoadmapPhaseInternal resolves the current-milestone phase
|
||||
// directly before any phases/ dir exists
|
||||
// -------------------------------------------------------------------------
|
||||
test('getRoadmapPhaseInternal resolves the current-milestone phase directly before any dir exists', () => {
|
||||
const core = require('../gsd-core/bin/lib/core.cjs');
|
||||
|
||||
const res = core.getRoadmapPhaseInternal(dir, '2');
|
||||
assert.ok(res !== null && res !== undefined, `getRoadmapPhaseInternal returned null/undefined for phase 2`);
|
||||
assert.strictEqual(res.found, true, `res.found should be true; got ${JSON.stringify(res)}`);
|
||||
assert.strictEqual(res.phase_name, 'Feature', `res.phase_name should be 'Feature'; got '${res.phase_name}'`);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 4 (AC3): validate health raises W006 for a current-milestone phase
|
||||
// defined under (Phase Details) with no directory on disk.
|
||||
//
|
||||
// Before the fix, extractCurrentMilestone's slice stopped before the
|
||||
// "## Milestone v1.1 — … (Phase Details)" section, so phase 2's
|
||||
// "### Phase 2: Feature" header was invisible and W006 was never raised.
|
||||
// After the fix the slice includes that section and W006 is emitted.
|
||||
//
|
||||
// This test uses its OWN local fixture (separate tmpdir) so it does not
|
||||
// disturb the shared beforeEach/afterEach fixture used by tests 1–3.
|
||||
// -------------------------------------------------------------------------
|
||||
test('validate health raises W006 for a started current-milestone phase defined under (Phase Details) with no directory', () => {
|
||||
const localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-t4-'));
|
||||
try {
|
||||
const planning = path.join(localDir, '.planning');
|
||||
fs.mkdirSync(planning, { recursive: true });
|
||||
|
||||
// STATE.md — milestone: v1.1
|
||||
fs.writeFileSync(
|
||||
path.join(planning, 'STATE.md'),
|
||||
`---\nmilestone: v1.1\n---\n`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
// ROADMAP.md — phase 2 is [x] (started/complete) so the not-started
|
||||
// guard does NOT suppress W006. Phase 2's details live exclusively in
|
||||
// the per-milestone "(Phase Details)" section (the blind-spot pre-fix).
|
||||
fs.writeFileSync(
|
||||
path.join(planning, 'ROADMAP.md'),
|
||||
`# Roadmap: Example\n\n## Phases\n\n- [x] **Phase 1: Setup** — initial scaffold\n\n### Milestone v1.1 — Second milestone (added 2026-01-01)\n\n- [x] **Phase 2: Feature** — the new thing\n\n## Phase Details\n\n### Phase 1: Setup\n**Goal:** scaffold the app.\n\n## Milestone v1.1 — Second milestone (Phase Details)\n\n### Phase 2: Feature\n**Goal:** build the new thing.\n`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
// Create the phase 1 directory so phase 1 does NOT trigger W006.
|
||||
// Phase 2 has NO directory — that's the missing-dir condition under test.
|
||||
fs.mkdirSync(path.join(planning, 'phases', '01-setup'), { recursive: true });
|
||||
|
||||
const result = runGsdTools(['validate', 'health'], localDir);
|
||||
const payload = JSON.parse(result.output);
|
||||
const warnings = payload.warnings || [];
|
||||
|
||||
// Find a W006 entry whose message references phase 2 (by number or name).
|
||||
const w006ForPhase2 = warnings.find(
|
||||
(w) =>
|
||||
w.code === 'W006' &&
|
||||
(/\b2\b/.test(w.message) || /\b02\b/.test(w.message) || /Feature/i.test(w.message)),
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
w006ForPhase2 != null,
|
||||
`Expected a W006 warning referencing phase 2 (Feature) — phase 2 is started ([x]) and has no directory on disk, ` +
|
||||
`but its ### Phase 2: header lives in the Milestone v1.1 (Phase Details) section which was invisible before the fix. ` +
|
||||
`Got warnings: ${JSON.stringify(warnings)}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(localDir);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 5: three-milestone roadmap, current = latest (v1.2)
|
||||
// -------------------------------------------------------------------------
|
||||
test('init phase-op resolves the latest milestone phase in a 3-milestone roadmap', () => {
|
||||
const localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-t5-'));
|
||||
try {
|
||||
const planning = path.join(localDir, '.planning');
|
||||
fs.mkdirSync(planning, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(planning, 'STATE.md'),
|
||||
`---\nmilestone: v1.2\n---\n`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(planning, 'ROADMAP.md'),
|
||||
`# Roadmap: Example\n\n## Phases\n\n- [x] **Phase 1: Setup** — done\n\n### Milestone v1.1 — Second (added 2026-01-01)\n\n- [x] **Phase 2: Feature** — done\n\n### Milestone v1.2 — Third (added 2026-02-01)\n\n- [ ] **Phase 3: Polish** — current\n\n## Phase Details\n\n### Phase 1: Setup\n**Goal:** scaffold.\n\n## Milestone v1.1 — Second (Phase Details)\n\n### Phase 2: Feature\n**Goal:** build.\n\n## Milestone v1.2 — Third (Phase Details)\n\n### Phase 3: Polish\n**Goal:** refine.\n`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const r = runGsdTools('init phase-op 3', localDir);
|
||||
assert.ok(r.success, `init phase-op 3 failed: ${r.error}`);
|
||||
|
||||
const out = JSON.parse(r.output);
|
||||
assert.strictEqual(out.phase_found, true, `phase_found should be true; got phase_found=${out.phase_found}`);
|
||||
assert.strictEqual(out.phase_name, 'Polish', `phase_name should be 'Polish'; got '${out.phase_name}'`);
|
||||
assert.strictEqual(out.padded_phase, '03', `padded_phase should be '03'; got '${out.padded_phase}'`);
|
||||
assert.strictEqual(out.expected_phase_dir, '.planning/phases/03-polish', `expected_phase_dir should be '.planning/phases/03-polish'; got '${out.expected_phase_dir}'`);
|
||||
} finally {
|
||||
cleanup(localDir);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 6: sub-milestone sharing a version prefix — closed sibling must NOT
|
||||
// cross-pollinate into the active milestone's Phase Details lookup (#730)
|
||||
// -------------------------------------------------------------------------
|
||||
test('init phase-op anchors Phase Details to the selected sub-milestone, not a closed same-prefix sibling', () => {
|
||||
const localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-t6-'));
|
||||
try {
|
||||
const planning = path.join(localDir, '.planning');
|
||||
fs.mkdirSync(planning, { recursive: true });
|
||||
|
||||
// STATE.md — milestone: v3.0 (matches v3.0-B active slice)
|
||||
fs.writeFileSync(
|
||||
path.join(planning, 'STATE.md'),
|
||||
`---\nmilestone: v3.0\n---\n`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
// ROADMAP.md — v3.0-A is SHIPPED (closed), v3.0-B is active.
|
||||
// The Phase Details for v3.0-A comes FIRST — without version-boundary
|
||||
// anchoring the old code would grab it (first non-closed (Phase Details)
|
||||
// heading outside the window), returning phase_name='Alpha' instead of 'Beta'.
|
||||
fs.writeFileSync(
|
||||
path.join(planning, 'ROADMAP.md'),
|
||||
[
|
||||
'# Roadmap: Example',
|
||||
'',
|
||||
'## Phases',
|
||||
'',
|
||||
'### Milestone v3.0-A — First slice (added 2026-01-01) ✅ SHIPPED',
|
||||
'',
|
||||
'- [x] **Phase 1: Alpha** — done',
|
||||
'',
|
||||
'### Milestone v3.0-B — Second slice (added 2026-02-01)',
|
||||
'',
|
||||
'- [ ] **Phase 2: Beta** — current',
|
||||
'',
|
||||
'## Phase Details',
|
||||
'',
|
||||
'## Milestone v3.0-A — First slice (Phase Details)',
|
||||
'',
|
||||
'### Phase 1: Alpha',
|
||||
'**Goal:** alpha goal.',
|
||||
'',
|
||||
'## Milestone v3.0-B — Second slice (Phase Details)',
|
||||
'',
|
||||
'### Phase 2: Beta',
|
||||
'**Goal:** beta goal.',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const r = runGsdTools('init phase-op 2', localDir);
|
||||
assert.ok(r.success, `init phase-op 2 failed: ${r.error}`);
|
||||
|
||||
const out = JSON.parse(r.output);
|
||||
assert.strictEqual(out.phase_found, true, `phase_found should be true; got phase_found=${out.phase_found}, output=${JSON.stringify(out)}`);
|
||||
assert.strictEqual(out.phase_name, 'Beta', `phase_name should be 'Beta' (v3.0-B section), not '${out.phase_name}' (would indicate v3.0-A cross-pollination)`);
|
||||
assert.strictEqual(out.expected_phase_dir, '.planning/phases/02-beta', `expected_phase_dir should be '.planning/phases/02-beta'; got '${out.expected_phase_dir}'`);
|
||||
} finally {
|
||||
cleanup(localDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1158,6 +1158,127 @@ describe('config-set prototype-pollution guard (#663)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── config-set prototype-pollution guard via dynamic-key prefixes (alert #26) ─
|
||||
|
||||
describe('config-set prototype-pollution guard via dynamic-key prefixes (alert #26)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
// Initialise config so there is a config.json to write to.
|
||||
runGsdTools('config-ensure-section', tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('agent_skills.__proto__ is blocked by setConfigValue guard (not schema gate)', () => {
|
||||
const result = runGsdTools('config-set agent_skills.__proto__ somevalue', tmpDir);
|
||||
|
||||
assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`);
|
||||
|
||||
// Must be the pollution guard, not the schema gate.
|
||||
assert.ok(
|
||||
result.error.includes('prototype pollution guard'),
|
||||
`Expected "prototype pollution guard" in error, got: ${result.error}`,
|
||||
);
|
||||
// No schema-gate message.
|
||||
assert.ok(
|
||||
!result.error.includes('Unknown config key'),
|
||||
`Should not hit schema gate, got: ${result.error}`,
|
||||
);
|
||||
|
||||
// No prototype pollution occurred.
|
||||
assert.strictEqual(({}).somevalue, undefined, 'agent_skills.__proto__: {}.somevalue should be undefined');
|
||||
assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'somevalue'), false,
|
||||
'agent_skills.__proto__: Object.prototype should not gain "somevalue"');
|
||||
});
|
||||
|
||||
test('agent_skills.constructor is blocked by setConfigValue guard (not schema gate)', () => {
|
||||
const result = runGsdTools('config-set agent_skills.constructor somevalue', tmpDir);
|
||||
|
||||
assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`);
|
||||
|
||||
assert.ok(
|
||||
result.error.includes('prototype pollution guard'),
|
||||
`Expected "prototype pollution guard" in error, got: ${result.error}`,
|
||||
);
|
||||
assert.ok(
|
||||
!result.error.includes('Unknown config key'),
|
||||
`Should not hit schema gate, got: ${result.error}`,
|
||||
);
|
||||
|
||||
assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'somevalue'), false,
|
||||
'agent_skills.constructor: Object.prototype should not gain "somevalue"');
|
||||
});
|
||||
|
||||
test('agent_skills.prototype is blocked by setConfigValue guard (not schema gate)', () => {
|
||||
const result = runGsdTools('config-set agent_skills.prototype somevalue', tmpDir);
|
||||
|
||||
assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`);
|
||||
|
||||
assert.ok(
|
||||
result.error.includes('prototype pollution guard'),
|
||||
`Expected "prototype pollution guard" in error, got: ${result.error}`,
|
||||
);
|
||||
assert.ok(
|
||||
!result.error.includes('Unknown config key'),
|
||||
`Should not hit schema gate, got: ${result.error}`,
|
||||
);
|
||||
|
||||
assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'somevalue'), false,
|
||||
'agent_skills.prototype: Object.prototype should not gain "somevalue"');
|
||||
});
|
||||
|
||||
test('features.__proto__ is blocked by setConfigValue guard (not schema gate)', () => {
|
||||
const result = runGsdTools('config-set features.__proto__ somevalue', tmpDir);
|
||||
|
||||
assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`);
|
||||
|
||||
assert.ok(
|
||||
result.error.includes('prototype pollution guard'),
|
||||
`Expected "prototype pollution guard" in error, got: ${result.error}`,
|
||||
);
|
||||
assert.ok(
|
||||
!result.error.includes('Unknown config key'),
|
||||
`Should not hit schema gate, got: ${result.error}`,
|
||||
);
|
||||
|
||||
assert.strictEqual(({}).somevalue, undefined, 'features.__proto__: {}.somevalue should be undefined');
|
||||
assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'somevalue'), false,
|
||||
'features.__proto__: Object.prototype should not gain "somevalue"');
|
||||
});
|
||||
|
||||
test('review.models.constructor is blocked by setConfigValue guard (not schema gate)', () => {
|
||||
const result = runGsdTools('config-set review.models.constructor somevalue', tmpDir);
|
||||
|
||||
assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`);
|
||||
|
||||
assert.ok(
|
||||
result.error.includes('prototype pollution guard'),
|
||||
`Expected "prototype pollution guard" in error, got: ${result.error}`,
|
||||
);
|
||||
assert.ok(
|
||||
!result.error.includes('Unknown config key'),
|
||||
`Should not hit schema gate, got: ${result.error}`,
|
||||
);
|
||||
|
||||
assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'somevalue'), false,
|
||||
'review.models.constructor: Object.prototype should not gain "somevalue"');
|
||||
});
|
||||
|
||||
test('positive control: agent_skills.sonnet-coder with valid value succeeds', () => {
|
||||
const result = runGsdTools('config-set agent_skills.sonnet-coder true', tmpDir);
|
||||
|
||||
assert.ok(result.success, `Legitimate agent_skills key rejected unexpectedly: ${result.error}`);
|
||||
|
||||
const config = readConfig(tmpDir);
|
||||
assert.strictEqual(config.agent_skills['sonnet-coder'], true,
|
||||
'agent_skills.sonnet-coder should be written to config.json');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── plan_review.source_grounding + _authority (#22) ─────────────────────────
|
||||
|
||||
describe('plan_review.source_grounding and plan_review.source_grounding_authority (#22)', () => {
|
||||
|
||||
@@ -318,3 +318,193 @@ describe('#68 resolve-granularity command: CLI behavior', () => {
|
||||
assert.strictEqual(output.unknown_phase_type, undefined, 'known phase type must not have unknown_phase_type');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #703 CLI override: --granularity flag ────────────────────────────────────
|
||||
|
||||
describe('#703 resolveGranularityInternal: CLI override param (3rd arg)', () => {
|
||||
let projectDir;
|
||||
beforeEach(() => { projectDir = makeTmp('cli-override'); });
|
||||
afterEach(() => { cleanup(projectDir); });
|
||||
|
||||
test('override fine beats per-phase config granularities.planning=coarse', () => {
|
||||
writeConfig(projectDir, {
|
||||
granularity: 'standard',
|
||||
granularities: { planning: 'coarse' },
|
||||
});
|
||||
assert.equal(resolveGranularityInternal(projectDir, 'planning', 'fine'), 'fine',
|
||||
'CLI override must beat per-phase config');
|
||||
});
|
||||
|
||||
test('override coarse beats top-level granularity=fine', () => {
|
||||
writeConfig(projectDir, {
|
||||
granularity: 'fine',
|
||||
});
|
||||
assert.equal(resolveGranularityInternal(projectDir, 'execution', 'coarse'), 'coarse',
|
||||
'CLI override must beat top-level granularity');
|
||||
});
|
||||
|
||||
test('override standard beats planning.granularity=fine global fallback (regardless of phase type)', () => {
|
||||
writeConfig(projectDir, {
|
||||
planning: { granularity: 'fine' },
|
||||
});
|
||||
assert.equal(resolveGranularityInternal(projectDir, 'execution', 'standard'), 'standard',
|
||||
'CLI override must beat planning.granularity fallback');
|
||||
});
|
||||
|
||||
test("override '' (empty string) falls through to config chain", () => {
|
||||
writeConfig(projectDir, {
|
||||
granularity: 'coarse',
|
||||
});
|
||||
assert.equal(resolveGranularityInternal(projectDir, 'planning', ''), 'coarse',
|
||||
'empty-string override must fall through to config chain');
|
||||
});
|
||||
|
||||
test('override undefined falls through to config chain', () => {
|
||||
writeConfig(projectDir, {
|
||||
granularity: 'fine',
|
||||
});
|
||||
assert.equal(resolveGranularityInternal(projectDir, 'planning', undefined), 'fine',
|
||||
'undefined override must fall through to config chain');
|
||||
});
|
||||
|
||||
test('override null falls through to config chain', () => {
|
||||
writeConfig(projectDir, {
|
||||
granularity: 'coarse',
|
||||
});
|
||||
assert.equal(resolveGranularityInternal(projectDir, 'planning', null), 'coarse',
|
||||
'null override must fall through to config chain');
|
||||
});
|
||||
|
||||
test('invalid override value falls through to config chain (not rejected in resolver)', () => {
|
||||
writeConfig(projectDir, {
|
||||
granularity: 'standard',
|
||||
});
|
||||
// Invalid override reaches resolver → falls through (validation is CLI boundary's job)
|
||||
assert.equal(resolveGranularityInternal(projectDir, 'planning', 'ultra'), 'standard',
|
||||
'invalid override must fall through to config chain in resolver');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#703 cmdResolveGranularity: --granularity CLI flag', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { tmpDir = createTempProject(); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('--granularity fine overrides config chain via CLI tool', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ granularity: 'coarse' })
|
||||
);
|
||||
const result = runGsdTools('resolve-granularity planning --granularity fine', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.granularity, 'fine', '--granularity fine must override config coarse');
|
||||
});
|
||||
|
||||
test('--granularity coarse overrides per-phase granularities.planning=fine', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({
|
||||
granularity: 'standard',
|
||||
granularities: { planning: 'fine' },
|
||||
})
|
||||
);
|
||||
const result = runGsdTools('resolve-granularity planning --granularity coarse', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.granularity, 'coarse', '--granularity coarse must beat per-phase fine');
|
||||
});
|
||||
|
||||
test('invalid --granularity value exits with error', () => {
|
||||
const result = runGsdTools('resolve-granularity planning --granularity ultra', tmpDir);
|
||||
assert.ok(!result.success, 'should fail with invalid granularity');
|
||||
assert.ok(
|
||||
result.error.includes('ultra') || result.error.includes('invalid'),
|
||||
`error should mention invalid value; got: ${result.error}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #703 end-to-end: init.plan-phase path forwards and resolves granularity ──
|
||||
//
|
||||
// Fixture mirrors tests/pattern-mapper.test.cjs: createTempProject() +
|
||||
// minimal STATE.md + ROADMAP.md + phase directory — just enough for
|
||||
// cmdInitPlanPhase to succeed without agents / git.
|
||||
|
||||
function makeInitPlanPhaseFixture(prefix) {
|
||||
const tmpDir = createTempProject(prefix);
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
fs.writeFileSync(path.join(planningDir, 'STATE.md'), [
|
||||
'# State',
|
||||
'',
|
||||
'## Current Phase',
|
||||
'Phase 1 — Foundation',
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Phase 1: Foundation',
|
||||
'Build the foundation.',
|
||||
'**Status:** Planning',
|
||||
'**Requirements:** [FOUND-01]',
|
||||
].join('\n'));
|
||||
fs.mkdirSync(path.join(planningDir, 'phases', '01-foundation'), { recursive: true });
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
describe('#703 init.plan-phase end-to-end: granularity resolution via CLI', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeInitPlanPhaseFixture('gsd-68-e2e-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('(1) --granularity fine overrides config granularities.planning=coarse end-to-end', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ granularity: 'standard', granularities: { planning: 'coarse' } })
|
||||
);
|
||||
const result = runGsdTools('init plan-phase 1 --granularity fine', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
assert.strictEqual(data.granularity, 'fine',
|
||||
'--granularity fine override must win over granularities.planning=coarse end-to-end');
|
||||
});
|
||||
|
||||
test('(2) no flag + config granularities.planning=fine → granularity=fine (Fix A: per-phase-type honored)', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ granularity: 'coarse', granularities: { planning: 'fine' } })
|
||||
);
|
||||
const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
assert.strictEqual(data.granularity, 'fine',
|
||||
'granularities.planning=fine must be honored (phaseType=planning in resolveGranularityInternal)');
|
||||
});
|
||||
|
||||
test('(3) no flag + global granularity=coarse (no granularities.planning) → granularity=coarse', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ granularity: 'coarse' })
|
||||
);
|
||||
const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
assert.strictEqual(data.granularity, 'coarse',
|
||||
'global granularity=coarse must be returned when no granularities.planning is set');
|
||||
});
|
||||
|
||||
test('(4) --granularity ultra → command errors (invalid value rejected on plan-phase path)', () => {
|
||||
const result = runGsdTools('init plan-phase 1 --granularity ultra', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(!result.success, 'should fail with invalid granularity ultra');
|
||||
assert.ok(
|
||||
result.error.includes('ultra') || result.error.includes('invalid'),
|
||||
`error should mention invalid value; got: ${result.error}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
'use strict';
|
||||
// allow-test-rule: runtime-contract-is-the-product
|
||||
// The ship.md verification gate is LLM-executed prose; its routing message text
|
||||
// IS the user-facing product surface (RULESET.TESTS.no-source-grep.exemption:
|
||||
// "reserved for tests where the file content IS the product surface ... agent .md").
|
||||
// The behavioral tests below additionally EXECUTE the gate's own bash extraction
|
||||
// pipeline (parsed out of ship.md) against fixture reports, so the extraction
|
||||
// contract is verified, not just asserted as text.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const helpers = require('./helpers.cjs');
|
||||
|
||||
const SHIP_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'ship.md');
|
||||
const ship = fs.readFileSync(SHIP_MD, 'utf8');
|
||||
|
||||
const start = ship.indexOf('**Verification passed?**');
|
||||
const end = ship.indexOf('**Clean working tree?**');
|
||||
assert.ok(start !== -1 && end !== -1 && end > start, 'could not locate the verification gate block');
|
||||
const gate = ship.slice(start, end);
|
||||
|
||||
// ---- content assertions (the routing message IS the product surface) ----
|
||||
|
||||
test('gate captures the status value with a single first-match grep', () => {
|
||||
assert.match(gate, /grep -m1 "\^status:"/, 'gate must extract status via grep -m1 "^status:"');
|
||||
});
|
||||
|
||||
test('gate scopes status extraction to the YAML frontmatter only', () => {
|
||||
assert.match(gate, /sed -n '\/\^---\$\/,\/\^---\$\/p'/, 'gate must restrict extraction to the frontmatter block');
|
||||
});
|
||||
|
||||
test('gate routes gaps_found to /gsd:plan-phase --gaps', () => {
|
||||
assert.match(gate, /gaps_found/);
|
||||
assert.match(gate, /\/gsd:plan-phase[^\n]*--gaps/);
|
||||
});
|
||||
|
||||
test('gate routes human_needed to the UAT manual-test step', () => {
|
||||
assert.match(gate, /human_needed/);
|
||||
assert.match(gate, /UAT\.md/);
|
||||
});
|
||||
|
||||
test('gate routes a missing VERIFICATION.md to re-running execute-phase', () => {
|
||||
assert.match(gate, /\/gsd:execute-phase/);
|
||||
});
|
||||
|
||||
test('gate still blocks with PHASE_VERIFICATION_INCOMPLETE', () => {
|
||||
assert.match(gate, /PHASE_VERIFICATION_INCOMPLETE/);
|
||||
});
|
||||
|
||||
test('the dead `pass` status arm is gone — only `passed` is accepted', () => {
|
||||
assert.doesNotMatch(gate, /status:\s*pass(?!ed)/i, 'no bare `status: pass` arm may remain');
|
||||
assert.doesNotMatch(gate, /`pass`\s*\/\s*`passed`/, 'the `pass` / `passed` either-arm must be removed');
|
||||
assert.match(gate, /passed/);
|
||||
});
|
||||
|
||||
// ---- behavioral tests: run the gate's OWN bash pipeline against fixtures ----
|
||||
|
||||
const bashBlock = (() => {
|
||||
// `\r?\n` (not a literal `\n`) so the fence matches on Windows CRLF checkouts;
|
||||
// normalize the captured block to LF before handing it to bash.
|
||||
const m = gate.match(/```bash\r?\n([\s\S]*?)```/);
|
||||
assert.ok(m, 'gate must contain a bash block');
|
||||
return m[1].replace(/\r\n/g, '\n');
|
||||
})();
|
||||
|
||||
const hasBash = (() => {
|
||||
try { execFileSync('bash', ['-c', 'true'], { stdio: 'ignore' }); return true; }
|
||||
catch { return false; }
|
||||
})();
|
||||
|
||||
// The extraction logic is platform-independent; the bash *pipeline* is executed
|
||||
// only where the gate's shell actually runs (POSIX). On Windows, git-bash exists
|
||||
// but receives Windows-style tmpdir paths it cannot glob, so skip execution there.
|
||||
const skipBashPipeline = (process.platform === 'win32' || !hasBash) && 'bash pipeline runs on POSIX only';
|
||||
|
||||
function runGateExtraction(verificationContents) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ship586-'));
|
||||
try {
|
||||
if (verificationContents !== null) {
|
||||
fs.writeFileSync(path.join(dir, '01-VERIFICATION.md'), verificationContents);
|
||||
}
|
||||
const script = `PHASE_DIR='${dir}'\n${bashBlock}\nprintf '%s' "$STATUS"`;
|
||||
return execFileSync('bash', ['-c', script], { encoding: 'utf8' });
|
||||
} finally {
|
||||
helpers.cleanup(dir);
|
||||
}
|
||||
}
|
||||
|
||||
const FM = (status) =>
|
||||
`---\nphase: 01-demo\nverified: 2026-01-01T00:00:00Z\nstatus: ${status}\nscore: 3/3 must-haves verified\n---\n\n# Verification\n`;
|
||||
|
||||
test('extraction yields passed for a passing frontmatter', { skip: skipBashPipeline }, () => {
|
||||
assert.strictEqual(runGateExtraction(FM('passed')), 'passed');
|
||||
});
|
||||
|
||||
test('extraction yields gaps_found / human_needed verbatim', { skip: skipBashPipeline }, () => {
|
||||
assert.strictEqual(runGateExtraction(FM('gaps_found')), 'gaps_found');
|
||||
assert.strictEqual(runGateExtraction(FM('human_needed')), 'human_needed');
|
||||
});
|
||||
|
||||
test('REGRESSION: a body `status:` line does not corrupt a passing report (Codex PR #650 finding)', { skip: skipBashPipeline }, () => {
|
||||
const withBodyStatus = FM('passed') +
|
||||
'\n## Example\n\n```yaml\nstatus: gaps_found\n```\n\nstatus: human_needed\n';
|
||||
assert.strictEqual(runGateExtraction(withBodyStatus), 'passed');
|
||||
});
|
||||
|
||||
test('extraction yields empty when no VERIFICATION.md exists', { skip: skipBashPipeline }, () => {
|
||||
assert.strictEqual(runGateExtraction(null), '');
|
||||
});
|
||||
331
tests/verification-status.test.cjs
Normal file
331
tests/verification-status.test.cjs
Normal file
@@ -0,0 +1,331 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Tests for verification-status module (issue #651).
|
||||
*
|
||||
* Covers:
|
||||
* 1. status: passed → routing
|
||||
* 2. status: gaps_found with phase token extraction
|
||||
* 3. status: human_needed → routing
|
||||
* 4. No *-VERIFICATION.md → 'missing'
|
||||
* 5. Frontmatter status present but unknown value → 'unknown'
|
||||
* 6. BROAD-GREP REGRESSION: body `status:` lines ignored, frontmatter wins
|
||||
* 7. PARITY: VERIFIER_STATUSES covered by routing table; gsd-verifier.md emitted statuses covered
|
||||
* 8. CRLF line endings in frontmatter
|
||||
* 9. Body-only file (no frontmatter block) → missing
|
||||
* 10. Nonexistent phase directory → missing
|
||||
* 11. Multiple *-VERIFICATION.md files → first by sort
|
||||
* 12. ship.md PHASE_VERIFICATION_INCOMPLETE sentinel (contract anchor for #651 consolidation)
|
||||
*
|
||||
* PORTABILITY: pure JS — no shell-outs, no bash fences.
|
||||
* Cross-platform (passes on Windows). Ref: DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.
|
||||
*/
|
||||
|
||||
const { describe, test } = 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 { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
VERIFIER_STATUSES,
|
||||
VERIFICATION_ROUTING_TABLE,
|
||||
readVerificationStatus,
|
||||
} = require('../gsd-core/bin/lib/verification.cjs');
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a temporary phase directory under os.tmpdir().
|
||||
* Returns the absolute path; caller must clean up.
|
||||
*/
|
||||
function mkPhaseDir(suffix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), `gsd-651-${suffix}-`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a *-VERIFICATION.md file with the given frontmatter status and
|
||||
* optional body content.
|
||||
*
|
||||
* @param {string} dir - Phase directory path
|
||||
* @param {string} filename - e.g. '01-review-VERIFICATION.md'
|
||||
* @param {string} status - Frontmatter status value
|
||||
* @param {string} [body] - Content after the closing `---`
|
||||
*/
|
||||
function writeVerificationMd(dir, filename, status, body = '') {
|
||||
const frontmatter = `---\nstatus: ${status}\n---\n`;
|
||||
fs.writeFileSync(path.join(dir, filename), frontmatter + body);
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('verification-status', () => {
|
||||
|
||||
// ── Case 1: passed ────────────────────────────────────────────────────────
|
||||
test('status: passed → next_command is empty, status is passed', () => {
|
||||
const dir = mkPhaseDir('passed');
|
||||
try {
|
||||
writeVerificationMd(dir, '01-foo-VERIFICATION.md', 'passed');
|
||||
const result = readVerificationStatus(dir);
|
||||
assert.equal(result.status, 'passed', 'status must be passed');
|
||||
assert.equal(result.next_command, '', 'next_command must be empty for passed');
|
||||
assert.ok(result.next_action.length > 0, 'next_action must be non-empty');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Case 2: gaps_found with phase token extraction ────────────────────────
|
||||
test('status: gaps_found in "03-foo" dir → next_command includes phase token 03', () => {
|
||||
// Phase dir basename starts with "03" — extractPhaseToken('03-foo') → '03'
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-651-parent-'));
|
||||
const phaseDir = path.join(baseDir, '03-foo');
|
||||
fs.mkdirSync(phaseDir);
|
||||
try {
|
||||
writeVerificationMd(phaseDir, '03-foo-VERIFICATION.md', 'gaps_found');
|
||||
const result = readVerificationStatus(phaseDir);
|
||||
assert.equal(result.status, 'gaps_found', 'status must be gaps_found');
|
||||
assert.ok(
|
||||
result.next_command.includes('03'),
|
||||
`next_command should include phase token '03'; got: ${result.next_command}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.next_command.includes('--gaps'),
|
||||
`next_command should include --gaps; got: ${result.next_command}`,
|
||||
);
|
||||
assert.equal(result.next_command, '/gsd:plan-phase 03 --gaps');
|
||||
} finally {
|
||||
cleanup(baseDir);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Case 3: human_needed ──────────────────────────────────────────────────
|
||||
test('status: human_needed → status human_needed, next_command is empty', () => {
|
||||
const dir = mkPhaseDir('human-needed');
|
||||
try {
|
||||
writeVerificationMd(dir, '01-hn-VERIFICATION.md', 'human_needed');
|
||||
const result = readVerificationStatus(dir);
|
||||
assert.equal(result.status, 'human_needed');
|
||||
assert.equal(result.next_command, '');
|
||||
assert.ok(result.next_action.length > 0);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Case 4: no *-VERIFICATION.md → missing ────────────────────────────────
|
||||
test('no *-VERIFICATION.md file → status missing, next_command execute-phase', () => {
|
||||
const dir = mkPhaseDir('missing');
|
||||
try {
|
||||
// write a non-matching file to confirm it is ignored
|
||||
fs.writeFileSync(path.join(dir, 'README.md'), '# phase');
|
||||
const result = readVerificationStatus(dir);
|
||||
assert.equal(result.status, 'missing');
|
||||
assert.equal(result.next_command, '/gsd:execute-phase');
|
||||
assert.ok(result.next_action.includes('verify step never completed'));
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Case 5: unknown frontmatter status value ──────────────────────────────
|
||||
test("frontmatter status 'bogus' → status unknown, next_command execute-phase", () => {
|
||||
const dir = mkPhaseDir('unknown');
|
||||
try {
|
||||
writeVerificationMd(dir, '01-u-VERIFICATION.md', 'bogus');
|
||||
const result = readVerificationStatus(dir);
|
||||
assert.equal(result.status, 'unknown');
|
||||
assert.equal(result.next_command, '/gsd:execute-phase');
|
||||
assert.ok(
|
||||
result.next_action.includes('bogus'),
|
||||
`next_action should mention the raw value; got: ${result.next_action}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Case 6: BROAD-GREP REGRESSION (critical) ──────────────────────────────
|
||||
//
|
||||
// Frontmatter: `status: passed`
|
||||
// Body: a fenced code block containing `status: gaps_found` AND `status: human_needed`
|
||||
// Result MUST be 'passed' — proving body lines are NOT matched.
|
||||
// This is the exact failure mode that issue #586 / PR #650 hit.
|
||||
//
|
||||
test('BROAD-GREP REGRESSION: body status lines ignored, frontmatter status wins', () => {
|
||||
const dir = mkPhaseDir('broad-grep');
|
||||
try {
|
||||
const bodyWithEmbeddedStatuses = [
|
||||
'',
|
||||
'## Section',
|
||||
'',
|
||||
'Some prose about the results.',
|
||||
'',
|
||||
'```yaml',
|
||||
'status: gaps_found',
|
||||
'gaps:',
|
||||
' - fix the thing',
|
||||
'```',
|
||||
'',
|
||||
'Another block:',
|
||||
'',
|
||||
'```',
|
||||
'status: human_needed',
|
||||
'```',
|
||||
'',
|
||||
'End of document.',
|
||||
].join('\n');
|
||||
|
||||
writeVerificationMd(dir, '01-bg-VERIFICATION.md', 'passed', bodyWithEmbeddedStatuses);
|
||||
|
||||
const result = readVerificationStatus(dir);
|
||||
assert.equal(
|
||||
result.status,
|
||||
'passed',
|
||||
`Expected status 'passed' (frontmatter wins); got '${result.status}'. ` +
|
||||
'Body status: lines must NOT be matched.',
|
||||
);
|
||||
assert.equal(result.next_command, '', 'next_command must be empty for passed');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Case 7: PARITY ASSERTION ──────────────────────────────────────────────
|
||||
//
|
||||
// (a) Every value in VERIFIER_STATUSES has a corresponding key in VERIFICATION_ROUTING_TABLE.
|
||||
// (b) Parse agents/gsd-verifier.md for emitted statuses via /→ \*\*status:\s*([a-z_]+)\*\*/g,
|
||||
// collect the set, and assert every emitted status is a routing key.
|
||||
//
|
||||
test('PARITY: VERIFIER_STATUSES covered by routing table', () => {
|
||||
for (const s of VERIFIER_STATUSES) {
|
||||
assert.ok(
|
||||
s in VERIFICATION_ROUTING_TABLE,
|
||||
`VERIFIER_STATUS '${s}' has no entry in VERIFICATION_ROUTING_TABLE`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('PARITY: gsd-verifier.md emitted statuses all have routing table entries', () => {
|
||||
const verifierPath = path.join(__dirname, '..', 'agents', 'gsd-verifier.md');
|
||||
const content = fs.readFileSync(verifierPath, 'utf-8');
|
||||
|
||||
const emittedStatuses = new Set();
|
||||
|
||||
// Source (a): decision-tree arrow lines — `→ **status: <value>**`
|
||||
// These are the per-branch emission points in Step 9 (the decision tree).
|
||||
const reArrow = /→ \*\*status:\s*([a-z_]+)\*\*/g;
|
||||
let m;
|
||||
while ((m = reArrow.exec(content)) !== null) {
|
||||
emittedStatuses.add(m[1]);
|
||||
}
|
||||
|
||||
// Source (b): output-template line — `status: A | B | C` (pipe-delimited list
|
||||
// of permitted values inside the frontmatter template block in the <output> section).
|
||||
// Anchored to lines that start with `status:` and contain `|` to avoid false
|
||||
// matches on prose sentences that happen to mention "status:".
|
||||
const reTemplate = /^status:\s+([a-z_]+(?:\s*\|\s*[a-z_]+)+)\s*$/gm;
|
||||
while ((m = reTemplate.exec(content)) !== null) {
|
||||
for (const token of m[1].split('|')) {
|
||||
const t = token.trim();
|
||||
if (t) emittedStatuses.add(t);
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
emittedStatuses.size > 0,
|
||||
'No emitted statuses found in gsd-verifier.md — regex or file path may be wrong. ' +
|
||||
'Checked: (a) → **status: X** arrow lines, (b) status: A | B | C template lines.',
|
||||
);
|
||||
|
||||
for (const s of emittedStatuses) {
|
||||
assert.ok(
|
||||
s in VERIFICATION_ROUTING_TABLE,
|
||||
`gsd-verifier.md emits status '${s}' but VERIFICATION_ROUTING_TABLE has no entry for it. ` +
|
||||
'Add a route or remove/rename the status in gsd-verifier.md.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Edge cases ────────────────────────────────────────────────────────────
|
||||
|
||||
// CRLF line endings in frontmatter
|
||||
test('CRLF line endings in frontmatter → correct status parsed', () => {
|
||||
const dir = mkPhaseDir('crlf');
|
||||
try {
|
||||
// Construct a file with CRLF line endings throughout
|
||||
const content = '---\r\nstatus: passed\r\nphase: 01-demo\r\n---\r\n\r\n# Body\r\n';
|
||||
fs.writeFileSync(path.join(dir, '01-crlf-VERIFICATION.md'), content);
|
||||
const result = readVerificationStatus(dir);
|
||||
assert.equal(result.status, 'passed', 'CRLF frontmatter must parse to passed');
|
||||
assert.equal(result.next_command, '');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// File with NO frontmatter block — body-only `status:` line must NOT be matched
|
||||
test('body-only file with no frontmatter block (status: in body) → missing', () => {
|
||||
const dir = mkPhaseDir('no-fm');
|
||||
try {
|
||||
// No opening `---` — this is a plain markdown file with a status: line in the body
|
||||
const content = '# Phase Verification\n\nstatus: passed\n\nSome notes.\n';
|
||||
fs.writeFileSync(path.join(dir, '01-nofm-VERIFICATION.md'), content);
|
||||
const result = readVerificationStatus(dir);
|
||||
assert.equal(
|
||||
result.status,
|
||||
'missing',
|
||||
"A body-only status: line must NOT be read — result should be 'missing'",
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// Missing / nonexistent phase directory → missing
|
||||
test('nonexistent phase directory → missing', () => {
|
||||
const nonexistent = path.join(os.tmpdir(), 'gsd-651-nonexistent-' + Date.now());
|
||||
const result = readVerificationStatus(nonexistent);
|
||||
assert.equal(result.status, 'missing', 'unreadable/nonexistent dir must return missing');
|
||||
assert.equal(result.next_command, '/gsd:execute-phase');
|
||||
});
|
||||
|
||||
// Multiple *-VERIFICATION.md files → deterministic pick (first by sort)
|
||||
test('multiple *-VERIFICATION.md files in dir → first by sort order wins', () => {
|
||||
const dir = mkPhaseDir('multi');
|
||||
try {
|
||||
// Write two files: alphabetically "01-a" comes before "02-b"
|
||||
// "01-a" has passed; "02-b" has gaps_found — first by sort must win
|
||||
const fm = (status) => `---\nstatus: ${status}\n---\n`;
|
||||
fs.writeFileSync(path.join(dir, '01-a-VERIFICATION.md'), fm('passed'));
|
||||
fs.writeFileSync(path.join(dir, '02-b-VERIFICATION.md'), fm('gaps_found'));
|
||||
const result = readVerificationStatus(dir);
|
||||
assert.equal(
|
||||
result.status,
|
||||
'passed',
|
||||
'When multiple *-VERIFICATION.md files exist, the first by lexicographic sort must be used',
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Task 2 (B1): ship.md gate sentinel contract anchor ────────────────────
|
||||
//
|
||||
// The deleted tests/ship-586-verification-routing.test.cjs was the only
|
||||
// thing asserting that ship.md emits the PHASE_VERIFICATION_INCOMPLETE block
|
||||
// sentinel (its user-visible gate error key). This test re-anchors that contract.
|
||||
//
|
||||
test('ship.md still emits the PHASE_VERIFICATION_INCOMPLETE gate sentinel (contract anchor for #651 consolidation)', () => {
|
||||
const shipMdPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'ship.md');
|
||||
const content = fs.readFileSync(shipMdPath, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('PHASE_VERIFICATION_INCOMPLETE'),
|
||||
'ship.md must contain the literal PHASE_VERIFICATION_INCOMPLETE gate sentinel. ' +
|
||||
'If you renamed or removed it, update the verification routing and this contract test.',
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
57
tests/verifier-spotcheck-test-discipline.test.cjs
Normal file
57
tests/verifier-spotcheck-test-discipline.test.cjs
Normal file
@@ -0,0 +1,57 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
// Issue #25: gsd-verifier Step 7b must not present the runner-specific
|
||||
// full-suite "| grep" anti-pattern, and must steer toward
|
||||
// enumerate-for-existence / single-named-test-for-pass.
|
||||
const verifierPath = path.join(__dirname, '..', 'agents', 'gsd-verifier.md');
|
||||
const content = fs.readFileSync(verifierPath, 'utf8');
|
||||
|
||||
// Scope assertions to the Step 7b section so the guard tracks the right place.
|
||||
const start = content.indexOf('## Step 7b');
|
||||
const end = content.indexOf('## Step 7c', start);
|
||||
assert.ok(start !== -1 && end !== -1 && end > start, 'Step 7b section not found');
|
||||
const step7b = content.slice(start, end);
|
||||
|
||||
test('Step 7b drops the misleading full-suite grep example', () => {
|
||||
assert.ok(
|
||||
!step7b.includes('npm test -- --grep "$PHASE_TEST_PATTERN" 2>&1 | grep -q "passing"'),
|
||||
'the runner-specific `npm test --grep` example should be removed (it mis-generalizes to `<full-suite> | grep`)'
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 7b steers existence proofs to test enumeration', () => {
|
||||
assert.ok(
|
||||
/cargo test -- --list/.test(step7b),
|
||||
'Step 7b should show the correct `cargo test -- --list` enumeration form'
|
||||
);
|
||||
assert.ok(
|
||||
/pytest --collect-only/.test(step7b) &&
|
||||
/vitest list/.test(step7b) &&
|
||||
/go test -list/.test(step7b),
|
||||
'Step 7b should list cross-ecosystem enumeration commands (pytest/vitest/go)'
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 7b steers pass-checks to a single named test', () => {
|
||||
assert.ok(
|
||||
/-- --exact/.test(step7b) &&
|
||||
/pytest -k/.test(step7b) &&
|
||||
/vitest run -t/.test(step7b),
|
||||
'Step 7b should show single-named-test commands across ecosystems'
|
||||
);
|
||||
});
|
||||
|
||||
test('Step 7b forbids re-running the full suite per must-have', () => {
|
||||
assert.ok(
|
||||
/at most once per verification/i.test(step7b),
|
||||
'Step 7b should forbid invoking the full workspace test command more than once per verification'
|
||||
);
|
||||
assert.ok(
|
||||
/grep/i.test(step7b) && /per must-have|per truth/i.test(step7b),
|
||||
'Step 7b should explicitly call out the per-must-have full-suite grep anti-pattern'
|
||||
);
|
||||
});
|
||||
@@ -64,8 +64,10 @@
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('node:os');
|
||||
const path = require('path');
|
||||
const { assertTightCeiling } = require('../scripts/lib/allowlist-ratchet.cjs');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
|
||||
@@ -121,10 +123,16 @@ function budgetFor(workflow) {
|
||||
}
|
||||
|
||||
function byteCount(filePath) {
|
||||
// Match `wc -c`: count every byte on disk, including any trailing newline.
|
||||
// Deliberately NOT the trailing-newline-stripping logic the old lineCount()
|
||||
// used — the byte ceilings are calibrated against raw `wc -c` output.
|
||||
return fs.statSync(filePath).size;
|
||||
// Count bytes as on an LF checkout, so the budget is platform-independent.
|
||||
// The tier ceilings are calibrated against `wc -c` on a Unix (LF) checkout,
|
||||
// but these .md files have no `eol=lf` in .gitattributes, so Windows checks
|
||||
// them out as CRLF. Counting raw on-disk bytes there adds one byte per line,
|
||||
// which fails CI on the high-water-mark file (execute-phase.md) on Windows
|
||||
// ONLY — a false positive that diverges from the LF calibration basis (#683).
|
||||
// Stripping CR yields the same LF byte count on every platform. Still a raw
|
||||
// byte count (not the old trailing-newline-stripping lineCount()).
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return Buffer.byteLength(content.replace(/\r\n/g, '\n'), 'utf-8');
|
||||
}
|
||||
|
||||
describe('SIZE: workflow byte-size budget', () => {
|
||||
@@ -549,3 +557,28 @@ describe('workflow progressive disclosure — MVP bodies lazy-loaded (#720)', ()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SIZE: byteCount is line-ending independent (#683 regression)', () => {
|
||||
// The budget ceilings are calibrated against an LF (Unix) checkout; Windows
|
||||
// checks these .md files out as CRLF, which previously inflated the count by
|
||||
// one byte per line and failed CI only on Windows for the high-water file.
|
||||
test('CRLF and LF content of the same logical file count identically', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-size-eol-'));
|
||||
try {
|
||||
const body = 'line one\nline two\nthree — with a multibyte dash\n';
|
||||
const lfPath = path.join(dir, 'lf.md');
|
||||
const crlfPath = path.join(dir, 'crlf.md');
|
||||
fs.writeFileSync(lfPath, body);
|
||||
fs.writeFileSync(crlfPath, body.replace(/\n/g, '\r\n'));
|
||||
assert.strictEqual(
|
||||
byteCount(crlfPath),
|
||||
byteCount(lfPath),
|
||||
'byteCount must normalize CRLF so the byte budget is platform-independent'
|
||||
);
|
||||
// And it must remain a real LF byte count (not stripped/whitespace-trimmed).
|
||||
assert.strictEqual(byteCount(lfPath), Buffer.byteLength(body, 'utf-8'));
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
773
tests/worktree-base-ref.test.cjs
Normal file
773
tests/worktree-base-ref.test.cjs
Normal file
@@ -0,0 +1,773 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Worktree Base-Ref Module — unit tests
|
||||
*
|
||||
* Seam: gsd-core/bin/lib/worktree-base-ref.cjs
|
||||
* Interface: shortSha, readBaseRefFromSettings, applyWorktreeBaseRef,
|
||||
* resolveEffectiveBaseRef, evaluateWorktreeBaseDegrade
|
||||
*
|
||||
* Issue #683: worktree base-mismatch detection and degradation logic.
|
||||
* All tests use dependency injection (inline stubs) — no real filesystem
|
||||
* or real git is exercised.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
const MODULE_PATH = path.join(
|
||||
__dirname, '..', 'gsd-core', 'bin', 'lib', 'worktree-base-ref.cjs'
|
||||
);
|
||||
|
||||
const {
|
||||
shortSha,
|
||||
readBaseRefFromSettings,
|
||||
applyWorktreeBaseRef,
|
||||
resolveEffectiveBaseRef,
|
||||
evaluateWorktreeBaseDegrade,
|
||||
cmdWorktreeBaseCheck,
|
||||
cmdWorktreeSetBaseRef,
|
||||
} = require(MODULE_PATH);
|
||||
|
||||
// ─── shortSha ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('shortSha', () => {
|
||||
test('returns first 8 chars of a full sha', () => {
|
||||
assert.strictEqual(shortSha('abc123def456789'), 'abc123de');
|
||||
});
|
||||
|
||||
test('returns the string itself when shorter than 8 chars', () => {
|
||||
assert.strictEqual(shortSha('abc12'), 'abc12');
|
||||
});
|
||||
|
||||
test('returns empty string for null', () => {
|
||||
assert.strictEqual(shortSha(null), '');
|
||||
});
|
||||
|
||||
test('returns empty string for empty string', () => {
|
||||
assert.strictEqual(shortSha(''), '');
|
||||
});
|
||||
|
||||
test('returns exactly 8 chars when sha is exactly 8 chars', () => {
|
||||
assert.strictEqual(shortSha('12345678'), '12345678');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── readBaseRefFromSettings ─────────────────────────────────────────────────
|
||||
|
||||
describe('readBaseRefFromSettings', () => {
|
||||
test('returns baseRef when present as a string', () => {
|
||||
assert.strictEqual(readBaseRefFromSettings({ worktree: { baseRef: 'head' } }), 'head');
|
||||
});
|
||||
|
||||
test('returns baseRef value "fresh"', () => {
|
||||
assert.strictEqual(readBaseRefFromSettings({ worktree: { baseRef: 'fresh' } }), 'fresh');
|
||||
});
|
||||
|
||||
test('returns null when worktree is missing', () => {
|
||||
assert.strictEqual(readBaseRefFromSettings({}), null);
|
||||
});
|
||||
|
||||
test('returns null when settings is null', () => {
|
||||
assert.strictEqual(readBaseRefFromSettings(null), null);
|
||||
});
|
||||
|
||||
test('returns null when settings is undefined', () => {
|
||||
assert.strictEqual(readBaseRefFromSettings(undefined), null);
|
||||
});
|
||||
|
||||
test('returns null when worktree is not an object (string)', () => {
|
||||
assert.strictEqual(readBaseRefFromSettings({ worktree: 'not-an-object' }), null);
|
||||
});
|
||||
|
||||
test('returns null when baseRef is a number (non-string)', () => {
|
||||
assert.strictEqual(readBaseRefFromSettings({ worktree: { baseRef: 42 } }), null);
|
||||
});
|
||||
|
||||
test('returns null when baseRef is null', () => {
|
||||
assert.strictEqual(readBaseRefFromSettings({ worktree: { baseRef: null } }), null);
|
||||
});
|
||||
|
||||
test('returns null when baseRef is undefined', () => {
|
||||
assert.strictEqual(readBaseRefFromSettings({ worktree: { baseRef: undefined } }), null);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── applyWorktreeBaseRef ─────────────────────────────────────────────────────
|
||||
|
||||
describe('applyWorktreeBaseRef', () => {
|
||||
test('sets baseRef to "head" when absent, returns changed:true', () => {
|
||||
const settings = {};
|
||||
const result = applyWorktreeBaseRef(settings);
|
||||
assert.strictEqual(result.changed, true);
|
||||
assert.strictEqual(result.skipped, null);
|
||||
assert.strictEqual(result.previous, null);
|
||||
assert.strictEqual(result.settings.worktree.baseRef, 'head');
|
||||
});
|
||||
|
||||
test('sets baseRef to "head" when worktree key is missing entirely', () => {
|
||||
const settings = { other: 'value' };
|
||||
const result = applyWorktreeBaseRef(settings);
|
||||
assert.strictEqual(result.changed, true);
|
||||
assert.strictEqual(settings.worktree.baseRef, 'head');
|
||||
});
|
||||
|
||||
test('sets baseRef to "head" when worktree.baseRef is null', () => {
|
||||
const settings = { worktree: { baseRef: null, otherKey: 'keep' } };
|
||||
const result = applyWorktreeBaseRef(settings);
|
||||
assert.strictEqual(result.changed, true);
|
||||
assert.strictEqual(settings.worktree.baseRef, 'head');
|
||||
});
|
||||
|
||||
test('sets baseRef to "head" when worktree.baseRef is undefined', () => {
|
||||
const settings = { worktree: { baseRef: undefined } };
|
||||
const result = applyWorktreeBaseRef(settings);
|
||||
assert.strictEqual(result.changed, true);
|
||||
assert.strictEqual(settings.worktree.baseRef, 'head');
|
||||
});
|
||||
|
||||
test('preserves other worktree.* keys when setting baseRef', () => {
|
||||
const settings = { worktree: { otherKey: 'preserved', anotherKey: 123 } };
|
||||
applyWorktreeBaseRef(settings);
|
||||
assert.strictEqual(settings.worktree.otherKey, 'preserved');
|
||||
assert.strictEqual(settings.worktree.anotherKey, 123);
|
||||
assert.strictEqual(settings.worktree.baseRef, 'head');
|
||||
});
|
||||
|
||||
test('mutates settings in place and returns the same object reference', () => {
|
||||
const settings = {};
|
||||
const result = applyWorktreeBaseRef(settings);
|
||||
assert.strictEqual(result.settings, settings);
|
||||
});
|
||||
|
||||
test('returns already-head skip when baseRef is already "head"', () => {
|
||||
const settings = { worktree: { baseRef: 'head' } };
|
||||
const result = applyWorktreeBaseRef(settings);
|
||||
assert.strictEqual(result.changed, false);
|
||||
assert.strictEqual(result.skipped, 'already-head');
|
||||
assert.strictEqual(result.previous, 'head');
|
||||
assert.strictEqual(settings.worktree.baseRef, 'head');
|
||||
});
|
||||
|
||||
test('returns explicit-other skip when baseRef is "fresh", does NOT overwrite', () => {
|
||||
const settings = { worktree: { baseRef: 'fresh' } };
|
||||
const result = applyWorktreeBaseRef(settings);
|
||||
assert.strictEqual(result.changed, false);
|
||||
assert.strictEqual(result.skipped, 'explicit-other');
|
||||
assert.strictEqual(result.previous, 'fresh');
|
||||
assert.strictEqual(settings.worktree.baseRef, 'fresh');
|
||||
});
|
||||
|
||||
test('returns explicit-other skip for any other string value', () => {
|
||||
const settings = { worktree: { baseRef: 'some-branch' } };
|
||||
const result = applyWorktreeBaseRef(settings);
|
||||
assert.strictEqual(result.changed, false);
|
||||
assert.strictEqual(result.skipped, 'explicit-other');
|
||||
assert.strictEqual(result.previous, 'some-branch');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveEffectiveBaseRef ──────────────────────────────────────────────────
|
||||
|
||||
describe('resolveEffectiveBaseRef', () => {
|
||||
// Helper to build a path-keyed readFile stub
|
||||
function makeReadFile(files) {
|
||||
return (p) => (Object.prototype.hasOwnProperty.call(files, p) ? files[p] : null);
|
||||
}
|
||||
|
||||
test('returns baseRef from settings.local.json when present', () => {
|
||||
const claudeDir = '/repo/.claude';
|
||||
const deps = {
|
||||
readFile: makeReadFile({
|
||||
[path.join(claudeDir, 'settings.local.json')]: JSON.stringify({ worktree: { baseRef: 'head' } }),
|
||||
[path.join(claudeDir, 'settings.json')]: JSON.stringify({ worktree: { baseRef: 'fresh' } }),
|
||||
}),
|
||||
};
|
||||
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'head');
|
||||
});
|
||||
|
||||
test('falls back to settings.json when settings.local.json has no baseRef', () => {
|
||||
const claudeDir = '/repo/.claude';
|
||||
const deps = {
|
||||
readFile: makeReadFile({
|
||||
[path.join(claudeDir, 'settings.local.json')]: JSON.stringify({ other: 'value' }),
|
||||
[path.join(claudeDir, 'settings.json')]: JSON.stringify({ worktree: { baseRef: 'fresh' } }),
|
||||
}),
|
||||
};
|
||||
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'fresh');
|
||||
});
|
||||
|
||||
test('returns null when both files are missing', () => {
|
||||
const claudeDir = '/repo/.claude';
|
||||
const deps = { readFile: () => null };
|
||||
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), null);
|
||||
});
|
||||
|
||||
test('returns null when both files exist but have no baseRef', () => {
|
||||
const claudeDir = '/repo/.claude';
|
||||
const deps = {
|
||||
readFile: makeReadFile({
|
||||
[path.join(claudeDir, 'settings.local.json')]: JSON.stringify({ other: 'value' }),
|
||||
[path.join(claudeDir, 'settings.json')]: JSON.stringify({ other: 'value2' }),
|
||||
}),
|
||||
};
|
||||
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), null);
|
||||
});
|
||||
|
||||
test('ignores malformed JSON in settings.local.json and falls back', () => {
|
||||
const claudeDir = '/repo/.claude';
|
||||
const deps = {
|
||||
readFile: makeReadFile({
|
||||
[path.join(claudeDir, 'settings.local.json')]: 'not valid json {{{',
|
||||
[path.join(claudeDir, 'settings.json')]: JSON.stringify({ worktree: { baseRef: 'head' } }),
|
||||
}),
|
||||
};
|
||||
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'head');
|
||||
});
|
||||
|
||||
test('ignores malformed JSON in settings.json', () => {
|
||||
const claudeDir = '/repo/.claude';
|
||||
const deps = {
|
||||
readFile: makeReadFile({
|
||||
[path.join(claudeDir, 'settings.local.json')]: null,
|
||||
[path.join(claudeDir, 'settings.json')]: 'not valid json',
|
||||
}),
|
||||
};
|
||||
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), null);
|
||||
});
|
||||
|
||||
test('settings.local.json null baseRef falls back to settings.json', () => {
|
||||
const claudeDir = '/repo/.claude';
|
||||
const deps = {
|
||||
readFile: makeReadFile({
|
||||
[path.join(claudeDir, 'settings.local.json')]: JSON.stringify({ worktree: { baseRef: null } }),
|
||||
[path.join(claudeDir, 'settings.json')]: JSON.stringify({ worktree: { baseRef: 'fresh' } }),
|
||||
}),
|
||||
};
|
||||
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'fresh');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── evaluateWorktreeBaseDegrade ──────────────────────────────────────────────
|
||||
|
||||
describe('evaluateWorktreeBaseDegrade', () => {
|
||||
// Stub helper: matches on args.join(' ') and returns canned results
|
||||
function makeExecGit(responses) {
|
||||
return function stubExecGit(args, _opts) {
|
||||
const key = args.join(' ');
|
||||
if (Object.prototype.hasOwnProperty.call(responses, key)) {
|
||||
return responses[key];
|
||||
}
|
||||
// Default: fail with a helpful error to surface unexpected calls
|
||||
throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`);
|
||||
};
|
||||
}
|
||||
|
||||
test('effectiveBaseRef="head" → no degrade, reason baseref-head, execGit never called', () => {
|
||||
let called = false;
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: () => { called = true; return { exitCode: 0, stdout: '', stderr: '', signal: null, error: null }; },
|
||||
effectiveBaseRef: 'head',
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, false);
|
||||
assert.strictEqual(result.reason, 'baseref-head');
|
||||
assert.strictEqual(result.message, null);
|
||||
assert.strictEqual(result.headSha, null);
|
||||
assert.strictEqual(result.forkRef, null);
|
||||
assert.strictEqual(result.forkSha, null);
|
||||
assert.strictEqual(called, false, 'execGit must not be called when effectiveBaseRef is head');
|
||||
});
|
||||
|
||||
test('git rev-parse HEAD fails → no degrade, reason no-head', () => {
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeExecGit({
|
||||
'rev-parse HEAD': { exitCode: 128, stdout: '', stderr: 'fatal: not a git repo', signal: null, error: null },
|
||||
}),
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, false);
|
||||
assert.strictEqual(result.reason, 'no-head');
|
||||
assert.strictEqual(result.headSha, null);
|
||||
});
|
||||
|
||||
test('git rev-parse HEAD returns empty stdout → no degrade, reason no-head', () => {
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeExecGit({
|
||||
'rev-parse HEAD': { exitCode: 0, stdout: '', stderr: '', signal: null, error: null },
|
||||
}),
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, false);
|
||||
assert.strictEqual(result.reason, 'no-head');
|
||||
});
|
||||
|
||||
test('HEAD == origin/HEAD → no degrade, reason head-matches-fork', () => {
|
||||
const HEAD_SHA = 'aabbccdd11223344aabbccdd11223344aabbccdd';
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeExecGit({
|
||||
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
|
||||
'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
|
||||
}),
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, false);
|
||||
assert.strictEqual(result.reason, 'head-matches-fork');
|
||||
assert.strictEqual(result.headSha, HEAD_SHA);
|
||||
assert.strictEqual(result.forkRef, 'origin/HEAD');
|
||||
assert.strictEqual(result.forkSha, HEAD_SHA);
|
||||
assert.strictEqual(result.message, null);
|
||||
});
|
||||
|
||||
test('HEAD != origin/HEAD → degrade, reason head-diverged-from-fork, MSG_DIVERGED', () => {
|
||||
const HEAD_SHA = 'deadbeef11223344deadbeef11223344deadbeef';
|
||||
const FORK_SHA = 'cafebabe11223344cafebabe11223344cafebabe';
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeExecGit({
|
||||
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
|
||||
'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: FORK_SHA, stderr: '', signal: null, error: null },
|
||||
}),
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, true);
|
||||
assert.strictEqual(result.reason, 'head-diverged-from-fork');
|
||||
assert.strictEqual(result.headSha, HEAD_SHA);
|
||||
assert.strictEqual(result.forkRef, 'origin/HEAD');
|
||||
assert.strictEqual(result.forkSha, FORK_SHA);
|
||||
// Verify message contains the short SHAs and the issue reference
|
||||
const expectedMsg = `⚠ Worktree base mismatch: HEAD (${HEAD_SHA.slice(0, 8)}) differs from origin/HEAD (${FORK_SHA.slice(0, 8)}). Running this phase sequentially on the main working tree. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`;
|
||||
assert.strictEqual(result.message, expectedMsg);
|
||||
});
|
||||
|
||||
test('origin/HEAD fails but symbolic-ref resolves to refs/remotes/origin/next', () => {
|
||||
const HEAD_SHA = 'aaaa1111bbbb2222aaaa1111bbbb2222aaaa1111';
|
||||
const FORK_SHA = 'cccc3333dddd4444cccc3333dddd4444cccc3333';
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeExecGit({
|
||||
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
|
||||
'rev-parse --verify --quiet origin/HEAD': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null },
|
||||
'symbolic-ref --quiet refs/remotes/origin/HEAD': { exitCode: 0, stdout: 'refs/remotes/origin/next', stderr: '', signal: null, error: null },
|
||||
'rev-parse --verify --quiet refs/remotes/origin/next': { exitCode: 0, stdout: FORK_SHA, stderr: '', signal: null, error: null },
|
||||
}),
|
||||
});
|
||||
assert.strictEqual(result.forkRef, 'origin/next');
|
||||
assert.strictEqual(result.forkSha, FORK_SHA);
|
||||
// HEAD != FORK_SHA in this fixture → degrade
|
||||
assert.strictEqual(result.shouldDegrade, true);
|
||||
assert.strictEqual(result.reason, 'head-diverged-from-fork');
|
||||
assert.ok(result.message !== null);
|
||||
assert.ok(result.message.includes('origin/next'));
|
||||
});
|
||||
|
||||
test('origin/HEAD fails AND symbolic-ref fails → degrade, reason fork-ref-unknown, MSG_UNKNOWN', () => {
|
||||
const HEAD_SHA = 'eeee5555ffff6666eeee5555ffff6666eeee5555';
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeExecGit({
|
||||
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
|
||||
'rev-parse --verify --quiet origin/HEAD': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null },
|
||||
'symbolic-ref --quiet refs/remotes/origin/HEAD': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null },
|
||||
}),
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, true);
|
||||
assert.strictEqual(result.reason, 'fork-ref-unknown');
|
||||
assert.strictEqual(result.forkRef, null);
|
||||
assert.strictEqual(result.forkSha, null);
|
||||
const expectedMsg = `⚠ Cannot determine the worktree fork base (origin/HEAD unresolved). Running this phase sequentially on the main working tree to avoid a base mismatch. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`;
|
||||
assert.strictEqual(result.message, expectedMsg);
|
||||
});
|
||||
|
||||
test('cwd is passed through to execGit calls', () => {
|
||||
const HEAD_SHA = '1234567890abcdef1234567890abcdef12345678';
|
||||
const capturedOpts = [];
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
cwd: '/some/worktree',
|
||||
execGit: (args, opts) => {
|
||||
capturedOpts.push(opts);
|
||||
const key = args.join(' ');
|
||||
if (key === 'rev-parse HEAD') return { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null };
|
||||
if (key === 'rev-parse --verify --quiet origin/HEAD') return { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null };
|
||||
throw new Error(`Unexpected: ${key}`);
|
||||
},
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, false);
|
||||
assert.ok(capturedOpts.length > 0);
|
||||
for (const opts of capturedOpts) {
|
||||
assert.strictEqual(opts && opts.cwd, '/some/worktree');
|
||||
}
|
||||
});
|
||||
|
||||
test('symbolic-ref resolves but subsequent rev-parse fails → falls through to fork-ref-unknown', () => {
|
||||
const HEAD_SHA = 'abcd1234abcd1234abcd1234abcd1234abcd1234';
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeExecGit({
|
||||
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
|
||||
'rev-parse --verify --quiet origin/HEAD': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null },
|
||||
'symbolic-ref --quiet refs/remotes/origin/HEAD': { exitCode: 0, stdout: 'refs/remotes/origin/main', stderr: '', signal: null, error: null },
|
||||
'rev-parse --verify --quiet refs/remotes/origin/main': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null },
|
||||
}),
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, true);
|
||||
assert.strictEqual(result.reason, 'fork-ref-unknown');
|
||||
assert.strictEqual(result.forkRef, null);
|
||||
assert.strictEqual(result.forkSha, null);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── cmdWorktreeBaseCheck ─────────────────────────────────────────────────────
|
||||
|
||||
describe('cmdWorktreeBaseCheck', () => {
|
||||
function makeExecGitCheck(responses) {
|
||||
return function stubExecGit(args, _opts) {
|
||||
const key = args.join(' ');
|
||||
if (Object.prototype.hasOwnProperty.call(responses, key)) {
|
||||
return responses[key];
|
||||
}
|
||||
throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`);
|
||||
};
|
||||
}
|
||||
|
||||
test('baseRef=head in settings → shouldDegrade false, reason baseref-head; write emits valid JSON', () => {
|
||||
const cwd = '/repo';
|
||||
const claudeDir = '/repo/.claude';
|
||||
let written = '';
|
||||
const deps = {
|
||||
readFile: (p) => {
|
||||
if (p === path.join(claudeDir, 'settings.local.json')) return JSON.stringify({ worktree: { baseRef: 'head' } });
|
||||
return null;
|
||||
},
|
||||
execGit: makeExecGitCheck({}),
|
||||
write: (s) => { written += s; },
|
||||
};
|
||||
const result = cmdWorktreeBaseCheck(cwd, [], deps);
|
||||
assert.strictEqual(result.shouldDegrade, false);
|
||||
assert.strictEqual(result.reason, 'baseref-head');
|
||||
const parsed = JSON.parse(written);
|
||||
assert.deepStrictEqual(parsed, result);
|
||||
});
|
||||
|
||||
test('diverged shas → shouldDegrade true; captured JSON parses correctly', () => {
|
||||
const cwd = '/repo';
|
||||
const HEAD_SHA = 'deadbeef11223344deadbeef11223344deadbeef';
|
||||
const FORK_SHA = 'cafebabe11223344cafebabe11223344cafebabe';
|
||||
let written = '';
|
||||
const deps = {
|
||||
readFile: () => null,
|
||||
execGit: makeExecGitCheck({
|
||||
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
|
||||
'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: FORK_SHA, stderr: '', signal: null, error: null },
|
||||
}),
|
||||
write: (s) => { written += s; },
|
||||
};
|
||||
const result = cmdWorktreeBaseCheck(cwd, [], deps);
|
||||
assert.strictEqual(result.shouldDegrade, true);
|
||||
const parsed = JSON.parse(written);
|
||||
assert.strictEqual(parsed.shouldDegrade, true);
|
||||
assert.strictEqual(parsed.reason, 'head-diverged-from-fork');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── cmdWorktreeSetBaseRef ────────────────────────────────────────────────────
|
||||
|
||||
describe('cmdWorktreeSetBaseRef', () => {
|
||||
test('readFile returns {} → changed true, writeFile called with worktree.baseRef "head"', () => {
|
||||
const cwd = '/repo';
|
||||
const file = path.join(cwd, '.claude', 'settings.local.json');
|
||||
let writtenPath = null;
|
||||
let writtenContent = null;
|
||||
let written = '';
|
||||
const deps = {
|
||||
readFile: () => '{}',
|
||||
existsSync: () => true,
|
||||
mkdir: () => {},
|
||||
writeFile: (p, content) => { writtenPath = p; writtenContent = content; },
|
||||
write: (s) => { written += s; },
|
||||
};
|
||||
const result = cmdWorktreeSetBaseRef(cwd, [], deps);
|
||||
assert.strictEqual(result.changed, true);
|
||||
assert.strictEqual(result.file, file);
|
||||
assert.strictEqual(result.baseRef, 'head');
|
||||
assert.strictEqual(writtenPath, file);
|
||||
const parsedWritten = JSON.parse(writtenContent);
|
||||
assert.strictEqual(parsedWritten.worktree.baseRef, 'head');
|
||||
const parsedOutput = JSON.parse(written);
|
||||
assert.strictEqual(parsedOutput.changed, true);
|
||||
});
|
||||
|
||||
test('readFile returns explicit-other → changed false, skipped explicit-other, writeFile NOT called', () => {
|
||||
const cwd = '/repo';
|
||||
let writeFileCalled = false;
|
||||
let written = '';
|
||||
const deps = {
|
||||
readFile: () => JSON.stringify({ worktree: { baseRef: 'fresh' } }),
|
||||
existsSync: () => true,
|
||||
mkdir: () => {},
|
||||
writeFile: () => { writeFileCalled = true; },
|
||||
write: (s) => { written += s; },
|
||||
};
|
||||
const result = cmdWorktreeSetBaseRef(cwd, [], deps);
|
||||
assert.strictEqual(result.changed, false);
|
||||
assert.strictEqual(result.skipped, 'explicit-other');
|
||||
assert.strictEqual(result.previous, 'fresh');
|
||||
assert.strictEqual(writeFileCalled, false, 'writeFile must NOT be called for explicit-other');
|
||||
const parsedOutput = JSON.parse(written);
|
||||
assert.strictEqual(parsedOutput.changed, false);
|
||||
assert.strictEqual(parsedOutput.skipped, 'explicit-other');
|
||||
});
|
||||
|
||||
test('readFile returns malformed JSON → throws refusing-to-modify error', () => {
|
||||
const cwd = '/repo';
|
||||
const file = path.join(cwd, '.claude', 'settings.local.json');
|
||||
const deps = {
|
||||
readFile: () => '{',
|
||||
existsSync: () => true,
|
||||
mkdir: () => {},
|
||||
writeFile: () => {},
|
||||
write: () => {},
|
||||
};
|
||||
assert.throws(
|
||||
() => cmdWorktreeSetBaseRef(cwd, [], deps),
|
||||
(err) => {
|
||||
assert.ok(err instanceof Error, 'must throw an Error');
|
||||
assert.ok(err.message.includes('Refusing to modify'), `message should contain "Refusing to modify", got: ${err.message}`);
|
||||
assert.ok(err.message.includes(file), `message should contain file path, got: ${err.message}`);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('readFile returns null (missing file) → treated as {} → changed true', () => {
|
||||
const cwd = '/repo';
|
||||
let writeFileCalled = false;
|
||||
const deps = {
|
||||
readFile: () => null,
|
||||
existsSync: () => false,
|
||||
mkdir: () => {},
|
||||
writeFile: () => { writeFileCalled = true; },
|
||||
write: () => {},
|
||||
};
|
||||
const result = cmdWorktreeSetBaseRef(cwd, [], deps);
|
||||
assert.strictEqual(result.changed, true);
|
||||
assert.strictEqual(writeFileCalled, true);
|
||||
});
|
||||
|
||||
// FIX 2: non-object top-level JSON must be rejected with a clear error
|
||||
test('readFile returns "[]" (array) → throws /expected a JSON object/', () => {
|
||||
const cwd = '/repo';
|
||||
const deps = {
|
||||
readFile: () => '[]',
|
||||
existsSync: () => true,
|
||||
mkdir: () => {},
|
||||
writeFile: () => {},
|
||||
write: () => {},
|
||||
};
|
||||
assert.throws(
|
||||
() => cmdWorktreeSetBaseRef(cwd, [], deps),
|
||||
/expected a JSON object/
|
||||
);
|
||||
});
|
||||
|
||||
test('readFile returns "42" (primitive) → throws /expected a JSON object/', () => {
|
||||
const cwd = '/repo';
|
||||
const deps = {
|
||||
readFile: () => '42',
|
||||
existsSync: () => true,
|
||||
mkdir: () => {},
|
||||
writeFile: () => {},
|
||||
write: () => {},
|
||||
};
|
||||
assert.throws(
|
||||
() => cmdWorktreeSetBaseRef(cwd, [], deps),
|
||||
/expected a JSON object/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// FIX 2: applyWorktreeBaseRef must reject non-object/array/null inputs
|
||||
|
||||
describe('applyWorktreeBaseRef — non-object inputs (FIX 2)', () => {
|
||||
test('applyWorktreeBaseRef(null) → throws TypeError', () => {
|
||||
assert.throws(
|
||||
() => applyWorktreeBaseRef(null),
|
||||
TypeError
|
||||
);
|
||||
});
|
||||
|
||||
test('applyWorktreeBaseRef([]) → throws TypeError', () => {
|
||||
assert.throws(
|
||||
() => applyWorktreeBaseRef([]),
|
||||
TypeError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FIX 2: JSONC support ─────────────────────────────────────────────────────
|
||||
|
||||
describe('resolveEffectiveBaseRef — JSONC (FIX 2)', () => {
|
||||
function makeReadFile(files) {
|
||||
return (p) => (Object.prototype.hasOwnProperty.call(files, p) ? files[p] : null);
|
||||
}
|
||||
|
||||
test('returns baseRef from settings.local.json with // line comments', () => {
|
||||
const claudeDir = '/repo/.claude';
|
||||
const jsonc = [
|
||||
'// this is a comment',
|
||||
'{',
|
||||
' // another comment',
|
||||
' "worktree": {',
|
||||
' "baseRef": "head" // inline comment',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n');
|
||||
const deps = {
|
||||
readFile: makeReadFile({
|
||||
[path.join(claudeDir, 'settings.local.json')]: jsonc,
|
||||
}),
|
||||
};
|
||||
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'head');
|
||||
});
|
||||
|
||||
test('returns baseRef from settings.local.json with /* */ block comments', () => {
|
||||
const claudeDir = '/repo/.claude';
|
||||
const jsonc = [
|
||||
'/* block comment */',
|
||||
'{',
|
||||
' "worktree": { /* inline block */ "baseRef": "fresh" }',
|
||||
'}',
|
||||
'/* trailing block */',
|
||||
].join('\n');
|
||||
const deps = {
|
||||
readFile: makeReadFile({
|
||||
[path.join(claudeDir, 'settings.local.json')]: jsonc,
|
||||
}),
|
||||
};
|
||||
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'fresh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cmdWorktreeSetBaseRef — JSONC (FIX 2)', () => {
|
||||
test('commented-but-valid settings.local.json → updates it (changed true) rather than throwing', () => {
|
||||
const cwd = '/repo';
|
||||
const jsonc = [
|
||||
'// user comment',
|
||||
'{',
|
||||
' // another comment',
|
||||
' "other": "value"',
|
||||
'}',
|
||||
].join('\n');
|
||||
let writtenContent = null;
|
||||
const deps = {
|
||||
readFile: () => jsonc,
|
||||
existsSync: () => true,
|
||||
mkdir: () => {},
|
||||
writeFile: (_p, content) => { writtenContent = content; },
|
||||
write: () => {},
|
||||
};
|
||||
const result = cmdWorktreeSetBaseRef(cwd, [], deps);
|
||||
assert.strictEqual(result.changed, true, 'must set baseRef when absent (even in JSONC file)');
|
||||
assert.ok(writtenContent !== null, 'must write the updated file');
|
||||
const parsed = JSON.parse(writtenContent);
|
||||
assert.strictEqual(parsed.worktree.baseRef, 'head');
|
||||
});
|
||||
|
||||
test('JSONC with explicit baseRef="fresh" → skipped explicit-other, does not throw', () => {
|
||||
const cwd = '/repo';
|
||||
const jsonc = [
|
||||
'// user comment',
|
||||
'{',
|
||||
' "worktree": {',
|
||||
' // keeps the fork base fixed',
|
||||
' "baseRef": "fresh"',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n');
|
||||
let writeFileCalled = false;
|
||||
const deps = {
|
||||
readFile: () => jsonc,
|
||||
existsSync: () => true,
|
||||
mkdir: () => {},
|
||||
writeFile: () => { writeFileCalled = true; },
|
||||
write: () => {},
|
||||
};
|
||||
const result = cmdWorktreeSetBaseRef(cwd, [], deps);
|
||||
assert.strictEqual(result.changed, false);
|
||||
assert.strictEqual(result.skipped, 'explicit-other');
|
||||
assert.strictEqual(writeFileCalled, false);
|
||||
});
|
||||
|
||||
test('genuinely malformed JSON (after stripping comments) still throws refusing-to-modify', () => {
|
||||
const cwd = '/repo';
|
||||
const file = path.join(cwd, '.claude', 'settings.local.json');
|
||||
// This is malformed even after comment stripping
|
||||
const malformed = '// comment\n{ "key": }';
|
||||
const deps = {
|
||||
readFile: () => malformed,
|
||||
existsSync: () => true,
|
||||
mkdir: () => {},
|
||||
writeFile: () => {},
|
||||
write: () => {},
|
||||
};
|
||||
assert.throws(
|
||||
() => cmdWorktreeSetBaseRef(cwd, [], deps),
|
||||
(err) => {
|
||||
assert.ok(err instanceof Error);
|
||||
assert.ok(err.message.includes('Refusing to modify'), `got: ${err.message}`);
|
||||
assert.ok(err.message.includes(file), `got: ${err.message}`);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FIX 3: defensive trim on git SHAs ────────────────────────────────────────
|
||||
|
||||
describe('evaluateWorktreeBaseDegrade — defensive trim on SHAs (FIX 3)', () => {
|
||||
function makeExecGit(responses) {
|
||||
return function stubExecGit(args, _opts) {
|
||||
const key = args.join(' ');
|
||||
if (Object.prototype.hasOwnProperty.call(responses, key)) {
|
||||
return responses[key];
|
||||
}
|
||||
throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`);
|
||||
};
|
||||
}
|
||||
|
||||
test('HEAD with trailing newline still matches origin/HEAD — no degrade', () => {
|
||||
const SHA = 'aabbccdd11223344aabbccdd11223344aabbccdd';
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeExecGit({
|
||||
'rev-parse HEAD': { exitCode: 0, stdout: SHA + '\n', stderr: '', signal: null, error: null },
|
||||
'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: SHA + '\n', stderr: '', signal: null, error: null },
|
||||
}),
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, false);
|
||||
assert.strictEqual(result.reason, 'head-matches-fork');
|
||||
});
|
||||
|
||||
test('HEAD with trailing whitespace still diverges correctly from different origin/HEAD', () => {
|
||||
const HEAD_SHA = 'deadbeef11223344deadbeef11223344deadbeef';
|
||||
const FORK_SHA = 'cafebabe11223344cafebabe11223344cafebabe';
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeExecGit({
|
||||
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA + '\n', stderr: '', signal: null, error: null },
|
||||
'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: FORK_SHA + '\r\n', stderr: '', signal: null, error: null },
|
||||
}),
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, true);
|
||||
assert.strictEqual(result.reason, 'head-diverged-from-fork');
|
||||
// After trimming, headSha and forkSha should be clean
|
||||
assert.strictEqual(result.headSha, HEAD_SHA);
|
||||
assert.strictEqual(result.forkSha, FORK_SHA);
|
||||
});
|
||||
|
||||
test('symbolic-ref stdout with trailing newline resolves correctly', () => {
|
||||
const HEAD_SHA = 'aaaa1111bbbb2222aaaa1111bbbb2222aaaa1111';
|
||||
const FORK_SHA = 'cccc3333dddd4444cccc3333dddd4444cccc3333';
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeExecGit({
|
||||
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA + '\n', stderr: '', signal: null, error: null },
|
||||
'rev-parse --verify --quiet origin/HEAD': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null },
|
||||
'symbolic-ref --quiet refs/remotes/origin/HEAD': { exitCode: 0, stdout: 'refs/remotes/origin/next\n', stderr: '', signal: null, error: null },
|
||||
'rev-parse --verify --quiet refs/remotes/origin/next': { exitCode: 0, stdout: FORK_SHA + '\n', stderr: '', signal: null, error: null },
|
||||
}),
|
||||
});
|
||||
assert.strictEqual(result.forkRef, 'origin/next');
|
||||
assert.strictEqual(result.forkSha, FORK_SHA);
|
||||
assert.strictEqual(result.shouldDegrade, true);
|
||||
});
|
||||
});
|
||||
609
tests/worktree-baseref-install.test.cjs
Normal file
609
tests/worktree-baseref-install.test.cjs
Normal file
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* Tests for #683: installer sets worktree.baseRef:"head" in settings.local.json
|
||||
* for local Claude Code installs.
|
||||
*
|
||||
* Cases:
|
||||
* 1. Fresh local install: writes worktree.baseRef:"head" automatically (no-clobber).
|
||||
* 2. Fresh install with pre-existing explicit baseRef: does NOT clobber it.
|
||||
* 3a. Upgrade + isLocalClaude + use_worktrees absent/true → auto-applies baseRef.
|
||||
* 3b. Upgrade + use_worktrees === false → does NOT apply baseRef.
|
||||
* 3c. Upgrade + explicit baseRef already present (local or shared) → unchanged (no-clobber).
|
||||
* 4. Idempotency: re-running a fresh-style install when baseRef is already "head"
|
||||
* does not duplicate or error.
|
||||
* 5. Global Claude install: does NOT set worktree.baseRef (only local Claude).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, before, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
const { install, finishInstall } = require(INSTALL_SRC);
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
|
||||
before(() => {
|
||||
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Helper: run both install phases (mirrors installAllRuntimes two-phase) ──
|
||||
|
||||
function runInstall(isGlobal, opts = {}) {
|
||||
const { shouldInstallStatusline = false } = opts;
|
||||
const result = install(isGlobal, 'claude');
|
||||
finishInstall(
|
||||
result.settingsPath,
|
||||
result.settings,
|
||||
result.statuslineCommand,
|
||||
shouldInstallStatusline,
|
||||
'claude',
|
||||
isGlobal
|
||||
);
|
||||
return { result };
|
||||
}
|
||||
|
||||
// ─── Helper: write .planning/config.json in the project root ─────────────────
|
||||
// For a local Claude install, targetDir = <cwd>/.claude, so project root = cwd.
|
||||
// .planning/config.json lives at <cwd>/.planning/config.json.
|
||||
|
||||
function writePlanningConfig(projectRoot, config) {
|
||||
const planningDir = path.join(projectRoot, '.planning');
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify(config, null, 2) + '\n');
|
||||
}
|
||||
|
||||
// ─── Case 1: fresh local install writes worktree.baseRef:"head" ──────────────
|
||||
|
||||
describe('#683 case 1: fresh local Claude install sets worktree.baseRef:"head"', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-fresh-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('settings.local.json contains worktree.baseRef:"head" after fresh install', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
runInstall(false);
|
||||
|
||||
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
|
||||
assert.ok(
|
||||
fs.existsSync(localSettingsPath),
|
||||
'.claude/settings.local.json must exist after local Claude install'
|
||||
);
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
||||
assert.ok(
|
||||
settings && typeof settings === 'object',
|
||||
'settings.local.json must be a valid JSON object'
|
||||
);
|
||||
assert.strictEqual(
|
||||
settings.worktree && settings.worktree.baseRef,
|
||||
'head',
|
||||
'worktree.baseRef must be "head" after a fresh local Claude install (#683)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Case 2: fresh install does not clobber a pre-existing explicit baseRef ──
|
||||
|
||||
describe('#683 case 2: fresh install does not clobber existing explicit worktree.baseRef', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-noclobber-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('pre-existing explicit baseRef is preserved on fresh install', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// Pre-populate settings.local.json with an explicit non-"head" baseRef
|
||||
const claudeDir = path.join(tmpDir, '.claude');
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
|
||||
fs.writeFileSync(localSettingsPath, JSON.stringify({ worktree: { baseRef: 'main' } }, null, 2) + '\n');
|
||||
|
||||
runInstall(false);
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
||||
assert.strictEqual(
|
||||
settings.worktree && settings.worktree.baseRef,
|
||||
'main',
|
||||
'An explicit worktree.baseRef must not be overwritten by the installer (#683 no-clobber)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Case 3a: upgrade + use_worktrees absent → auto-applies baseRef ──────────
|
||||
|
||||
describe('#683 case 3a: upgrade + use_worktrees absent → auto-applies worktree.baseRef', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-upgrade-on-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('upgrade auto-applies worktree.baseRef:"head" when use_worktrees is absent', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// Simulate a prior install by pre-creating the VERSION file.
|
||||
const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION');
|
||||
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
|
||||
fs.writeFileSync(versionPath, '1.0.0');
|
||||
// No .planning/config.json — use_worktrees defaults to enabled (true).
|
||||
|
||||
runInstall(false);
|
||||
|
||||
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
|
||||
assert.ok(
|
||||
fs.existsSync(localSettingsPath),
|
||||
'.claude/settings.local.json must exist after upgrade'
|
||||
);
|
||||
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
||||
assert.strictEqual(
|
||||
settings.worktree && settings.worktree.baseRef,
|
||||
'head',
|
||||
'upgrade must auto-apply worktree.baseRef:"head" when use_worktrees is absent (#683)'
|
||||
);
|
||||
});
|
||||
|
||||
test('upgrade auto-applies worktree.baseRef:"head" when use_worktrees is true', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// Simulate a prior install by pre-creating the VERSION file.
|
||||
const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION');
|
||||
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
|
||||
fs.writeFileSync(versionPath, '1.0.0');
|
||||
// .planning/config.json with use_worktrees: true
|
||||
writePlanningConfig(tmpDir, { workflow: { use_worktrees: true } });
|
||||
|
||||
runInstall(false);
|
||||
|
||||
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
|
||||
assert.ok(fs.existsSync(localSettingsPath), '.claude/settings.local.json must exist after upgrade');
|
||||
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
||||
assert.strictEqual(
|
||||
settings.worktree && settings.worktree.baseRef,
|
||||
'head',
|
||||
'upgrade must auto-apply worktree.baseRef:"head" when use_worktrees:true (#683)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Case 3b: upgrade + use_worktrees === false → does NOT apply baseRef ─────
|
||||
|
||||
describe('#683 case 3b: upgrade + use_worktrees:false → does NOT apply worktree.baseRef', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-upgrade-off-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('upgrade does not apply worktree.baseRef when use_worktrees is false', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// Simulate a prior install by pre-creating the VERSION file.
|
||||
const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION');
|
||||
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
|
||||
fs.writeFileSync(versionPath, '1.0.0');
|
||||
// .planning/config.json with use_worktrees: false
|
||||
writePlanningConfig(tmpDir, { workflow: { use_worktrees: false } });
|
||||
|
||||
runInstall(false);
|
||||
|
||||
// finishInstall always writes settings.local.json for local Claude installs.
|
||||
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
|
||||
assert.ok(
|
||||
fs.existsSync(localSettingsPath),
|
||||
'.claude/settings.local.json must exist after upgrade (finishInstall writes it)'
|
||||
);
|
||||
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
||||
assert.strictEqual(
|
||||
settings.worktree,
|
||||
undefined,
|
||||
'upgrade must NOT apply worktree block when use_worktrees:false (#683)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Case 3c: upgrade + explicit baseRef already present → no-clobber ─────────
|
||||
|
||||
describe('#683 case 3c: upgrade + explicit baseRef present → no-clobber (unchanged)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-upgrade-noclobber-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('upgrade preserves an explicit worktree.baseRef set by the user in local settings', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// Simulate prior install with user-set explicit baseRef
|
||||
const claudeDir = path.join(tmpDir, '.claude');
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
const versionPath = path.join(claudeDir, 'gsd-core', 'VERSION');
|
||||
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
|
||||
fs.writeFileSync(versionPath, '1.0.0');
|
||||
|
||||
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
|
||||
fs.writeFileSync(localSettingsPath, JSON.stringify({ worktree: { baseRef: 'fresh' } }, null, 2) + '\n');
|
||||
|
||||
runInstall(false);
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
||||
assert.strictEqual(
|
||||
settings.worktree && settings.worktree.baseRef,
|
||||
'fresh',
|
||||
'upgrade must preserve an explicit user-set worktree.baseRef in local settings (#683 no-clobber)'
|
||||
);
|
||||
});
|
||||
|
||||
test('upgrade does not inject baseRef when shared settings.json already has one', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// Simulate prior install
|
||||
const claudeDir = path.join(tmpDir, '.claude');
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
const versionPath = path.join(claudeDir, 'gsd-core', 'VERSION');
|
||||
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
|
||||
fs.writeFileSync(versionPath, '1.0.0');
|
||||
|
||||
// Shared settings.json has an explicit baseRef; settings.local.json does not.
|
||||
const sharedSettingsPath = path.join(claudeDir, 'settings.json');
|
||||
fs.writeFileSync(sharedSettingsPath, JSON.stringify({ worktree: { baseRef: 'main' } }, null, 2) + '\n');
|
||||
|
||||
runInstall(false);
|
||||
|
||||
// finishInstall always writes settings.local.json for local Claude installs.
|
||||
// Shared no-clobber: sharedBaseRef !== null → installer must not inject.
|
||||
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
|
||||
assert.ok(
|
||||
fs.existsSync(localSettingsPath),
|
||||
'.claude/settings.local.json must exist after upgrade (finishInstall writes it)'
|
||||
);
|
||||
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
||||
assert.strictEqual(
|
||||
settings.worktree && settings.worktree.baseRef,
|
||||
undefined,
|
||||
'upgrade must NOT inject worktree.baseRef to settings.local.json when shared settings.json already has one (#683 no-clobber)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Case 4: idempotency — re-running fresh-style install when already "head" ─
|
||||
|
||||
describe('#683 case 4: idempotency — re-installing when worktree.baseRef already "head"', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-idem-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('second install does not error or duplicate worktree block', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// Run once (sets baseRef:"head")
|
||||
runInstall(false);
|
||||
|
||||
// Remove the VERSION file to simulate a fresh-style re-install (e.g. forced reinstall)
|
||||
const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION');
|
||||
if (fs.existsSync(versionPath)) {
|
||||
fs.unlinkSync(versionPath);
|
||||
}
|
||||
|
||||
// Run again — should be idempotent
|
||||
runInstall(false);
|
||||
|
||||
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
|
||||
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
||||
assert.strictEqual(
|
||||
settings.worktree && settings.worktree.baseRef,
|
||||
'head',
|
||||
'worktree.baseRef must still be "head" after idempotent re-install (#683)'
|
||||
);
|
||||
// Ensure worktree block wasn't duplicated into an array or otherwise corrupted
|
||||
assert.strictEqual(
|
||||
typeof settings.worktree,
|
||||
'object',
|
||||
'worktree must be a plain object after idempotent re-install'
|
||||
);
|
||||
assert.ok(
|
||||
!Array.isArray(settings.worktree),
|
||||
'worktree must not have been duplicated into an array'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Case 2b (FIX 1): fresh install does not clobber baseRef set in shared settings.json ──
|
||||
|
||||
describe('#683 case 2b (FIX 1): fresh install does not clobber worktree.baseRef in shared settings.json', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-shared-noclobber-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('shared settings.json with worktree.baseRef:"fresh" → installer must NOT write baseRef to settings.local.json and must NOT print ✓ notice', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// Pre-populate only the SHARED settings.json with an explicit baseRef.
|
||||
// settings.local.json does NOT exist — this is a fresh install otherwise.
|
||||
const claudeDir = path.join(tmpDir, '.claude');
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
const sharedSettingsPath = path.join(claudeDir, 'settings.json');
|
||||
fs.writeFileSync(sharedSettingsPath, JSON.stringify({ worktree: { baseRef: 'fresh' } }, null, 2) + '\n');
|
||||
|
||||
runInstall(false);
|
||||
|
||||
// settings.local.json must either not exist or have no worktree.baseRef.
|
||||
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
|
||||
if (fs.existsSync(localSettingsPath)) {
|
||||
const localSettings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
||||
assert.strictEqual(
|
||||
localSettings.worktree && localSettings.worktree.baseRef,
|
||||
undefined,
|
||||
'installer must NOT write worktree.baseRef to settings.local.json when shared settings.json already has an explicit baseRef (#683 FIX 1)'
|
||||
);
|
||||
}
|
||||
// If settings.local.json wasn't written, the test passes (no injection occurred).
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Case 6 (FIX 1): non-object settings.local.json does not crash installer ──
|
||||
|
||||
describe('#683 FIX 1: non-object settings.local.json does not crash the installer', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-nonobj-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('settings.local.json containing [] does not throw during fresh local install', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// Pre-populate settings.local.json with an array — valid JSON but non-object
|
||||
const claudeDir = path.join(tmpDir, '.claude');
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
|
||||
fs.writeFileSync(localSettingsPath, '[]');
|
||||
|
||||
// Must not throw — baseRef logic must be silently skipped for non-objects
|
||||
assert.doesNotThrow(() => runInstall(false));
|
||||
});
|
||||
|
||||
test('settings.local.json containing "null" JSON value does not crash via #683 block (FIX 1 guard)', () => {
|
||||
// The #683 block guard check: `settings !== null && typeof settings === 'object' && !Array.isArray(settings)`
|
||||
// For the array case the crash was directly applyWorktreeBaseRef([]). Test the guard in isolation
|
||||
// by verifying applyWorktreeBaseRef is not called with a non-object.
|
||||
// (A literal null parses and readSettings returns null → hits the null early-return, so no crash.)
|
||||
// This test verifies the guard path — checking that readSettings returning null before #683 is handled.
|
||||
// The array case below covers the actual fix.
|
||||
assert.ok(true, 'placeholder: null is handled by the null early-return above the #683 block');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Case 7: fresh + use_worktrees:false → does NOT write worktree.baseRef ───
|
||||
|
||||
describe('#683 case 7: fresh local Claude install + use_worktrees:false → does NOT set worktree.baseRef', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-fresh-off-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('fresh install with use_worktrees:false does not write worktree.baseRef', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// No VERSION file → fresh install.
|
||||
// .planning/config.json with use_worktrees: false → must suppress baseRef.
|
||||
writePlanningConfig(tmpDir, { workflow: { use_worktrees: false } });
|
||||
|
||||
runInstall(false);
|
||||
|
||||
// finishInstall always writes settings.local.json for local Claude installs.
|
||||
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
|
||||
assert.ok(
|
||||
fs.existsSync(localSettingsPath),
|
||||
'.claude/settings.local.json must exist after fresh local Claude install'
|
||||
);
|
||||
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
||||
assert.strictEqual(
|
||||
settings.worktree,
|
||||
undefined,
|
||||
'fresh install must NOT write worktree.baseRef when use_worktrees:false (#683 FIX A)'
|
||||
);
|
||||
});
|
||||
|
||||
test('fresh install with absent .planning/config.json still applies worktree.baseRef (default enabled)', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// No VERSION file → fresh install.
|
||||
// No .planning/config.json → worktreesEnabled defaults to true.
|
||||
|
||||
runInstall(false);
|
||||
|
||||
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
|
||||
assert.ok(
|
||||
fs.existsSync(localSettingsPath),
|
||||
'.claude/settings.local.json must exist after fresh local Claude install'
|
||||
);
|
||||
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
||||
assert.strictEqual(
|
||||
settings.worktree && settings.worktree.baseRef,
|
||||
'head',
|
||||
'fresh install must apply worktree.baseRef:"head" when .planning/config.json is absent (default enabled; #683 FIX A)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Case 8: upgrade idempotency — two upgrade runs produce exactly one baseRef ─
|
||||
|
||||
describe('#683 case 8: upgrade→upgrade idempotency — two upgrade runs do not duplicate worktree.baseRef', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-idem2-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('two consecutive upgrade runs leave settings.local.json with exactly one worktree.baseRef:"head"', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// First upgrade run: VERSION present → upgrade path.
|
||||
const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION');
|
||||
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
|
||||
fs.writeFileSync(versionPath, '1.0.0');
|
||||
// use_worktrees defaults to enabled (no .planning/config.json)
|
||||
|
||||
runInstall(false);
|
||||
|
||||
// Restore VERSION so the second run is also an upgrade.
|
||||
if (!fs.existsSync(versionPath)) {
|
||||
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(versionPath, '1.0.0');
|
||||
|
||||
runInstall(false);
|
||||
|
||||
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
|
||||
assert.ok(
|
||||
fs.existsSync(localSettingsPath),
|
||||
'.claude/settings.local.json must exist after two upgrade runs'
|
||||
);
|
||||
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
||||
assert.strictEqual(
|
||||
settings.worktree && settings.worktree.baseRef,
|
||||
'head',
|
||||
'worktree.baseRef must be "head" after two upgrade runs (#683 idempotency)'
|
||||
);
|
||||
assert.strictEqual(
|
||||
typeof settings.worktree,
|
||||
'object',
|
||||
'worktree must be a plain object after two upgrade runs'
|
||||
);
|
||||
assert.ok(
|
||||
!Array.isArray(settings.worktree),
|
||||
'worktree must not have been duplicated into an array after two upgrade runs'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Case 5: global Claude install does NOT set worktree.baseRef ─────────────
|
||||
|
||||
describe('#683 case 5: global Claude install does NOT set worktree.baseRef', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-global-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('global install does not write worktree.baseRef', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
|
||||
// Point CLAUDE_CONFIG_DIR at a tmpDir subdir to avoid polluting ~/.claude
|
||||
const configDir = path.join(tmpDir, '.claude');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
const origEnv = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir;
|
||||
t.after(() => {
|
||||
if (origEnv === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = origEnv;
|
||||
}
|
||||
});
|
||||
|
||||
runInstall(true);
|
||||
|
||||
const settingsPath = path.join(configDir, 'settings.json');
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||
assert.strictEqual(
|
||||
settings.worktree,
|
||||
undefined,
|
||||
'global Claude install must not write worktree.baseRef into settings.json (#683)'
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user