* fix(#4192): honor explicit model pins on the claude runtime Two documented model-configuration contracts did not hold on the claude runtime (confirmed-bug scope from the issue triage): Finding 1 — model_profile_overrides.claude.<tier> was inert. Step 3 of resolveModelInternal gated runtime-aware tier resolution on configRuntime !== 'claude', so the key's only reader was never consulted, while workflows/settings-advanced.md writes it for claude-runtime users. A new step 4.5 resolves ONLY the user's override entry (never the builtin claude tier map, so unpinned installs keep resolving aliases). An override value that maps to a current tier alias collapses to that alias (byte-equivalent, the #2041 protection); anything else — a pinned older generation, a bare alias repoint, a non-Anthropic id — resolves verbatim. It sits after the resolve_model_ids:'omit' gate so an explicit project omit still wins (#2297) and before the alias return so resolve_model_ids:true cannot re-materialize the pin to the latest id. Finding 2 — fully-qualified claude-* ids in model_overrides were warn-dropped to tier resolution (mapClaudeOverrideForRuntime unmappable branch, #2041), while the docs promise any fully-qualified model id is valid. The unmappable branch now passes the pin through verbatim with a warn-once breadcrumb (text describes the pass-through). Dropping it silently unpinned the operator's explicit choice — the exact 'profile can misrepresent what actually runs' defect of #4192. Mappable ids and non-claude values behave exactly as before; resolveModelForTier shares the mapping; the tier honesty signal is unchanged (raw ids still report 'unknown'); the model_policy path is untouched. Docs updated to the agreed contract (CONFIGURATION.md false 'Claude example' corrected; how-to + shipped reference document the pin semantics, the fable alias, and the tier-override composition). * test(#4192): pin explicit model pin resolution on the claude runtime 28 failing-first rows across the resolver seam and the resolve-model CLI: pinned-generation fidelity (tier override + per-agent verbatim pins, object form, explicit runtime), unpinned controls byte-stable (no override, other runtime/tier, inherit, project omit, precedence), adversarial rows (prototype-chain keys, malformed values, warn-once dedupe, 64-char stderr cap), and behavioral AC1/AC2 rows through runGsdTools. The stale #2041 fall-through assertions now pin the pass-through contract; mappable-id collapse assertions unchanged. * chore(#4192): add changeset fragment * chore(#4192): backfill PR number in changeset fragment --------- Co-authored-by: ZCode <zcode@localhost>
6224 lines
277 KiB
JavaScript
6224 lines
277 KiB
JavaScript
// allow-test-rule: source-text-is-the-product
|
||
// Reads .md/.json/.yml product files whose deployed text IS what the
|
||
// runtime loads — testing text content tests the deployed contract.
|
||
// docs-guard-exempt: 'docs/x.md' below is a synthetic fixture path fed into
|
||
// groupFilesBySubrepo() to exercise its subrepo-grouping logic — no real
|
||
// docs/ file is ever read or asserted on for content.
|
||
|
||
/**
|
||
* GSD Tools Tests - Commands
|
||
*/
|
||
|
||
const { test, describe, after, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { runGsdTools, createTempProject, createTempDir, cleanup } = require('./helpers.cjs');
|
||
const { splitLines } = require('../gsd-core/bin/lib/text-lines.cjs');
|
||
const fc = require('./helpers/fast-check-setup.cjs');
|
||
const { gitOrThrow, throwIfFailed } = require('./helpers/git-fixture.cjs');
|
||
const { runNode } = require('./helpers/process-seam.cjs');
|
||
|
||
describe('history-digest command', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('empty phases directory returns valid schema', () => {
|
||
const result = runGsdTools('history-digest', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const digest = JSON.parse(result.output);
|
||
|
||
assert.deepStrictEqual(digest.phases, {}, 'phases should be empty object');
|
||
assert.deepStrictEqual(digest.decisions, [], 'decisions should be empty array');
|
||
assert.deepStrictEqual(digest.tech_stack, [], 'tech_stack should be empty array');
|
||
});
|
||
|
||
test('nested frontmatter fields extracted correctly', () => {
|
||
// Create phase directory with SUMMARY containing nested frontmatter
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
const summaryContent = `---
|
||
phase: "01"
|
||
name: "Foundation Setup"
|
||
dependency-graph:
|
||
provides:
|
||
- "Database schema"
|
||
- "Auth system"
|
||
affects:
|
||
- "API layer"
|
||
tech-stack:
|
||
added:
|
||
- "prisma"
|
||
- "jose"
|
||
patterns-established:
|
||
- "Repository pattern"
|
||
- "JWT auth flow"
|
||
key-decisions:
|
||
- "Use Prisma over Drizzle"
|
||
- "JWT in httpOnly cookies"
|
||
---
|
||
|
||
# Summary content here
|
||
`;
|
||
|
||
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), summaryContent);
|
||
|
||
const result = runGsdTools('history-digest', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const digest = JSON.parse(result.output);
|
||
|
||
// Check nested dependency-graph.provides
|
||
assert.ok(digest.phases['01'], 'Phase 01 should exist');
|
||
assert.deepStrictEqual(
|
||
digest.phases['01'].provides.sort(),
|
||
['Auth system', 'Database schema'],
|
||
'provides should contain nested values'
|
||
);
|
||
|
||
// Check nested dependency-graph.affects
|
||
assert.deepStrictEqual(
|
||
digest.phases['01'].affects,
|
||
['API layer'],
|
||
'affects should contain nested values'
|
||
);
|
||
|
||
// Check nested tech-stack.added
|
||
assert.deepStrictEqual(
|
||
digest.tech_stack.sort(),
|
||
['jose', 'prisma'],
|
||
'tech_stack should contain nested values'
|
||
);
|
||
|
||
// Check patterns-established (flat array)
|
||
assert.deepStrictEqual(
|
||
digest.phases['01'].patterns.sort(),
|
||
['JWT auth flow', 'Repository pattern'],
|
||
'patterns should be extracted'
|
||
);
|
||
|
||
// Check key-decisions
|
||
assert.strictEqual(digest.decisions.length, 2, 'Should have 2 decisions');
|
||
assert.ok(
|
||
digest.decisions.some(d => d.decision === 'Use Prisma over Drizzle'),
|
||
'Should contain first decision'
|
||
);
|
||
});
|
||
|
||
test('multiple phases merged into single digest', () => {
|
||
// Create phase 01
|
||
const phase01Dir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phase01Dir, { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(phase01Dir, '01-01-SUMMARY.md'),
|
||
`---
|
||
phase: "01"
|
||
name: "Foundation"
|
||
provides:
|
||
- "Database"
|
||
patterns-established:
|
||
- "Pattern A"
|
||
key-decisions:
|
||
- "Decision 1"
|
||
---
|
||
`
|
||
);
|
||
|
||
// Create phase 02
|
||
const phase02Dir = path.join(tmpDir, '.planning', 'phases', '02-api');
|
||
fs.mkdirSync(phase02Dir, { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(phase02Dir, '02-01-SUMMARY.md'),
|
||
`---
|
||
phase: "02"
|
||
name: "API"
|
||
provides:
|
||
- "REST endpoints"
|
||
patterns-established:
|
||
- "Pattern B"
|
||
key-decisions:
|
||
- "Decision 2"
|
||
tech-stack:
|
||
added:
|
||
- "zod"
|
||
---
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('history-digest', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const digest = JSON.parse(result.output);
|
||
|
||
// Both phases present
|
||
assert.ok(digest.phases['01'], 'Phase 01 should exist');
|
||
assert.ok(digest.phases['02'], 'Phase 02 should exist');
|
||
|
||
// Decisions merged
|
||
assert.strictEqual(digest.decisions.length, 2, 'Should have 2 decisions total');
|
||
|
||
// Tech stack merged
|
||
assert.deepStrictEqual(digest.tech_stack, ['zod'], 'tech_stack should have zod');
|
||
});
|
||
|
||
test('malformed SUMMARY.md skipped gracefully', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
// Valid summary
|
||
fs.writeFileSync(
|
||
path.join(phaseDir, '01-01-SUMMARY.md'),
|
||
`---
|
||
phase: "01"
|
||
provides:
|
||
- "Valid feature"
|
||
---
|
||
`
|
||
);
|
||
|
||
// Malformed summary (no frontmatter)
|
||
fs.writeFileSync(
|
||
path.join(phaseDir, '01-02-SUMMARY.md'),
|
||
`# Just a heading
|
||
No frontmatter here
|
||
`
|
||
);
|
||
|
||
// Another malformed summary (broken YAML)
|
||
fs.writeFileSync(
|
||
path.join(phaseDir, '01-03-SUMMARY.md'),
|
||
`---
|
||
broken: [unclosed
|
||
---
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('history-digest', tmpDir);
|
||
assert.ok(result.success, `Command should succeed despite malformed files: ${result.error}`);
|
||
|
||
const digest = JSON.parse(result.output);
|
||
assert.ok(digest.phases['01'], 'Phase 01 should exist');
|
||
assert.ok(
|
||
digest.phases['01'].provides.includes('Valid feature'),
|
||
'Valid feature should be extracted'
|
||
);
|
||
});
|
||
|
||
test('flat provides field still works (backward compatibility)', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(
|
||
path.join(phaseDir, '01-01-SUMMARY.md'),
|
||
`---
|
||
phase: "01"
|
||
provides:
|
||
- "Direct provides"
|
||
---
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('history-digest', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const digest = JSON.parse(result.output);
|
||
assert.deepStrictEqual(
|
||
digest.phases['01'].provides,
|
||
['Direct provides'],
|
||
'Direct provides should work'
|
||
);
|
||
});
|
||
|
||
test('inline array syntax supported', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(
|
||
path.join(phaseDir, '01-01-SUMMARY.md'),
|
||
`---
|
||
phase: "01"
|
||
provides: [Feature A, Feature B]
|
||
patterns-established: ["Pattern X", "Pattern Y"]
|
||
---
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('history-digest', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const digest = JSON.parse(result.output);
|
||
assert.deepStrictEqual(
|
||
digest.phases['01'].provides.sort(),
|
||
['Feature A', 'Feature B'],
|
||
'Inline array should work'
|
||
);
|
||
assert.deepStrictEqual(
|
||
digest.phases['01'].patterns.sort(),
|
||
['Pattern X', 'Pattern Y'],
|
||
'Inline quoted array should work'
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// phases list command
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
describe('summary-extract command', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('missing file returns error', () => {
|
||
const result = runGsdTools('summary-extract .planning/phases/01-test/01-01-SUMMARY.md', tmpDir);
|
||
assert.ok(result.success, `Command should succeed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.error, 'File not found', 'should report missing file');
|
||
});
|
||
|
||
test('extracts all fields from SUMMARY.md', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(
|
||
path.join(phaseDir, '01-01-SUMMARY.md'),
|
||
`---
|
||
one-liner: Set up Prisma with User and Project models
|
||
key-files:
|
||
- prisma/schema.prisma
|
||
- src/lib/db.ts
|
||
tech-stack:
|
||
added:
|
||
- prisma
|
||
- zod
|
||
patterns-established:
|
||
- Repository pattern
|
||
- Dependency injection
|
||
key-decisions:
|
||
- Use Prisma over Drizzle: Better DX and ecosystem
|
||
- Single database: Start simple, shard later
|
||
requirements-completed:
|
||
- AUTH-01
|
||
- AUTH-02
|
||
---
|
||
|
||
# Summary
|
||
|
||
Full summary content here.
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.path, '.planning/phases/01-foundation/01-01-SUMMARY.md', 'path correct');
|
||
assert.strictEqual(output.one_liner, 'Set up Prisma with User and Project models', 'one-liner extracted');
|
||
assert.deepStrictEqual(output.key_files, ['prisma/schema.prisma', 'src/lib/db.ts'], 'key files extracted');
|
||
assert.deepStrictEqual(output.tech_added, ['prisma', 'zod'], 'tech added extracted');
|
||
assert.deepStrictEqual(output.patterns, ['Repository pattern', 'Dependency injection'], 'patterns extracted');
|
||
assert.strictEqual(output.decisions.length, 2, 'decisions extracted');
|
||
assert.deepStrictEqual(output.requirements_completed, ['AUTH-01', 'AUTH-02'], 'requirements completed extracted');
|
||
});
|
||
|
||
test('selective extraction with --fields', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(
|
||
path.join(phaseDir, '01-01-SUMMARY.md'),
|
||
`---
|
||
one-liner: Set up database
|
||
key-files:
|
||
- prisma/schema.prisma
|
||
tech-stack:
|
||
added:
|
||
- prisma
|
||
patterns-established:
|
||
- Repository pattern
|
||
key-decisions:
|
||
- Use Prisma: Better DX
|
||
requirements-completed:
|
||
- AUTH-01
|
||
---
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md --fields one_liner,key_files,requirements_completed', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.one_liner, 'Set up database', 'one_liner included');
|
||
assert.deepStrictEqual(output.key_files, ['prisma/schema.prisma'], 'key_files included');
|
||
assert.deepStrictEqual(output.requirements_completed, ['AUTH-01'], 'requirements_completed included');
|
||
assert.strictEqual(output.tech_added, undefined, 'tech_added excluded');
|
||
assert.strictEqual(output.patterns, undefined, 'patterns excluded');
|
||
assert.strictEqual(output.decisions, undefined, 'decisions excluded');
|
||
});
|
||
|
||
test('extracts one-liner from body when not in frontmatter', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(
|
||
path.join(phaseDir, '01-01-SUMMARY.md'),
|
||
`---
|
||
phase: "01"
|
||
key-files:
|
||
- src/lib/db.ts
|
||
---
|
||
|
||
# Phase 1: Foundation Summary
|
||
|
||
**JWT auth with refresh rotation using jose library**
|
||
|
||
## Performance
|
||
|
||
- **Duration:** 28 min
|
||
- **Tasks:** 5
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.one_liner, 'JWT auth with refresh rotation using jose library',
|
||
'one-liner should be extracted from body **bold** line');
|
||
});
|
||
|
||
test('handles missing frontmatter fields gracefully', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(
|
||
path.join(phaseDir, '01-01-SUMMARY.md'),
|
||
`---
|
||
one-liner: Minimal summary
|
||
---
|
||
|
||
# Summary
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.one_liner, 'Minimal summary', 'one-liner extracted');
|
||
assert.deepStrictEqual(output.key_files, [], 'key_files defaults to empty');
|
||
assert.deepStrictEqual(output.tech_added, [], 'tech_added defaults to empty');
|
||
assert.deepStrictEqual(output.patterns, [], 'patterns defaults to empty');
|
||
assert.deepStrictEqual(output.decisions, [], 'decisions defaults to empty');
|
||
assert.deepStrictEqual(output.requirements_completed, [], 'requirements_completed defaults to empty');
|
||
});
|
||
|
||
test('reads requirements in snake_case form the tool itself emits (#628)', () => {
|
||
// Regression: the tool's JSON output key and the milestone-audit `--pick` both use the
|
||
// snake form `requirements_completed`, so operators naturally write that into SUMMARY
|
||
// frontmatter. The reader must accept it, not silently drop it to [].
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(
|
||
path.join(phaseDir, '01-01-SUMMARY.md'),
|
||
`---
|
||
one-liner: Snake-keyed summary
|
||
requirements_completed:
|
||
- REQ-1
|
||
- REQ-2
|
||
---
|
||
|
||
# Summary
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.deepStrictEqual(output.requirements_completed, ['REQ-1', 'REQ-2'],
|
||
'snake-case requirements_completed should be read, not dropped to []');
|
||
});
|
||
|
||
test('prefers kebab requirements-completed when both key forms are present (#628)', () => {
|
||
// kebab is the documented template form and must win the tolerance fallback.
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(
|
||
path.join(phaseDir, '01-01-SUMMARY.md'),
|
||
`---
|
||
one-liner: Both key forms present
|
||
requirements-completed:
|
||
- KEBAB-1
|
||
requirements_completed:
|
||
- SNAKE-1
|
||
---
|
||
|
||
# Summary
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.deepStrictEqual(output.requirements_completed, ['KEBAB-1'],
|
||
'kebab key should take precedence over snake when both are present');
|
||
});
|
||
|
||
test('parses key-decisions with rationale', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
|
||
fs.writeFileSync(
|
||
path.join(phaseDir, '01-01-SUMMARY.md'),
|
||
`---
|
||
key-decisions:
|
||
- Use Prisma: Better DX than alternatives
|
||
- JWT tokens: Stateless auth for scalability
|
||
---
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.decisions[0].summary, 'Use Prisma', 'decision summary parsed');
|
||
assert.strictEqual(output.decisions[0].rationale, 'Better DX than alternatives', 'decision rationale parsed');
|
||
assert.strictEqual(output.decisions[1].summary, 'JWT tokens', 'second decision summary');
|
||
assert.strictEqual(output.decisions[1].rationale, 'Stateless auth for scalability', 'second decision rationale');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// init commands tests
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
describe('progress command', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('renders JSON progress', () => {
|
||
// #3217: no version token — genuinely free-form, so windowing scope is
|
||
// COMPLETE (§7.1) rather than UNSCOPED (a title merely mentioning "v1.0"
|
||
// with no STATE.md milestone pointer cannot be windowed to that version).
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
`# Roadmap MVP\n`
|
||
);
|
||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(p1, { recursive: true });
|
||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Done');
|
||
fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan 2');
|
||
|
||
const result = runGsdTools('progress json', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.total_plans, 2, '2 total plans');
|
||
assert.strictEqual(output.total_summaries, 1, '1 summary');
|
||
assert.strictEqual(output.percent, 50, '50%');
|
||
assert.strictEqual(output.phases.length, 1, '1 phase');
|
||
assert.strictEqual(output.phases[0].status, 'In Progress', 'phase in progress');
|
||
});
|
||
|
||
test('renders bar format', () => {
|
||
// #3217: no version token — see 'renders JSON progress' above.
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
`# Roadmap\n`
|
||
);
|
||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-test');
|
||
fs.mkdirSync(p1, { recursive: true });
|
||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Done');
|
||
|
||
const result = runGsdTools('progress bar --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
assert.ok(result.output.includes('1/1'), 'should include count');
|
||
assert.ok(result.output.includes('100%'), 'should include 100%');
|
||
});
|
||
|
||
test('renders table format', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
`# Roadmap v1.0 MVP\n`
|
||
);
|
||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(p1, { recursive: true });
|
||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||
|
||
const result = runGsdTools('progress table --raw', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
assert.ok(result.output.includes('Phase'), 'should have table header');
|
||
assert.ok(result.output.includes('foundation'), 'should include phase name');
|
||
});
|
||
|
||
test('does not crash when summaries exceed plans (orphaned SUMMARY.md)', () => {
|
||
// #3217: no version token — see 'renders JSON progress' above.
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
`# Roadmap MVP\n`
|
||
);
|
||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||
fs.mkdirSync(p1, { recursive: true });
|
||
// 1 plan but 2 summaries (orphaned SUMMARY.md after PLAN.md deletion)
|
||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Done');
|
||
fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Orphaned summary');
|
||
|
||
// bar format - should not crash with RangeError
|
||
const barResult = runGsdTools('progress bar --raw', tmpDir);
|
||
assert.ok(barResult.success, `Bar format crashed: ${barResult.error}`);
|
||
assert.ok(barResult.output.includes('100%'), 'percent should be clamped to 100%');
|
||
|
||
// table format - should not crash with RangeError
|
||
const tableResult = runGsdTools('progress table --raw', tmpDir);
|
||
assert.ok(tableResult.success, `Table format crashed: ${tableResult.error}`);
|
||
|
||
// json format - percent should be clamped
|
||
const jsonResult = runGsdTools('progress json', tmpDir);
|
||
assert.ok(jsonResult.success, `JSON format crashed: ${jsonResult.error}`);
|
||
const output = JSON.parse(jsonResult.output);
|
||
assert.ok(output.percent <= 100, `percent should be <= 100 but got ${output.percent}`);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// todo complete command
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
describe('todo complete command', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('moves todo from pending to completed', () => {
|
||
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
||
fs.mkdirSync(pendingDir, { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(pendingDir, 'add-dark-mode.md'),
|
||
`title: Add dark mode\narea: ui\ncreated: 2025-01-01\n`
|
||
);
|
||
|
||
const result = runGsdTools('todo complete add-dark-mode.md', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.completed, true);
|
||
|
||
// Verify moved
|
||
assert.ok(
|
||
!fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'pending', 'add-dark-mode.md')),
|
||
'should be removed from pending'
|
||
);
|
||
assert.ok(
|
||
fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'completed', 'add-dark-mode.md')),
|
||
'should be in completed'
|
||
);
|
||
|
||
// Verify completion timestamp added — #4096: the completed/status keys must
|
||
// live INSIDE a well-formed frontmatter block (line 1 is the opening fence),
|
||
// never above it.
|
||
const content = fs.readFileSync(
|
||
path.join(tmpDir, '.planning', 'todos', 'completed', 'add-dark-mode.md'),
|
||
'utf-8'
|
||
);
|
||
assert.ok(content.startsWith('---\n'), 'should open with a frontmatter fence');
|
||
assert.match(content, /^completed: \d{4}-\d{2}-\d{2}$/m);
|
||
});
|
||
|
||
test('fails for nonexistent todo', () => {
|
||
const result = runGsdTools('todo complete nonexistent.md', tmpDir);
|
||
assert.ok(!result.success, 'should fail');
|
||
assert.ok(result.error.includes('not found'), 'error mentions not found');
|
||
});
|
||
|
||
// #4096 regressions — --dry-run must not mutate, and completion keys must be
|
||
// written inside the frontmatter fence.
|
||
test('--dry-run previews without moving the file or mutating anything', () => {
|
||
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
||
fs.mkdirSync(pendingDir, { recursive: true });
|
||
const source = path.join(pendingDir, 'dry-run-probe.md');
|
||
fs.writeFileSync(source, '---\ntitle: probe\nstatus: pending\n---\n\n# body\n');
|
||
|
||
const result = runGsdTools('todo complete dry-run-probe.md --dry-run', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.dry_run, true, 'payload must be preview-shaped (dry_run)');
|
||
assert.strictEqual(output.would_complete, true, 'payload must say would_complete');
|
||
assert.strictEqual('completed' in output, false, 'preview must never report completed:true');
|
||
assert.strictEqual(output.file, 'dry-run-probe.md');
|
||
assert.match(output.date, /^\d{4}-\d{2}-\d{2}$/);
|
||
|
||
// File untouched, in place; nothing written to completed/.
|
||
assert.ok(fs.existsSync(source), 'pending file must still exist under --dry-run');
|
||
const completedPath = path.join(tmpDir, '.planning', 'todos', 'completed', 'dry-run-probe.md');
|
||
assert.ok(!fs.existsSync(completedPath), 'no completed copy under --dry-run');
|
||
assert.strictEqual(
|
||
fs.readFileSync(source, 'utf-8'),
|
||
'---\ntitle: probe\nstatus: pending\n---\n\n# body\n',
|
||
'source bytes must be unchanged under --dry-run'
|
||
);
|
||
});
|
||
|
||
test('--dry-run still fails for nonexistent todo', () => {
|
||
const result = runGsdTools('todo complete nonexistent.md --dry-run', tmpDir);
|
||
assert.ok(!result.success, 'dry-run must still run existence checks');
|
||
assert.ok(result.error.includes('not found'), 'error mentions not found');
|
||
});
|
||
|
||
test('writes completed and status: completed inside the frontmatter fence', () => {
|
||
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
||
fs.mkdirSync(pendingDir, { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(pendingDir, 'fence-probe.md'),
|
||
'---\ntitle: fence probe\nstatus: pending\ncreated: 2025-01-01\n---\n\n# body\n'
|
||
);
|
||
|
||
const result = runGsdTools('todo complete fence-probe.md', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const content = fs.readFileSync(
|
||
path.join(tmpDir, '.planning', 'todos', 'completed', 'fence-probe.md'),
|
||
'utf-8'
|
||
);
|
||
// Line 1 must remain the opening fence (#4096 defect 2).
|
||
assert.ok(content.startsWith('---\n'), 'opening fence must stay on line 1');
|
||
const lines = splitLines(content);
|
||
const closeIdx = lines.indexOf('---', 1);
|
||
assert.ok(closeIdx > 0, 'closing fence must exist');
|
||
const fm = lines.slice(1, closeIdx);
|
||
assert.ok(fm.includes('status: completed'), 'status: completed must be inside the fence');
|
||
assert.strictEqual(fm.filter(l => /^completed: /.test(l)).length, 1,
|
||
'exactly one completed: line, inside the fence');
|
||
// Other frontmatter keys survive.
|
||
assert.ok(fm.some(l => l.startsWith('title:')), 'existing keys preserved');
|
||
// Body preserved after the block.
|
||
assert.ok(content.includes('# body'), 'body preserved');
|
||
});
|
||
|
||
test('upserts an existing completed field instead of duplicating it', () => {
|
||
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
||
fs.mkdirSync(pendingDir, { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(pendingDir, 'recomplete-probe.md'),
|
||
'---\ntitle: recomplete\nstatus: pending\ncompleted: 2020-01-01\n---\n\n# body\n'
|
||
);
|
||
|
||
const result = runGsdTools('todo complete recomplete-probe.md', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const content = fs.readFileSync(
|
||
path.join(tmpDir, '.planning', 'todos', 'completed', 'recomplete-probe.md'),
|
||
'utf-8'
|
||
);
|
||
assert.ok(content.startsWith('---\n'), 'opening fence must stay on line 1');
|
||
assert.ok(!content.includes('completed: 2020-01-01'), 'stale completed value replaced');
|
||
assert.strictEqual(
|
||
(content.match(/^completed: /gm) || []).length, 1,
|
||
'exactly one completed: occurrence'
|
||
);
|
||
});
|
||
|
||
test('wraps a frontmatter-less todo in a complete frontmatter block', () => {
|
||
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
||
fs.mkdirSync(pendingDir, { recursive: true });
|
||
fs.writeFileSync(path.join(pendingDir, 'bare.md'), '# just a body\n');
|
||
|
||
const result = runGsdTools('todo complete bare.md', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const content = fs.readFileSync(
|
||
path.join(tmpDir, '.planning', 'todos', 'completed', 'bare.md'),
|
||
'utf-8'
|
||
);
|
||
// #4096 fix-2: no bare prefix line — a complete frontmatter block instead.
|
||
assert.ok(content.startsWith('---\n'), 'frontmatter block must open the file');
|
||
const lines = splitLines(content);
|
||
const closeIdx = lines.indexOf('---', 1);
|
||
assert.ok(closeIdx > 0, 'closing fence must exist');
|
||
const fm = lines.slice(1, closeIdx);
|
||
assert.ok(fm.some(l => /^completed: \d{4}-\d{2}-\d{2}$/.test(l)), 'completed inside block');
|
||
assert.ok(fm.includes('status: completed'), 'status: completed inside block');
|
||
assert.ok(content.includes('# just a body'), 'body preserved');
|
||
});
|
||
|
||
test('rejects unknown flags instead of silently completing', () => {
|
||
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
||
fs.mkdirSync(pendingDir, { recursive: true });
|
||
fs.writeFileSync(path.join(pendingDir, 'flag-probe.md'), '---\nstatus: pending\n---\n');
|
||
|
||
const result = runGsdTools('todo complete flag-probe.md --bogus-flag', tmpDir);
|
||
assert.ok(!result.success, 'unknown flag must fail loudly');
|
||
assert.ok(result.error.includes('Unknown flag'), 'error mentions Unknown flag');
|
||
// And crucially: nothing moved.
|
||
assert.ok(
|
||
fs.existsSync(path.join(pendingDir, 'flag-probe.md')),
|
||
'file must not move when a flag is rejected'
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// todo match-phase command
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('todo match-phase command', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
afterEach(() => cleanup(tmpDir));
|
||
|
||
test('returns empty matches when no todos exist', () => {
|
||
const result = runGsdTools('todo match-phase 01', tmpDir);
|
||
assert.ok(result.success, 'should succeed');
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.todo_count, 0);
|
||
assert.deepStrictEqual(output.matches, []);
|
||
});
|
||
|
||
test('matches todo by keyword overlap with phase name', () => {
|
||
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
||
fs.mkdirSync(pendingDir, { recursive: true });
|
||
fs.writeFileSync(path.join(pendingDir, 'auth-todo.md'),
|
||
'title: Add OAuth token refresh\narea: auth\ncreated: 2026-03-01\n\nNeed to handle token expiry for OAuth flows.');
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
'# Roadmap\n\n### Phase 01: Authentication and Session Management\n\n**Goal:** Implement OAuth login and session handling\n');
|
||
|
||
const result = runGsdTools('todo match-phase 01', tmpDir);
|
||
assert.ok(result.success, 'should succeed');
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.todo_count, 1, 'should find 1 todo');
|
||
assert.ok(output.matches.length > 0, 'should have matches');
|
||
assert.strictEqual(output.matches[0].title, 'Add OAuth token refresh');
|
||
assert.ok(output.matches[0].score > 0, 'score should be positive');
|
||
assert.ok(output.matches[0].reasons.length > 0, 'should have reasons');
|
||
});
|
||
|
||
test('does not match unrelated todo', () => {
|
||
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
||
fs.mkdirSync(pendingDir, { recursive: true });
|
||
fs.writeFileSync(path.join(pendingDir, 'auth-todo.md'),
|
||
'title: Add OAuth token refresh\narea: auth\ncreated: 2026-03-01\n\nOAuth token expiry.');
|
||
fs.writeFileSync(path.join(pendingDir, 'unrelated-todo.md'),
|
||
'title: Fix CSS grid layout in dashboard\narea: ui\ncreated: 2026-03-01\n\nGrid columns break on mobile.');
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
'# Roadmap\n\n### Phase 01: Authentication and Session Management\n\n**Goal:** Implement OAuth login and session handling\n');
|
||
|
||
const result = runGsdTools('todo match-phase 01', tmpDir);
|
||
assert.ok(result.success, 'should succeed');
|
||
const output = JSON.parse(result.output);
|
||
const matchTitles = output.matches.map(m => m.title);
|
||
assert.ok(matchTitles.includes('Add OAuth token refresh'), 'auth todo should match');
|
||
assert.ok(!matchTitles.includes('Fix CSS grid layout in dashboard'), 'unrelated todo should not match');
|
||
});
|
||
|
||
test('matches todo by area overlap', () => {
|
||
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
||
fs.mkdirSync(pendingDir, { recursive: true });
|
||
fs.writeFileSync(path.join(pendingDir, 'auth-todo.md'),
|
||
'title: Add OAuth token refresh\narea: auth\ncreated: 2026-03-01\n\nOAuth token handling.');
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
'# Roadmap\n\n### Phase 01: Auth System\n\n**Goal:** Build auth module\n');
|
||
|
||
const result = runGsdTools('todo match-phase 01', tmpDir);
|
||
const output = JSON.parse(result.output);
|
||
const authMatch = output.matches.find(m => m.title === 'Add OAuth token refresh');
|
||
assert.ok(authMatch, 'should find auth todo');
|
||
const hasAreaReason = authMatch.reasons.some(r => r.startsWith('area:'));
|
||
assert.ok(hasAreaReason, 'should match on area');
|
||
});
|
||
|
||
test('sorts matches by score descending', () => {
|
||
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
||
fs.mkdirSync(pendingDir, { recursive: true });
|
||
fs.writeFileSync(path.join(pendingDir, 'weak-match.md'),
|
||
'title: Check token format\narea: general\ncreated: 2026-03-01\n\nToken format validation.');
|
||
fs.writeFileSync(path.join(pendingDir, 'strong-match.md'),
|
||
'title: Session management authentication OAuth token handling\narea: auth\ncreated: 2026-03-01\n\nSession auth OAuth tokens.');
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
'# Roadmap\n\n### Phase 01: Authentication and Session Management\n\n**Goal:** Implement OAuth login, session handling, and token management\n');
|
||
|
||
const result = runGsdTools('todo match-phase 01', tmpDir);
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(output.matches.length >= 2, 'should have multiple matches');
|
||
for (let i = 1; i < output.matches.length; i++) {
|
||
assert.ok(output.matches[i - 1].score >= output.matches[i].score,
|
||
`match ${i-1} score (${output.matches[i-1].score}) should be >= match ${i} score (${output.matches[i].score})`);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// scaffold command
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
describe('scaffold command', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('scaffolds context file', () => {
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
|
||
|
||
const result = runGsdTools('scaffold context --phase 3', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.created, true);
|
||
|
||
// Verify file content
|
||
const content = fs.readFileSync(
|
||
path.join(tmpDir, '.planning', 'phases', '03-api', '03-CONTEXT.md'),
|
||
'utf-8'
|
||
);
|
||
assert.ok(content.includes('Phase 3'), 'should reference phase number');
|
||
assert.ok(content.includes('Decisions'), 'should have decisions section');
|
||
assert.ok(content.includes('Discretion Areas'), 'should have discretion section');
|
||
});
|
||
|
||
test('scaffolds UAT file', () => {
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
|
||
|
||
const result = runGsdTools('scaffold uat --phase 3', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.created, true);
|
||
|
||
const content = fs.readFileSync(
|
||
path.join(tmpDir, '.planning', 'phases', '03-api', '03-UAT.md'),
|
||
'utf-8'
|
||
);
|
||
assert.ok(content.includes('User Acceptance Testing'), 'should have UAT heading');
|
||
assert.ok(content.includes('Test Results'), 'should have test results section');
|
||
});
|
||
|
||
test('scaffolds verification file', () => {
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
|
||
|
||
const result = runGsdTools('scaffold verification --phase 3', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.created, true);
|
||
|
||
const content = fs.readFileSync(
|
||
path.join(tmpDir, '.planning', 'phases', '03-api', '03-VERIFICATION.md'),
|
||
'utf-8'
|
||
);
|
||
assert.ok(content.includes('Goal-Backward Verification'), 'should have verification heading');
|
||
});
|
||
|
||
test('scaffolds phase directory', () => {
|
||
const result = runGsdTools('scaffold phase-dir --phase 5 --name User Dashboard', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.created, true);
|
||
assert.ok(
|
||
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '05-user-dashboard')),
|
||
'directory should be created'
|
||
);
|
||
});
|
||
|
||
test('does not overwrite existing files', () => {
|
||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Existing content');
|
||
|
||
const result = runGsdTools('scaffold context --phase 3', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.created, false, 'should not overwrite');
|
||
assert.strictEqual(output.reason, 'already_exists');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// cmdGenerateSlug tests (CMD-01)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('generate-slug command', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('converts normal text to slug', () => {
|
||
const result = runGsdTools('generate-slug "Hello World"', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.slug, 'hello-world');
|
||
});
|
||
|
||
test('strips special characters', () => {
|
||
const result = runGsdTools('generate-slug "Test@#$%^Special!!!"', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.slug, 'test-special');
|
||
});
|
||
|
||
test('preserves numbers', () => {
|
||
const result = runGsdTools('generate-slug "Phase 3 Plan"', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.slug, 'phase-3-plan');
|
||
});
|
||
|
||
test('strips leading and trailing hyphens', () => {
|
||
const result = runGsdTools('generate-slug "---leading-trailing---"', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.slug, 'leading-trailing');
|
||
});
|
||
|
||
test('fails when no text provided', () => {
|
||
const result = runGsdTools('generate-slug', tmpDir);
|
||
assert.ok(!result.success, 'should fail without text');
|
||
assert.ok(result.error.includes('text required'), 'error should mention text required');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// cmdCurrentTimestamp tests (CMD-01)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('current-timestamp command', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('date format returns YYYY-MM-DD', () => {
|
||
const result = runGsdTools('current-timestamp date', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.match(output.timestamp, /^\d{4}-\d{2}-\d{2}$/, 'should be YYYY-MM-DD format');
|
||
});
|
||
|
||
test('filename format returns ISO without colons or fractional seconds', () => {
|
||
const result = runGsdTools('current-timestamp filename', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.match(output.timestamp, /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}$/, 'should replace colons with hyphens and strip fractional seconds');
|
||
});
|
||
|
||
test('full format returns full ISO string', () => {
|
||
const result = runGsdTools('current-timestamp full', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.match(output.timestamp, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, 'should be full ISO format');
|
||
});
|
||
|
||
test('default (no format) returns full ISO string', () => {
|
||
const result = runGsdTools('current-timestamp', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.match(output.timestamp, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, 'default should be full ISO format');
|
||
});
|
||
|
||
test('dispatches directly to CJS handler (no SDK bridge) to avoid Windows native crash path', () => {
|
||
// ADR-2346 P4: current-timestamp migrated from a case arm to HOST_COMMAND_ROUTERS.
|
||
// Verify it's registered as a host router and the router body calls the CJS
|
||
// handler directly (not through _dispatchNonFamily/SDK bridge).
|
||
const { HOST_COMMAND_ROUTERS } = require('../gsd-core/bin/gsd-tools.cjs');
|
||
assert.ok(
|
||
Object.prototype.hasOwnProperty.call(HOST_COMMAND_ROUTERS, 'current-timestamp'),
|
||
'current-timestamp must be registered in HOST_COMMAND_ROUTERS',
|
||
);
|
||
const router = HOST_COMMAND_ROUTERS['current-timestamp'];
|
||
assert.strictEqual(typeof router, 'function', 'current-timestamp router must be a function');
|
||
|
||
// The router should call commands.cmdCurrentTimestamp directly.
|
||
// (Verified behaviorally by the 'current-timestamp command' tests above.)
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// cmdCurrentTimestamp exact-value tests (#3314 — ADR-456 subprocess clock pin)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('current-timestamp command — exact value under GSD_NOW_MS pin', () => {
|
||
let tmpDir;
|
||
// Pinned instant with a non-zero millisecond fraction so the 'full' format
|
||
// assertion can't accidentally pass against a truncated value.
|
||
const PINNED_MS = 1_700_000_000_123; // 2023-11-14T22:13:20.123Z
|
||
const PIN_ENV = { GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) };
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('date format: exact value for pinned instant', () => {
|
||
const result = runGsdTools('current-timestamp date', tmpDir, PIN_ENV);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
const expected = new Date(PINNED_MS).toISOString().split('T')[0];
|
||
assert.strictEqual(output.timestamp, expected);
|
||
});
|
||
|
||
test('filename format: exact value for pinned instant', () => {
|
||
const result = runGsdTools('current-timestamp filename', tmpDir, PIN_ENV);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
const expected = new Date(PINNED_MS).toISOString().replace(/:/g, '-').replace(/\..+/, '');
|
||
assert.strictEqual(output.timestamp, expected);
|
||
});
|
||
|
||
test('full format: exact value for pinned instant', () => {
|
||
const result = runGsdTools('current-timestamp full', tmpDir, PIN_ENV);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.timestamp, new Date(PINNED_MS).toISOString());
|
||
});
|
||
|
||
test('default format: exact value for pinned instant', () => {
|
||
const result = runGsdTools('current-timestamp', tmpDir, PIN_ENV);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.timestamp, new Date(PINNED_MS).toISOString());
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// cmdListTodos tests (CMD-02)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('list-todos command', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('empty directory returns zero count', () => {
|
||
const result = runGsdTools('list-todos', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.count, 0, 'count should be 0');
|
||
assert.deepStrictEqual(output.todos, [], 'todos should be empty');
|
||
});
|
||
|
||
test('returns multiple todos with correct fields', () => {
|
||
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
||
fs.mkdirSync(pendingDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(pendingDir, 'add-tests.md'), 'title: Add unit tests\narea: testing\ncreated: 2026-01-15\n');
|
||
fs.writeFileSync(path.join(pendingDir, 'fix-bug.md'), 'title: Fix login bug\narea: auth\ncreated: 2026-01-20\n');
|
||
|
||
const result = runGsdTools('list-todos', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.count, 2, 'should have 2 todos');
|
||
assert.strictEqual(output.todos.length, 2, 'todos array should have 2 entries');
|
||
|
||
const testTodo = output.todos.find(t => t.file === 'add-tests.md');
|
||
assert.ok(testTodo, 'add-tests.md should be in results');
|
||
assert.strictEqual(testTodo.title, 'Add unit tests');
|
||
assert.strictEqual(testTodo.area, 'testing');
|
||
assert.strictEqual(testTodo.created, '2026-01-15');
|
||
});
|
||
|
||
test('area filter returns only matching todos', () => {
|
||
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
||
fs.mkdirSync(pendingDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(pendingDir, 'ui-task.md'), 'title: UI task\narea: ui\ncreated: 2026-01-01\n');
|
||
fs.writeFileSync(path.join(pendingDir, 'api-task.md'), 'title: API task\narea: api\ncreated: 2026-01-01\n');
|
||
|
||
const result = runGsdTools('list-todos ui', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.count, 1, 'should have 1 matching todo');
|
||
assert.strictEqual(output.todos[0].area, 'ui', 'should only return ui area');
|
||
});
|
||
|
||
test('area filter miss returns zero count', () => {
|
||
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
||
fs.mkdirSync(pendingDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(pendingDir, 'task.md'), 'title: Some task\narea: backend\ncreated: 2026-01-01\n');
|
||
|
||
const result = runGsdTools('list-todos nonexistent-area', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.count, 0, 'should have 0 matching todos');
|
||
});
|
||
|
||
test('malformed files use defaults', () => {
|
||
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
||
fs.mkdirSync(pendingDir, { recursive: true });
|
||
|
||
// File with no title or area fields
|
||
fs.writeFileSync(path.join(pendingDir, 'malformed.md'), 'some random content\nno fields here\n');
|
||
|
||
const result = runGsdTools('list-todos', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.count, 1, 'malformed file should still be counted');
|
||
assert.strictEqual(output.todos[0].title, 'Untitled', 'missing title defaults to Untitled');
|
||
assert.strictEqual(output.todos[0].area, 'general', 'missing area defaults to general');
|
||
assert.strictEqual(output.todos[0].created, 'unknown', 'missing created defaults to unknown');
|
||
});
|
||
|
||
// ── #2337: severity must be surfaced when present, omitted when absent ──────
|
||
// cmdListTodos parsed created/title/area but silently dropped severity, so
|
||
// audit-open and status summaries could not triage by blocker/major/minor/
|
||
// cosmetic even for todos an agent had correctly hand-tagged.
|
||
test('surfaces severity when the frontmatter carries it (#2337)', () => {
|
||
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
||
fs.mkdirSync(pendingDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(pendingDir, 'crash.md'),
|
||
'title: Fix data-loss crash\narea: core\ncreated: 2026-02-01\nseverity: blocker\n');
|
||
|
||
const result = runGsdTools('list-todos', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
const todo = output.todos.find(t => t.file === 'crash.md');
|
||
assert.ok(todo, 'crash.md should be in results');
|
||
assert.strictEqual(todo.severity, 'blocker', 'severity must be surfaced verbatim');
|
||
});
|
||
|
||
test('omits the severity key for todos with no severity line — backward compatible (#2337)', () => {
|
||
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
|
||
fs.mkdirSync(pendingDir, { recursive: true });
|
||
|
||
fs.writeFileSync(path.join(pendingDir, 'legacy.md'),
|
||
'title: Legacy todo\narea: docs\ncreated: 2026-02-02\n');
|
||
|
||
const result = runGsdTools('list-todos', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
const todo = output.todos.find(t => t.file === 'legacy.md');
|
||
assert.ok(todo, 'legacy.md should be in results');
|
||
assert.ok(!('severity' in todo),
|
||
'severity key must be ABSENT (not null/empty) for a todo with no severity line');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// cmdVerifyPathExists tests (CMD-02)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('verify-path-exists command', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('existing file returns exists=true with type=file', () => {
|
||
fs.writeFileSync(path.join(tmpDir, 'test-file.txt'), 'hello');
|
||
|
||
const result = runGsdTools('verify-path-exists test-file.txt', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.exists, true);
|
||
assert.strictEqual(output.type, 'file');
|
||
});
|
||
|
||
test('existing directory returns exists=true with type=directory', () => {
|
||
fs.mkdirSync(path.join(tmpDir, 'test-dir'), { recursive: true });
|
||
|
||
const result = runGsdTools('verify-path-exists test-dir', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.exists, true);
|
||
assert.strictEqual(output.type, 'directory');
|
||
});
|
||
|
||
test('missing path returns exists=false', () => {
|
||
const result = runGsdTools('verify-path-exists nonexistent/path', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.exists, false);
|
||
assert.strictEqual(output.type, null);
|
||
});
|
||
|
||
test('absolute path resolves correctly', () => {
|
||
const absFile = path.join(tmpDir, 'abs-test.txt');
|
||
fs.writeFileSync(absFile, 'content');
|
||
|
||
const result = runGsdTools(['verify-path-exists', absFile], tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.exists, true);
|
||
assert.strictEqual(output.type, 'file');
|
||
});
|
||
|
||
test('fails when no path provided', () => {
|
||
const result = runGsdTools('verify-path-exists', tmpDir);
|
||
assert.ok(!result.success, 'should fail without path');
|
||
assert.ok(result.error.includes('path required'), 'error should mention path required');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// cmdResolveModel tests (CMD-03)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('resolve-model command', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('known agent returns model and profile without unknown_agent', () => {
|
||
const result = runGsdTools('resolve-model gsd-planner', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.ok(output.model, 'should have model field');
|
||
assert.ok(output.profile, 'should have profile field');
|
||
assert.strictEqual(output.unknown_agent, undefined, 'should not have unknown_agent for known agent');
|
||
});
|
||
|
||
test('shipped-but-previously-missing agent resolves under quality profile (#3229)', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ model_profile: 'quality' }));
|
||
const result = runGsdTools('resolve-model gsd-code-reviewer', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.model, 'opus');
|
||
assert.strictEqual(output.profile, 'quality');
|
||
assert.strictEqual(output.unknown_agent, undefined);
|
||
});
|
||
|
||
test('unknown agent returns unknown_agent=true', () => {
|
||
const result = runGsdTools('resolve-model fake-nonexistent-agent', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.unknown_agent, true, 'should flag unknown agent');
|
||
});
|
||
|
||
test('unknown agent uses quality-semantic fallback (opus)', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ model_profile: 'quality' }));
|
||
const result = runGsdTools('resolve-model fake-nonexistent-agent', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.model, 'opus');
|
||
assert.strictEqual(output.unknown_agent, true);
|
||
});
|
||
|
||
test('unknown agent uses budget-semantic fallback (haiku)', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ model_profile: 'budget' }));
|
||
const result = runGsdTools('resolve-model fake-nonexistent-agent', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.model, 'haiku');
|
||
assert.strictEqual(output.unknown_agent, true);
|
||
});
|
||
|
||
test('default profile fallback when no config exists', () => {
|
||
// tmpDir has no config.json, so defaults to balanced profile
|
||
const result = runGsdTools('resolve-model gsd-executor', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.profile, 'balanced', 'should default to balanced profile');
|
||
assert.ok(output.model, 'should resolve a model');
|
||
});
|
||
|
||
// #4192 (AC1, behavioral): a claude-runtime tier override must change the
|
||
// resolved output relative to the no-override control — the CLI surface the
|
||
// orchestrator reads is where the documented contract is observable.
|
||
test('claude-runtime tier override changes resolved output vs control (#4192)', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({
|
||
model_profile: 'balanced',
|
||
model_profile_overrides: { claude: { opus: 'claude-opus-4-7' } },
|
||
}));
|
||
const pinned = runGsdTools('resolve-model gsd-planner', tmpDir);
|
||
assert.ok(pinned.success, `Command failed: ${pinned.error}`);
|
||
assert.strictEqual(JSON.parse(pinned.output).model, 'claude-opus-4-7');
|
||
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({
|
||
model_profile: 'balanced',
|
||
}));
|
||
const control = runGsdTools('resolve-model gsd-planner', tmpDir);
|
||
assert.ok(control.success, `Command failed: ${control.error}`);
|
||
assert.strictEqual(JSON.parse(control.output).model, 'opus');
|
||
});
|
||
|
||
// #4192 (AC2, behavioral): a per-agent fully-qualified Claude model ID is
|
||
// resolved as configured through the CLI surface.
|
||
test('per-agent fully-qualified claude ID resolves as configured (#4192)', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({
|
||
model_profile: 'balanced',
|
||
model_overrides: { 'gsd-debugger': 'claude-opus-4-7' },
|
||
}));
|
||
const result = runGsdTools('resolve-model gsd-debugger', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
assert.strictEqual(JSON.parse(result.output).model, 'claude-opus-4-7');
|
||
});
|
||
|
||
// #443: resolve-model now emits unified `effort` instead of `reasoning_effort`.
|
||
// reasoning_effort was flavor-text (resolved but consumed by nobody); effort is
|
||
// the wired, config-driven universal effort string for all runtimes.
|
||
test('emits unified effort (not reasoning_effort) when runtime supports tiered effort', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({
|
||
model_profile: 'balanced',
|
||
runtime: 'codex',
|
||
models: { planning: 'opus' },
|
||
}));
|
||
const result = runGsdTools('resolve-model gsd-planner', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.model, 'gpt-5.6-sol');
|
||
assert.strictEqual(output.profile, 'balanced');
|
||
// #443: effort is now the unified field (xhigh for gsd-planner heavy tier default)
|
||
assert.strictEqual(output.effort, 'xhigh');
|
||
// reasoning_effort must be absent — replaced by unified effort
|
||
assert.ok(!Object.prototype.hasOwnProperty.call(output, 'reasoning_effort'),
|
||
'reasoning_effort must not appear in resolve-model output (replaced by effort)');
|
||
});
|
||
|
||
test('does not include reasoning_effort for unsupported runtime overrides (effort present instead)', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({
|
||
model_profile: 'balanced',
|
||
runtime: 'opencode',
|
||
models: { planning: 'opus' },
|
||
model_profile_overrides: {
|
||
opencode: {
|
||
opus: { model: 'openrouter/openai/gpt-5.5', reasoning_effort: 'high' },
|
||
},
|
||
},
|
||
}));
|
||
const result = runGsdTools('resolve-model gsd-planner', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.model, 'openrouter/openai/gpt-5.5');
|
||
assert.strictEqual(output.profile, 'balanced');
|
||
// #443: effort always present; reasoning_effort never present
|
||
assert.ok(Object.prototype.hasOwnProperty.call(output, 'effort'), 'effort must be present');
|
||
assert.ok(!Object.prototype.hasOwnProperty.call(output, 'reasoning_effort'),
|
||
'reasoning_effort must not appear (replaced by unified effort)');
|
||
});
|
||
|
||
test('does not include reasoning_effort for per-agent model_overrides (effort present instead)', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({
|
||
model_profile: 'balanced',
|
||
runtime: 'codex',
|
||
models: { planning: 'opus' },
|
||
model_overrides: { 'gsd-planner': 'gpt-5.5' },
|
||
}));
|
||
const result = runGsdTools('resolve-model gsd-planner', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.model, 'gpt-5.5');
|
||
assert.strictEqual(output.profile, 'balanced');
|
||
// #443: effort always present; reasoning_effort never present
|
||
assert.ok(Object.prototype.hasOwnProperty.call(output, 'effort'), 'effort must be present');
|
||
assert.ok(!Object.prototype.hasOwnProperty.call(output, 'reasoning_effort'),
|
||
'reasoning_effort must not appear (replaced by unified effort)');
|
||
});
|
||
|
||
test('fails when no agent-type provided', () => {
|
||
const result = runGsdTools('resolve-model', tmpDir);
|
||
assert.ok(!result.success, 'should fail without agent-type');
|
||
assert.ok(result.error.includes('agent-type required'), 'error should mention agent-type required');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// cmdCommit tests (CMD-04)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('commit command', () => {
|
||
const { createTempGitProject } = require('./helpers.cjs');
|
||
const { gitOrThrow, throwIfFailed } = require('./helpers/git-fixture.cjs');
|
||
const { runNode } = require('./helpers/process-seam.cjs');
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempGitProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('skips when commit_docs is false', () => {
|
||
// Write config with commit_docs: false
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({ commit_docs: false })
|
||
);
|
||
|
||
const result = runGsdTools('commit "test message"', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.committed, false);
|
||
assert.strictEqual(output.reason, 'skipped_commit_docs_false');
|
||
});
|
||
|
||
test('skips when .planning is gitignored', () => {
|
||
// Add .planning/ to .gitignore and commit it so git recognizes the ignore
|
||
fs.writeFileSync(path.join(tmpDir, '.gitignore'), '.planning/\n');
|
||
gitOrThrow(['add', '.gitignore'], { cwd: tmpDir });
|
||
gitOrThrow(['commit', '-m', 'add gitignore'], { cwd: tmpDir });
|
||
|
||
const result = runGsdTools('commit "test message"', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.committed, false);
|
||
assert.strictEqual(output.reason, 'skipped_gitignored');
|
||
});
|
||
|
||
test('handles nothing to commit', () => {
|
||
// Don't modify any files after initial commit
|
||
const result = runGsdTools('commit "test message"', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.committed, false);
|
||
assert.strictEqual(output.reason, 'nothing_to_commit');
|
||
});
|
||
|
||
test('creates real commit with correct hash', () => {
|
||
// Create a new file in .planning/
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'test-file.md'), '# Test\n');
|
||
|
||
const result = runGsdTools('commit "test: add test file" --files .planning/test-file.md', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.committed, true, 'should have committed');
|
||
assert.ok(output.hash, 'should have a commit hash');
|
||
assert.strictEqual(output.reason, 'committed');
|
||
|
||
// Verify via git log
|
||
const gitLog = gitOrThrow(['log', '--oneline', '-1'], { cwd: tmpDir }).trim();
|
||
assert.ok(gitLog.includes('test: add test file'), 'git log should contain the commit message');
|
||
assert.ok(gitLog.includes(output.hash), 'git log should contain the returned hash');
|
||
});
|
||
|
||
test('amend mode works without crashing', () => {
|
||
// Create a file and commit it first
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'amend-file.md'), '# Initial\n');
|
||
gitOrThrow(['add', '.planning/amend-file.md'], { cwd: tmpDir });
|
||
gitOrThrow(['commit', '-m', 'initial file'], { cwd: tmpDir });
|
||
|
||
// Modify the file and amend
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'amend-file.md'), '# Amended\n');
|
||
|
||
const result = runGsdTools('commit "ignored" --files .planning/amend-file.md --amend', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.committed, true, 'amend should succeed');
|
||
|
||
// Verify only 2 commits total (initial setup + amended)
|
||
const logCount = gitOrThrow(['log', '--oneline'], { cwd: tmpDir }).trim().split('\n').length;
|
||
assert.strictEqual(logCount, 2, 'should have 2 commits (initial + amended)');
|
||
});
|
||
test('#3207: creates AND switches to the milestone branch before first commit', () => {
|
||
// Configure milestone branching strategy
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({
|
||
commit_docs: true,
|
||
branching_strategy: 'milestone',
|
||
milestone_branch_template: 'gsd/{milestone}-{slug}',
|
||
})
|
||
);
|
||
// getMilestoneInfo reads ROADMAP.md for milestone version/name
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
'## v1.0: Initial Release\n\n### Phase 1: Setup\n'
|
||
);
|
||
|
||
// Create a file to commit
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'test-context.md'), '# Context\n');
|
||
|
||
const result = runGsdTools('commit "docs: add context" --files .planning/test-context.md', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.committed, true, 'should have committed');
|
||
|
||
// #3207: the branch should be CREATED and HEAD switched to it, so the first
|
||
// milestone-scoped commit lands on the milestone branch (#1278 intent). The
|
||
// prior #3079 no-switch behavior regressed this for fresh creates.
|
||
const branch = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim();
|
||
assert.strictEqual(branch, 'gsd/v1.0-initial-release', '#3207: must switch to the milestone branch');
|
||
// The commit must be reachable on the milestone branch (HEAD is on it).
|
||
const committedFile = gitOrThrow(['show', 'HEAD:.planning/test-context.md'], { cwd: tmpDir });
|
||
assert.ok(committedFile.includes('# Context'), 'milestone commit must land on the milestone branch');
|
||
});
|
||
|
||
test('#3207: creates AND switches to the phase branch before first commit', () => {
|
||
// Configure phase branching strategy
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({
|
||
commit_docs: true,
|
||
branching_strategy: 'phase',
|
||
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
||
})
|
||
);
|
||
// Create ROADMAP.md with a phase
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-setup'), { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
'# Roadmap\n\n## Phase 1: Setup\nGoal: Initial setup\n'
|
||
);
|
||
|
||
// Create a context file for phase 1
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '01-setup', '01-CONTEXT.md'), '# Context\n');
|
||
|
||
const result = runGsdTools(
|
||
'commit "docs(01): add context" --files .planning/phases/01-setup/01-CONTEXT.md',
|
||
tmpDir
|
||
);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.committed, true, 'should have committed');
|
||
|
||
// #3207: the branch should be CREATED and HEAD switched to it, so the first
|
||
// phase-scoped commit lands on the phase branch (#1278 intent). The prior
|
||
// #3079 no-switch behavior regressed this for fresh creates.
|
||
const branch = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim();
|
||
assert.strictEqual(branch, 'gsd/phase-01-setup', '#3207: must switch to the phase branch');
|
||
// The commit must be reachable on the phase branch (HEAD is on it).
|
||
const committedFile = gitOrThrow(
|
||
['show', 'HEAD:.planning/phases/01-setup/01-CONTEXT.md'], { cwd: tmpDir }
|
||
);
|
||
assert.ok(committedFile.includes('# Context'), 'phase commit must land on the phase branch');
|
||
});
|
||
|
||
test('decimal phase numbers are captured correctly in branching strategy', () => {
|
||
// Configure phase branching strategy
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({
|
||
commit_docs: true,
|
||
branching_strategy: 'phase',
|
||
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
||
})
|
||
);
|
||
// Create ROADMAP.md with a decimal phase
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '45.14-golden-capture'), { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
'# Roadmap\n\n## Phase 45.14: Golden Capture\nGoal: Capture golden standard\n'
|
||
);
|
||
|
||
// Create a context file for phase 45.14
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '45.14-golden-capture', '45.14-CONTEXT.md'), '# Context\n');
|
||
|
||
const result = runGsdTools(
|
||
'commit "docs(45.14): add context" --files .planning/phases/45.14-golden-capture/45.14-CONTEXT.md',
|
||
tmpDir
|
||
);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.committed, true, 'should have committed');
|
||
|
||
// #3207: the branch should be CREATED and HEAD switched to it (decimal phase).
|
||
const branch = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim();
|
||
assert.strictEqual(branch, 'gsd/phase-45.14-golden-capture', '#3207: must switch to the decimal phase branch');
|
||
// Verify the correct branch name was resolved (not integer-only)
|
||
const branchExists = gitOrThrow(['rev-parse', '--verify', 'gsd/phase-45.14-golden-capture'], { cwd: tmpDir });
|
||
assert.ok(branchExists.trim(), 'decimal phase branch should be created (45.14, not 14)');
|
||
});
|
||
|
||
// #2539: the phase-token extraction must be anchored to the path segment under
|
||
// .planning/phases/ and reuse the project-code-aware extractPhaseToken helper.
|
||
// The prior unanchored `match(/(\d+(?:\.\d+)*)-/)` matched the leftmost
|
||
// digit-run-then-hyphen anywhere in the joined file path, so a project_code
|
||
// ending in a digit (e.g. PROJECT_V2) made `.../PROJECT_V2-07-name/...` match
|
||
// the `2-` inside `V2-` BEFORE reaching the real `07-` phase token —
|
||
// resolving phase "2" instead of phase "7". findPhaseInternal also searches
|
||
// archived milestones, so an existing archived phase 2 produced a real branch
|
||
// name, and the silent `git checkout <existing-branch>` fallback switched the
|
||
// whole working tree onto the wrong branch in the same call that then
|
||
// committed. This fixture reproduces both preconditions.
|
||
test('#2539: digit-suffixed project_code does not collide with the phase number', () => {
|
||
// Configure phase branching strategy with a project_code ending in a digit.
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({
|
||
commit_docs: true,
|
||
project_code: 'PROJECT_V2',
|
||
branching_strategy: 'phase',
|
||
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
||
})
|
||
);
|
||
|
||
// Archived phase 02 under a shipped milestone — the collision target that
|
||
// findPhaseInternal reaches via the .planning/milestones/<v>-phases/ search.
|
||
fs.mkdirSync(
|
||
path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', 'PROJECT_V2-02-archived-phase'),
|
||
{ recursive: true }
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', 'PROJECT_V2-02-archived-phase', '02-CONTEXT.md'),
|
||
'# Archived\n'
|
||
);
|
||
|
||
// Active phase 07 — the phase actually being committed.
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'PROJECT_V2-07-active-phase'), { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
'# Roadmap\n\n## Phase 7: Active Phase\nGoal: ship it\n'
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'phases', 'PROJECT_V2-07-active-phase', '07-CONTEXT.md'),
|
||
'# Context\n'
|
||
);
|
||
|
||
const result = runGsdTools(
|
||
'commit "docs(07): add context" --files .planning/phases/PROJECT_V2-07-active-phase/07-CONTEXT.md',
|
||
tmpDir
|
||
);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.committed, true, 'should have committed');
|
||
|
||
// #3207: the commit now CREATES and SWITCHES to the phase branch. The
|
||
// resolved branch is phase-07 (correct, not the archived 02), and HEAD
|
||
// moves onto it so the phase's work accumulates there.
|
||
const branch = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim();
|
||
assert.notStrictEqual(
|
||
branch,
|
||
'gsd/phase-02-archived-phase',
|
||
`must NOT be on the archived phase-02 branch (got ${branch})`
|
||
);
|
||
// #3207: HEAD must land on the CORRECT freshly-created phase-07 branch.
|
||
assert.strictEqual(
|
||
branch,
|
||
'gsd/phase-07-active-phase',
|
||
`must switch onto the correct phase-07 branch (got ${branch})`
|
||
);
|
||
// Verify the correct phase-07 branch was created (not the archived 02)
|
||
const phase07Exists = gitOrThrow(
|
||
['rev-parse', '--verify', 'gsd/phase-07-active-phase'],
|
||
{ cwd: tmpDir }
|
||
);
|
||
assert.ok(phase07Exists.trim(), 'phase-07 branch should be created (not the archived phase-02)');
|
||
|
||
// The committed file must exist on HEAD, proving the commit landed.
|
||
const committedFile = gitOrThrow(
|
||
['show', 'HEAD:.planning/phases/PROJECT_V2-07-active-phase/07-CONTEXT.md'],
|
||
{ cwd: tmpDir }
|
||
);
|
||
assert.ok(committedFile.includes('# Context'), 'phase-07 file must be in the commit');
|
||
});
|
||
|
||
// #2539 second defect: an auto-checkout mid-commit must never be silent. The
|
||
// #1278 intent was to CREATE the phase branch before the FIRST commit on it —
|
||
// not to force-switch an already-checked-out working branch onto a different
|
||
// existing branch. If the resolved phase branch already exists and the working
|
||
// tree is on some other branch, switching to it silently is the dangerous
|
||
// drift; the fix keeps create-if-absent but drops the silent switch-to-existing.
|
||
test('#2539: does not silently switch onto an existing unrelated phase branch', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({
|
||
commit_docs: true,
|
||
branching_strategy: 'phase',
|
||
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
||
})
|
||
);
|
||
// Active phase 01.
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-first-phase'), { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
'# Roadmap\n\n## Phase 1: First Phase\nGoal: start\n'
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'phases', '01-first-phase', '01-CONTEXT.md'),
|
||
'# Context\n'
|
||
);
|
||
|
||
// Pre-create the phase-01 branch and check it out, then return to the
|
||
// default branch so the working tree is NOT on the phase branch when commit
|
||
// runs. The resolved branch already exists; the pre-fix code silently
|
||
// switched onto it.
|
||
gitOrThrow(['branch', 'gsd/phase-01-first-phase'], { cwd: tmpDir });
|
||
// Ensure the file is staged only by the commit command itself (it must run
|
||
// from the current/default branch and must not be force-switched).
|
||
const beforeBranch = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim();
|
||
|
||
// Invoke gsd-tools via the process seam so stderr is observable on the
|
||
// success path — the warning that proves the no-switch path is not silent
|
||
// (#2539 AC2) is written to stderr, which execFileSync discards on success.
|
||
const { TOOLS_PATH } = require('./helpers.cjs');
|
||
const proc = runNode([
|
||
TOOLS_PATH, 'commit', 'docs(01): add context',
|
||
'--files', '.planning/phases/01-first-phase/01-CONTEXT.md',
|
||
], { cwd: tmpDir });
|
||
throwIfFailed(proc, 'gsd-tools commit (#2539 no-switch fixture)');
|
||
const stdout = proc.stdout || '';
|
||
const stderr = proc.stderr || '';
|
||
|
||
const output = JSON.parse(stdout.trim());
|
||
assert.strictEqual(output.committed, true, 'should have committed');
|
||
|
||
// The command must NOT have silently switched the working tree onto the
|
||
// pre-existing phase branch. The commit lands on the branch we were on.
|
||
const afterBranch = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim();
|
||
assert.strictEqual(
|
||
afterBranch,
|
||
beforeBranch,
|
||
`must not silently switch onto an existing phase branch mid-commit (was ${beforeBranch}, now ${afterBranch})`
|
||
);
|
||
|
||
// #2539/#3079 AC2: the no-switch path must not be silent. The warning
|
||
// surfaces the resolved branch and the branch the commit actually lands on.
|
||
// Note: stderr may also contain config-loader warnings; the branching warning
|
||
// is the one we assert on.
|
||
assert.ok(
|
||
/Warning: resolved.*branch .* already exists/.test(stderr),
|
||
`expected a non-silent warning on stderr when the resolved branch already exists; got stderr=${stderr}`
|
||
);
|
||
});
|
||
|
||
// #3207 AC3: the fresh-create path must NOT be silent. Pre-fix the first
|
||
// phase-scoped commit produced no output at all, so the divergence between
|
||
// "phase branch exists" and "phase work is on it" started invisibly. The fix
|
||
// logs the create+switch on stderr.
|
||
test('#3207: fresh phase-branch create is non-silent (logs create+switch)', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({
|
||
commit_docs: true,
|
||
branching_strategy: 'phase',
|
||
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
||
})
|
||
);
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-setup'), { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
'# Roadmap\n\n## Phase 1: Setup\nGoal: Initial setup\n'
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'phases', '01-setup', '01-CONTEXT.md'), '# Context\n'
|
||
);
|
||
|
||
// Observe stderr on the success path via the process seam (execFileSync
|
||
// discards stderr on success — same reason the #2539 test uses runNode).
|
||
const { TOOLS_PATH } = require('./helpers.cjs');
|
||
const proc = runNode([
|
||
TOOLS_PATH, 'commit', 'docs(01): add context',
|
||
'--files', '.planning/phases/01-setup/01-CONTEXT.md',
|
||
], { cwd: tmpDir });
|
||
throwIfFailed(proc, 'gsd-tools commit (#3207 non-silent fixture)');
|
||
const stderr = proc.stderr || '';
|
||
|
||
// The fresh create must announce itself — not the "already exists" wording
|
||
// (that belongs to the existing-branch path) but a create+switch notice.
|
||
assert.ok(
|
||
/created.*switched|switched.*created/i.test(stderr) ||
|
||
/phase-01-setup/i.test(stderr),
|
||
`expected a non-silent create+switch notice on stderr; got stderr=${stderr}`
|
||
);
|
||
});
|
||
|
||
// #3207 AC5: once the first commit has switched HEAD onto the phase branch,
|
||
// a second phase-scoped commit must NOT emit the misleading "already exists"
|
||
// warning — the currentBranch === branchName guard skips the block entirely.
|
||
test('#3207: second phase commit does not re-warn once HEAD is on the phase branch', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({
|
||
commit_docs: true,
|
||
branching_strategy: 'phase',
|
||
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
||
})
|
||
);
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-setup'), { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
'# Roadmap\n\n## Phase 1: Setup\nGoal: Initial setup\n'
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'phases', '01-setup', '01-CONTEXT.md'), '# Context\n'
|
||
);
|
||
|
||
const { TOOLS_PATH } = require('./helpers.cjs');
|
||
|
||
// First commit — fresh create, switches onto the phase branch.
|
||
const first = runNode([
|
||
TOOLS_PATH, 'commit', 'docs(01): first',
|
||
'--files', '.planning/phases/01-setup/01-CONTEXT.md',
|
||
], { cwd: tmpDir });
|
||
throwIfFailed(first, 'gsd-tools commit (#3207 first)');
|
||
const branchAfterFirst = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim();
|
||
assert.strictEqual(branchAfterFirst, 'gsd/phase-01-setup', 'first commit must switch onto the phase branch');
|
||
|
||
// Second commit — HEAD is already on the phase branch, so the block is
|
||
// skipped and NO "already exists" warning should appear.
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'phases', '01-setup', '02-NOTES.md'), '# Notes\n'
|
||
);
|
||
const second = runNode([
|
||
TOOLS_PATH, 'commit', 'docs(01): second',
|
||
'--files', '.planning/phases/01-setup/02-NOTES.md',
|
||
], { cwd: tmpDir });
|
||
throwIfFailed(second, 'gsd-tools commit (#3207 second)');
|
||
const secondStderr = second.stderr || '';
|
||
assert.ok(
|
||
!/already exists/i.test(secondStderr),
|
||
`second commit must not re-warn once on the phase branch; got stderr=${secondStderr}`
|
||
);
|
||
});
|
||
|
||
// #3734 — the phase arm of `query commit` must not treat a backlog sentinel
|
||
// phase id as a real phase. /gsd-capture --backlog commits the sentinel phase
|
||
// directory via add-backlog.md; pre-fix each capture created AND switched to a
|
||
// gsd/phase-999.<n>-<slug> branch, scattering backlog items across branches
|
||
// and leaving the operator's branch at the base commit.
|
||
test('#3734: 999.x backlog sentinel commits on the current branch and creates no phase branch', () => {
|
||
const startBranch = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim();
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({
|
||
commit_docs: true,
|
||
branching_strategy: 'phase',
|
||
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
||
})
|
||
);
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '999.42-first-idea'), { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'phases', '999.42-first-idea', '.gitkeep'), '# backlog marker\n'
|
||
);
|
||
|
||
const result = runGsdTools(
|
||
'commit "docs: add backlog item 999.42 — first idea" --files .planning/phases/999.42-first-idea/.gitkeep',
|
||
tmpDir
|
||
);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.committed, true, 'sentinel capture must still commit');
|
||
|
||
const endBranch = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim();
|
||
assert.strictEqual(endBranch, startBranch, '#3734: sentinel capture must not switch branches');
|
||
const sentinelBranches = gitOrThrow(
|
||
['branch', '--list', 'gsd/phase-999*'], { cwd: tmpDir }
|
||
).trim();
|
||
assert.strictEqual(sentinelBranches, '', '#3734: no gsd/phase-999.* branch may be created');
|
||
// The "lost work" mode from the issue: the commit must be reachable on the
|
||
// branch the operator was actually on. gitOrThrow throws if the path is
|
||
// absent from startBranch, so reaching the content assertion proves it.
|
||
const onStartBranch = gitOrThrow(
|
||
['show', `${startBranch}:.planning/phases/999.42-first-idea/.gitkeep`], { cwd: tmpDir }
|
||
);
|
||
assert.ok(onStartBranch.includes('# backlog marker'), 'sentinel commit must land on the starting branch');
|
||
});
|
||
|
||
test('#3734: 0.x backlog sentinel also never creates a phase branch', () => {
|
||
const startBranch = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim();
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({
|
||
commit_docs: true,
|
||
branching_strategy: 'phase',
|
||
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
||
})
|
||
);
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '0.3-icebox-idea'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '0.3-icebox-idea', '.gitkeep'), '# icebox marker\n');
|
||
|
||
const result = runGsdTools(
|
||
'commit "docs: add backlog item 0.3 — icebox idea" --files .planning/phases/0.3-icebox-idea/.gitkeep',
|
||
tmpDir
|
||
);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
assert.strictEqual(JSON.parse(result.output).committed, true, '0.x capture must still commit');
|
||
|
||
const endBranch = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim();
|
||
assert.strictEqual(endBranch, startBranch, '#3734: 0.x sentinel must not switch branches');
|
||
const sentinelBranches = gitOrThrow(
|
||
['branch', '--list', 'gsd/phase-0*'], { cwd: tmpDir }
|
||
).trim();
|
||
assert.strictEqual(sentinelBranches, '', '#3734: no gsd/phase-0.* branch may be created');
|
||
const onStartBranch = gitOrThrow(
|
||
['show', `${startBranch}:.planning/phases/0.3-icebox-idea/.gitkeep`], { cwd: tmpDir }
|
||
);
|
||
assert.ok(onStartBranch.includes('# icebox marker'), '0.x sentinel commit must land on the starting branch');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// groupFilesBySubrepo tests (#311)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('groupFilesBySubrepo (#311)', () => {
|
||
const { groupFilesBySubrepo } = require('../gsd-core/bin/lib/commands.cjs');
|
||
|
||
test('single-segment subrepos route files correctly and unmatched collected', () => {
|
||
const result = groupFilesBySubrepo(
|
||
['packages/a.js', 'docs/x.md', 'README.md'],
|
||
['packages', 'docs']
|
||
);
|
||
assert.deepStrictEqual(result.grouped, { packages: ['packages/a.js'], docs: ['docs/x.md'] });
|
||
assert.deepStrictEqual(result.unmatched, ['README.md']);
|
||
});
|
||
|
||
test('multi-segment subrepo matches deep files, not shallow sibling', () => {
|
||
const result = groupFilesBySubrepo(
|
||
['vendor/pkg/x.js', 'vendor/other.js', 'vendor/pkg/y.js'],
|
||
['vendor/pkg']
|
||
);
|
||
assert.deepStrictEqual(result.grouped, { 'vendor/pkg': ['vendor/pkg/x.js', 'vendor/pkg/y.js'] });
|
||
assert.deepStrictEqual(result.unmatched, ['vendor/other.js']);
|
||
});
|
||
|
||
test('longest-prefix wins, not first-match-in-array-order (#391)', () => {
|
||
// 'app' precedes 'app/sub' in array order, but 'app/sub' is the more specific
|
||
// configured sub-repo, so 'app/sub/f.js' must route to 'app/sub'.
|
||
const result = groupFilesBySubrepo(
|
||
['app/sub/f.js'],
|
||
['app', 'app/sub']
|
||
);
|
||
assert.deepStrictEqual(result.grouped, { 'app/sub': ['app/sub/f.js'] });
|
||
assert.deepStrictEqual(result.unmatched, []);
|
||
});
|
||
|
||
test('longest-prefix selection is independent of sub_repos array order (#391)', () => {
|
||
// Reverse array order: longest-prefix must still win (no array-order workaround).
|
||
const result = groupFilesBySubrepo(
|
||
['app/sub/f.js'],
|
||
['app/sub', 'app']
|
||
);
|
||
assert.deepStrictEqual(result.grouped, { 'app/sub': ['app/sub/f.js'] });
|
||
assert.deepStrictEqual(result.unmatched, []);
|
||
});
|
||
|
||
test('nested sub-repos route by specificity; shallow files stay shallow (#391)', () => {
|
||
// Exact repro from #391 plus a shallow sibling file under the parent sub-repo.
|
||
const result = groupFilesBySubrepo(
|
||
['packages/core/widget.js', 'packages/util.js'],
|
||
['packages', 'packages/core']
|
||
);
|
||
assert.deepStrictEqual(result.grouped, {
|
||
'packages/core': ['packages/core/widget.js'],
|
||
packages: ['packages/util.js'],
|
||
});
|
||
assert.deepStrictEqual(result.unmatched, []);
|
||
});
|
||
|
||
test('three-level nesting routes to the deepest matching prefix (#391)', () => {
|
||
const result = groupFilesBySubrepo(
|
||
['a/b/c/f.js', 'a/b/g.js', 'a/h.js'],
|
||
['a', 'a/b', 'a/b/c']
|
||
);
|
||
assert.deepStrictEqual(result.grouped, {
|
||
'a/b/c': ['a/b/c/f.js'],
|
||
'a/b': ['a/b/g.js'],
|
||
a: ['a/h.js'],
|
||
});
|
||
assert.deepStrictEqual(result.unmatched, []);
|
||
});
|
||
|
||
test('file with no slash does not match a same-name subrepo', () => {
|
||
const result = groupFilesBySubrepo(['top'], ['top']);
|
||
assert.deepStrictEqual(result.grouped, {});
|
||
assert.deepStrictEqual(result.unmatched, ['top']);
|
||
});
|
||
|
||
test('file with slash after prefix routes correctly', () => {
|
||
const result = groupFilesBySubrepo(['top/a'], ['top']);
|
||
assert.deepStrictEqual(result.grouped, { top: ['top/a'] });
|
||
assert.deepStrictEqual(result.unmatched, []);
|
||
});
|
||
|
||
test('empty files list returns empty grouped and unmatched', () => {
|
||
const result = groupFilesBySubrepo([], ['a']);
|
||
assert.deepStrictEqual(result.grouped, {});
|
||
assert.deepStrictEqual(result.unmatched, []);
|
||
});
|
||
|
||
test('empty subRepos list puts all files in unmatched', () => {
|
||
const result = groupFilesBySubrepo(['a/b'], []);
|
||
assert.deepStrictEqual(result.grouped, {});
|
||
assert.deepStrictEqual(result.unmatched, ['a/b']);
|
||
});
|
||
|
||
test('non-string subRepos entry does not throw and string entries still route (#311)', () => {
|
||
// Old inline code coerced non-string repos via `repo + '/'` and never threw.
|
||
let result;
|
||
assert.doesNotThrow(() => {
|
||
result = groupFilesBySubrepo(['a/b', 'README.md'], [null, 'a']);
|
||
});
|
||
assert.deepStrictEqual(result.grouped, { a: ['a/b'] });
|
||
assert.deepStrictEqual(result.unmatched, ['README.md']);
|
||
});
|
||
|
||
test('non-string entry in a matched bucket does not throw (#391)', () => {
|
||
// A null sub_repos entry shares the 'null' first-segment bucket with a real
|
||
// multi-segment entry; longest-prefix selection must not throw reading length.
|
||
let result;
|
||
assert.doesNotThrow(() => {
|
||
result = groupFilesBySubrepo(['null/a/f.js'], [null, 'null/a']);
|
||
});
|
||
assert.deepStrictEqual(result.grouped, { 'null/a': ['null/a/f.js'] });
|
||
assert.deepStrictEqual(result.unmatched, []);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// cmdWebsearch tests (CMD-05)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('websearch command', () => {
|
||
const { cmdWebsearch } = require('../gsd-core/bin/lib/commands.cjs');
|
||
let origFetch;
|
||
let origApiKey;
|
||
let origWriteSync;
|
||
let captured;
|
||
|
||
beforeEach(() => {
|
||
origFetch = global.fetch;
|
||
origApiKey = process.env.BRAVE_API_KEY;
|
||
origWriteSync = fs.writeSync;
|
||
captured = '';
|
||
// output() uses fs.writeSync(1, data) since #1276 — mock it to capture output
|
||
fs.writeSync = (fd, data) => { if (fd === 1) captured += data; return Buffer.byteLength(String(data)); };
|
||
});
|
||
|
||
afterEach(() => {
|
||
global.fetch = origFetch;
|
||
if (origApiKey !== undefined) {
|
||
process.env.BRAVE_API_KEY = origApiKey;
|
||
} else {
|
||
delete process.env.BRAVE_API_KEY;
|
||
}
|
||
fs.writeSync = origWriteSync;
|
||
});
|
||
|
||
test('returns available=false when BRAVE_API_KEY is unset', async () => {
|
||
delete process.env.BRAVE_API_KEY;
|
||
|
||
await cmdWebsearch('test query', {}, false);
|
||
|
||
const output = JSON.parse(captured);
|
||
assert.strictEqual(output.available, false);
|
||
assert.ok(output.reason.includes('BRAVE_API_KEY'), 'should mention missing API key');
|
||
});
|
||
|
||
test('returns error when no query provided', async () => {
|
||
process.env.BRAVE_API_KEY = 'test-key';
|
||
|
||
await cmdWebsearch(null, {}, false);
|
||
|
||
const output = JSON.parse(captured);
|
||
assert.strictEqual(output.available, false);
|
||
assert.ok(output.error.includes('Query required'), 'should mention query required');
|
||
});
|
||
|
||
test('returns results for successful API response', async () => {
|
||
process.env.BRAVE_API_KEY = 'test-key';
|
||
|
||
global.fetch = async () => ({
|
||
ok: true,
|
||
json: async () => ({
|
||
web: {
|
||
results: [
|
||
{ title: 'Test Result', url: 'https://example.com', description: 'A test result', age: '1d' },
|
||
],
|
||
},
|
||
}),
|
||
});
|
||
|
||
await cmdWebsearch('test query', { limit: 5, freshness: 'pd' }, false);
|
||
|
||
const output = JSON.parse(captured);
|
||
assert.strictEqual(output.available, true);
|
||
assert.strictEqual(output.query, 'test query');
|
||
assert.strictEqual(output.count, 1);
|
||
assert.strictEqual(output.results[0].title, 'Test Result');
|
||
assert.strictEqual(output.results[0].url, 'https://example.com');
|
||
assert.strictEqual(output.results[0].age, '1d');
|
||
});
|
||
|
||
test('constructs correct URL parameters', async () => {
|
||
process.env.BRAVE_API_KEY = 'test-key';
|
||
let capturedUrl = '';
|
||
|
||
global.fetch = async (url) => {
|
||
capturedUrl = url;
|
||
return {
|
||
ok: true,
|
||
json: async () => ({ web: { results: [] } }),
|
||
};
|
||
};
|
||
|
||
await cmdWebsearch('node.js testing', { limit: 5, freshness: 'pd' }, false);
|
||
|
||
const parsed = new URL(capturedUrl);
|
||
assert.strictEqual(parsed.searchParams.get('q'), 'node.js testing', 'query param should decode to original string');
|
||
assert.strictEqual(parsed.searchParams.get('count'), '5', 'count param should be 5');
|
||
assert.strictEqual(parsed.searchParams.get('freshness'), 'pd', 'freshness param should be pd');
|
||
});
|
||
|
||
test('handles API error (non-200 status)', async () => {
|
||
process.env.BRAVE_API_KEY = 'test-key';
|
||
|
||
global.fetch = async () => ({
|
||
ok: false,
|
||
status: 401,
|
||
headers: { get: () => null },
|
||
});
|
||
|
||
await cmdWebsearch('test query', {}, false);
|
||
|
||
const output = JSON.parse(captured);
|
||
assert.strictEqual(output.available, false);
|
||
assert.ok(output.error.includes('401'), 'error should include status code');
|
||
});
|
||
|
||
test('handles network failure', async () => {
|
||
process.env.BRAVE_API_KEY = 'test-key';
|
||
|
||
global.fetch = async () => {
|
||
throw new Error('Network timeout');
|
||
};
|
||
|
||
await cmdWebsearch('test query', {}, false);
|
||
|
||
const output = JSON.parse(captured);
|
||
assert.strictEqual(output.available, false);
|
||
assert.strictEqual(output.error, 'Network timeout');
|
||
});
|
||
|
||
// ── New retry/timeout tests (A–E) ──────────────────────────────────────────
|
||
|
||
test('A. timeout is bounded: AbortSignal fires, resolves with available=false and attempts field', async (t) => {
|
||
process.env.BRAVE_API_KEY = 'test-key';
|
||
process.env.GSD_WEBSEARCH_TIMEOUT_MS = '20';
|
||
t.after(() => { delete process.env.GSD_WEBSEARCH_TIMEOUT_MS; });
|
||
|
||
global.fetch = async (_url, init) => new Promise((_resolve, reject) => {
|
||
init.signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
|
||
});
|
||
|
||
await cmdWebsearch('q', {}, false);
|
||
|
||
const output = JSON.parse(captured);
|
||
assert.strictEqual(output.available, false, 'should be available=false after timeout exhaustion');
|
||
assert.ok(typeof output.attempts === 'number', 'should include attempts field');
|
||
});
|
||
|
||
test('B. retry on 503 then success: succeeds on 2nd attempt, fetch called exactly twice', async () => {
|
||
process.env.BRAVE_API_KEY = 'test-key';
|
||
let callCount = 0;
|
||
|
||
global.fetch = async () => {
|
||
callCount++;
|
||
if (callCount === 1) {
|
||
return { ok: false, status: 503, headers: { get: () => null } };
|
||
}
|
||
return {
|
||
ok: true,
|
||
json: async () => ({
|
||
web: { results: [{ title: 'T', url: 'https://example.com', description: 'D' }] },
|
||
}),
|
||
};
|
||
};
|
||
|
||
await cmdWebsearch('test query', {}, false);
|
||
|
||
const output = JSON.parse(captured);
|
||
assert.strictEqual(output.available, true, 'should succeed after retry');
|
||
assert.strictEqual(output.results.length, 1, 'should have one result');
|
||
assert.strictEqual(callCount, 2, 'fetch should be called exactly twice');
|
||
});
|
||
|
||
test('C. 429 honors Retry-After then succeeds on 2nd call, fetch called exactly twice', async () => {
|
||
process.env.BRAVE_API_KEY = 'test-key';
|
||
let callCount = 0;
|
||
|
||
global.fetch = async () => {
|
||
callCount++;
|
||
if (callCount === 1) {
|
||
return {
|
||
ok: false,
|
||
status: 429,
|
||
headers: { get: (h) => h.toLowerCase() === 'retry-after' ? '0' : null },
|
||
};
|
||
}
|
||
return {
|
||
ok: true,
|
||
json: async () => ({
|
||
web: { results: [{ title: 'T', url: 'https://example.com', description: 'D' }] },
|
||
}),
|
||
};
|
||
};
|
||
|
||
await cmdWebsearch('test query', {}, false);
|
||
|
||
const output = JSON.parse(captured);
|
||
assert.strictEqual(output.available, true, 'should succeed after 429 retry');
|
||
assert.strictEqual(callCount, 2, 'fetch should be called exactly twice');
|
||
});
|
||
|
||
test('D. no retry on 401: fails immediately, fetch called exactly once', async () => {
|
||
process.env.BRAVE_API_KEY = 'test-key';
|
||
let callCount = 0;
|
||
|
||
global.fetch = async () => {
|
||
callCount++;
|
||
return { ok: false, status: 401, headers: { get: () => null } };
|
||
};
|
||
|
||
await cmdWebsearch('test query', {}, false);
|
||
|
||
const output = JSON.parse(captured);
|
||
assert.strictEqual(output.available, false, 'should be available=false');
|
||
assert.strictEqual(output.error, 'API error: 401', 'error should be API error: 401');
|
||
assert.strictEqual(output.attempts, undefined, 'should NOT have attempts field on immediate fail');
|
||
assert.strictEqual(callCount, 1, 'fetch should be called exactly once');
|
||
});
|
||
|
||
test('E. network error retried then exhausted: attempts=3, fetch called 3 times', async () => {
|
||
process.env.BRAVE_API_KEY = 'test-key';
|
||
let callCount = 0;
|
||
|
||
global.fetch = async () => {
|
||
callCount++;
|
||
throw new Error('boom');
|
||
};
|
||
|
||
await cmdWebsearch('test query', {}, false);
|
||
|
||
const output = JSON.parse(captured);
|
||
assert.strictEqual(output.available, false, 'should be available=false');
|
||
assert.ok(output.error.includes('boom'), 'error should include boom');
|
||
assert.strictEqual(output.attempts, 3, 'attempts should be 3');
|
||
assert.strictEqual(callCount, 3, 'fetch should be called 3 times');
|
||
});
|
||
});
|
||
|
||
describe('stats command', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
// #3217 (ADR-3180 §7.6 rule 4): a free-form ROADMAP.md (no version token
|
||
// anywhere) is COMPLETE scope for windowing (§7.1) — without this, an
|
||
// absent ROADMAP.md is UNREADABLE and stats withholds `percent`/counts.
|
||
// Individual tests below that write their own ROADMAP.md content
|
||
// overwrite this baseline.
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('returns valid JSON with empty project', () => {
|
||
const result = runGsdTools('stats', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const stats = JSON.parse(result.output);
|
||
assert.ok(Array.isArray(stats.phases), 'phases should be an array');
|
||
assert.strictEqual(stats.total_plans, 0);
|
||
assert.strictEqual(stats.total_summaries, 0);
|
||
assert.strictEqual(stats.percent, 0);
|
||
assert.strictEqual(stats.phases_completed, 0);
|
||
assert.strictEqual(stats.phases_total, 0);
|
||
assert.strictEqual(stats.requirements_total, 0);
|
||
assert.strictEqual(stats.requirements_complete, 0);
|
||
});
|
||
|
||
test('counts phases, plans, and summaries correctly', () => {
|
||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
||
const p2 = path.join(tmpDir, '.planning', 'phases', '02-api');
|
||
fs.mkdirSync(p1, { recursive: true });
|
||
fs.mkdirSync(p2, { recursive: true });
|
||
|
||
// Phase 1: 2 plans, 2 summaries, passing verification (complete)
|
||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||
fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan');
|
||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||
fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary');
|
||
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification');
|
||
|
||
// Phase 2: 1 plan, 0 summaries (planned)
|
||
fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan');
|
||
|
||
const result = runGsdTools('stats', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const stats = JSON.parse(result.output);
|
||
assert.strictEqual(stats.phases_total, 2);
|
||
assert.strictEqual(stats.phases_completed, 1);
|
||
assert.strictEqual(stats.total_plans, 3);
|
||
assert.strictEqual(stats.total_summaries, 2);
|
||
assert.strictEqual(stats.percent, 50);
|
||
assert.strictEqual(stats.plan_percent, 67);
|
||
});
|
||
|
||
// #3473 F2 (companion to #3357): determinePhaseStatus now resolves its
|
||
// *-VERIFICATION.md via the shared resolveVerificationFile resolver instead
|
||
// of a hand-rolled `.find()` over unsorted readdir() order. Before this fix,
|
||
// which of a canonical report and an ad-hoc `-CORRECTION-VERIFICATION.md`
|
||
// worksheet "won" was filesystem-dependent; the canonical report must now
|
||
// win deterministically regardless of directory-listing order.
|
||
test('#3473 F2: phase status resolves the canonical report over a -CORRECTION- worksheet, not readdir order', () => {
|
||
const p1 = path.join(tmpDir, '.planning', 'phases', '03-api');
|
||
fs.mkdirSync(p1, { recursive: true });
|
||
fs.writeFileSync(path.join(p1, '03-01-PLAN.md'), '# Plan');
|
||
fs.writeFileSync(path.join(p1, '03-01-SUMMARY.md'), '# Summary');
|
||
// The ad-hoc worksheet reports gaps_found; if it won the pick, the phase
|
||
// would read 'Executed', not 'Complete'.
|
||
fs.writeFileSync(path.join(p1, '03-CORRECTION-VERIFICATION.md'), '---\nstatus: gaps_found\n---\n# Correction worksheet');
|
||
fs.writeFileSync(path.join(p1, '03-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification');
|
||
|
||
const result = runGsdTools('stats', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const stats = JSON.parse(result.output);
|
||
const phase = stats.phases.find(p => p.number === '03');
|
||
assert.ok(phase, 'phase 03 must be present in stats output');
|
||
assert.strictEqual(phase.status, 'Complete', 'the canonical 03-VERIFICATION.md must win over the CORRECTION worksheet');
|
||
});
|
||
|
||
// #3511 BLOCKER-2 regression: a cross-phase stray VERIFICATION.md must not
|
||
// resolve as THIS phase's report. Phase 03's directory holds only a
|
||
// '04-VERIFICATION.md' (belongs to phase 04); an unscoped resolver would
|
||
// pick it up as phase 03's own report and read 'Complete'.
|
||
test('#3511: phase status is not Complete off a cross-phase stray VERIFICATION.md', () => {
|
||
const p1 = path.join(tmpDir, '.planning', 'phases', '03-test');
|
||
fs.mkdirSync(p1, { recursive: true });
|
||
fs.writeFileSync(path.join(p1, '03-01-PLAN.md'), '# Plan');
|
||
fs.writeFileSync(path.join(p1, '03-01-SUMMARY.md'), '# Summary');
|
||
fs.writeFileSync(path.join(p1, '04-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification');
|
||
|
||
const result = runGsdTools('stats', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const stats = JSON.parse(result.output);
|
||
const phase = stats.phases.find(p => p.number === '03');
|
||
assert.ok(phase, 'phase 03 must be present in stats output');
|
||
assert.notStrictEqual(phase.status, 'Complete',
|
||
`phase 03 must not report Complete off phase 04's report; got: ${phase.status}`);
|
||
});
|
||
|
||
test('counts requirements from REQUIREMENTS.md', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
|
||
`# Requirements
|
||
|
||
## v1 Requirements
|
||
|
||
- [x] **AUTH-01**: User can sign up
|
||
- [x] **AUTH-02**: User can log in
|
||
- [ ] **API-01**: REST endpoints
|
||
- [ ] **API-02**: GraphQL support
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('stats', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const stats = JSON.parse(result.output);
|
||
assert.strictEqual(stats.requirements_total, 4);
|
||
assert.strictEqual(stats.requirements_complete, 2);
|
||
});
|
||
|
||
test('reads last activity from STATE.md', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Last Activity:** 2025-06-15\n**Last Activity Description:** Working\n`
|
||
);
|
||
|
||
const result = runGsdTools('stats', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const stats = JSON.parse(result.output);
|
||
assert.strictEqual(stats.last_activity, '2025-06-15');
|
||
});
|
||
|
||
test('reads last activity from plain STATE.md template format', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||
`# Project State\n\n## Current Position\n\nPhase: 1 of 2 (Foundation)\nPlan: 1 of 1 in current phase\nStatus: In progress\nLast activity: 2025-06-16 — Finished plan 01-01\n`
|
||
);
|
||
|
||
const result = runGsdTools('stats', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const stats = JSON.parse(result.output);
|
||
assert.strictEqual(stats.last_activity, '2025-06-16 — Finished plan 01-01');
|
||
});
|
||
|
||
test('includes roadmap-only phases in totals and preserves hyphenated names', () => {
|
||
const p1 = path.join(tmpDir, '.planning', 'phases', '14-auth-hardening');
|
||
const p2 = path.join(tmpDir, '.planning', 'phases', '15-proof-generation');
|
||
fs.mkdirSync(p1, { recursive: true });
|
||
fs.mkdirSync(p2, { recursive: true });
|
||
fs.writeFileSync(path.join(p1, '14-01-PLAN.md'), '# Plan');
|
||
fs.writeFileSync(path.join(p1, '14-01-SUMMARY.md'), '# Summary');
|
||
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified');
|
||
fs.writeFileSync(path.join(p2, '15-01-PLAN.md'), '# Plan');
|
||
fs.writeFileSync(path.join(p2, '15-01-SUMMARY.md'), '# Summary');
|
||
fs.writeFileSync(path.join(p2, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified');
|
||
|
||
// #3217 (ADR-3180 §7.6 rule 4): no `vX.Y` token in the milestone heading
|
||
// — the ROADMAP has no STATE.md milestone pointer, so a real version
|
||
// token here would resolve to UNSCOPED (§7.1 row 4: "has versioned
|
||
// milestones, but no version resolved"), not the free-form COMPLETE
|
||
// window this test's counting assertions depend on.
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
`# Roadmap
|
||
|
||
- [x] **Phase 14: Auth Hardening**
|
||
- [x] **Phase 15: Proof Generation**
|
||
- [ ] **Phase 16: Multi-Claim Verification & UX**
|
||
|
||
## Milestone Growth
|
||
|
||
### Phase 14: Auth Hardening
|
||
**Goal:** Improve auth checks
|
||
|
||
### Phase 15: Proof Generation
|
||
**Goal:** Improve proof generation
|
||
|
||
### Phase 16: Multi-Claim Verification & UX
|
||
**Goal:** Support multi-claim verification
|
||
`
|
||
);
|
||
|
||
const result = runGsdTools('stats', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const stats = JSON.parse(result.output);
|
||
assert.strictEqual(stats.phases_total, 3);
|
||
assert.strictEqual(stats.phases_completed, 2);
|
||
assert.strictEqual(stats.percent, 67);
|
||
assert.strictEqual(stats.plan_percent, 100);
|
||
assert.strictEqual(
|
||
stats.phases.find(p => p.number === '16')?.name,
|
||
'Multi-Claim Verification & UX'
|
||
);
|
||
assert.strictEqual(
|
||
stats.phases.find(p => p.number === '16')?.status,
|
||
'Not Started'
|
||
);
|
||
});
|
||
|
||
test('reports git commit count and first commit date from repository history', () => {
|
||
gitOrThrow(['init'], { cwd: tmpDir });
|
||
gitOrThrow(['config', 'user.email', 'test@example.com'], { cwd: tmpDir });
|
||
gitOrThrow(['config', 'user.name', 'Test User'], { cwd: tmpDir });
|
||
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\n');
|
||
gitOrThrow(['add', '-A'], { cwd: tmpDir });
|
||
gitOrThrow(['commit', '-m', 'initial commit'], {
|
||
cwd: tmpDir,
|
||
env: {
|
||
...process.env,
|
||
GIT_AUTHOR_DATE: '2026-01-01T00:00:00Z',
|
||
GIT_COMMITTER_DATE: '2026-01-01T00:00:00Z',
|
||
},
|
||
});
|
||
|
||
fs.writeFileSync(path.join(tmpDir, 'README.md'), '# Updated\n');
|
||
gitOrThrow(['add', 'README.md'], { cwd: tmpDir });
|
||
gitOrThrow(['commit', '-m', 'second commit'], {
|
||
cwd: tmpDir,
|
||
env: {
|
||
...process.env,
|
||
GIT_AUTHOR_DATE: '2026-02-01T00:00:00Z',
|
||
GIT_COMMITTER_DATE: '2026-02-01T00:00:00Z',
|
||
},
|
||
});
|
||
|
||
const result = runGsdTools('stats', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const stats = JSON.parse(result.output);
|
||
assert.strictEqual(stats.git_commits, 2);
|
||
assert.strictEqual(stats.git_first_commit_date, '2026-01-01');
|
||
});
|
||
|
||
test('table format renders readable output', () => {
|
||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
||
fs.mkdirSync(p1, { recursive: true });
|
||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified');
|
||
|
||
const result = runGsdTools('stats table', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const parsed = JSON.parse(result.output);
|
||
assert.ok(parsed.rendered, 'table format should include rendered field');
|
||
assert.ok(parsed.rendered.includes('Statistics'), 'should include Statistics header');
|
||
assert.ok(parsed.rendered.includes('| Phase |'), 'should include table header');
|
||
assert.ok(parsed.rendered.includes('| 1 |'), 'should include phase row');
|
||
assert.ok(parsed.rendered.includes('1/1 phases'), 'should report phase progress');
|
||
});
|
||
|
||
test('phase with summaries but no verification is Executed, not Complete', () => {
|
||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
||
fs.mkdirSync(p1, { recursive: true });
|
||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||
const result = runGsdTools('stats', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const stats = JSON.parse(result.output);
|
||
const phase = stats.phases.find(p => p.number === '01' || p.number === '1');
|
||
assert.strictEqual(phase.status, 'Executed', 'should be Executed without verification');
|
||
assert.strictEqual(stats.phases_completed, 0, 'unverified phase should not count as completed');
|
||
});
|
||
|
||
test('phase with passing verification is Complete', () => {
|
||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
||
fs.mkdirSync(p1, { recursive: true });
|
||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification');
|
||
const result = runGsdTools('stats', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const stats = JSON.parse(result.output);
|
||
const phase = stats.phases.find(p => p.number === '01' || p.number === '1');
|
||
assert.strictEqual(phase.status, 'Complete', 'should be Complete with passing verification');
|
||
assert.strictEqual(stats.phases_completed, 1);
|
||
});
|
||
|
||
test('phase with gaps_found verification is Executed', () => {
|
||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
||
fs.mkdirSync(p1, { recursive: true });
|
||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: gaps_found\n---\n# Verification');
|
||
const result = runGsdTools('stats', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const stats = JSON.parse(result.output);
|
||
const phase = stats.phases.find(p => p.number === '01' || p.number === '1');
|
||
assert.strictEqual(phase.status, 'Executed', 'gaps_found should show as Executed');
|
||
});
|
||
|
||
test('phase with human_needed verification shows Needs Review', () => {
|
||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
||
fs.mkdirSync(p1, { recursive: true });
|
||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: human_needed\n---\n# Verification');
|
||
const result = runGsdTools('stats', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const stats = JSON.parse(result.output);
|
||
const phase = stats.phases.find(p => p.number === '01' || p.number === '1');
|
||
assert.strictEqual(phase.status, 'Needs Review', 'human_needed should show as Needs Review');
|
||
});
|
||
|
||
test('progress command also uses verification-aware status', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
`# Roadmap v1.0 MVP\n`
|
||
);
|
||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
||
fs.mkdirSync(p1, { recursive: true });
|
||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||
|
||
const result = runGsdTools('progress json', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.phases[0].status, 'Executed', 'progress should show Executed without verification');
|
||
});
|
||
|
||
test('does not duplicate phases when ROADMAP uses unpadded numbers and dirs use padded numbers', () => {
|
||
// ROADMAP.md uses "Phase 1:" (unpadded) but directory is "01-auth" (padded).
|
||
// Without normalization, the Map holds two entries: "1" and "01", doubling phases_total.
|
||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
||
fs.mkdirSync(p1, { recursive: true });
|
||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||
fs.writeFileSync(path.join(p1, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified');
|
||
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
[
|
||
'# Roadmap',
|
||
'',
|
||
'## Milestone v1',
|
||
'',
|
||
'### Phase 1: Auth',
|
||
'**Goal:** Authentication',
|
||
].join('\n')
|
||
);
|
||
|
||
const result = runGsdTools('stats', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const stats = JSON.parse(result.output);
|
||
assert.strictEqual(stats.phases_total, 1, 'unpadded ROADMAP heading and padded dir should merge into one phase');
|
||
assert.strictEqual(stats.phases_completed, 1);
|
||
assert.strictEqual(stats.phases.length, 1);
|
||
});
|
||
|
||
// ─── #2408: cmdStats last-write-wins fix — colliding dirs fold by precedence ──
|
||
//
|
||
// Two on-disk phase directories that normalize to the same phase key
|
||
// (e.g. `05-real/` + `05-real-stray/`) used to silently overwrite `status`
|
||
// at the directory-scan merge site (last-write-wins), so /gsd-stats could
|
||
// report `Not Started` for a phase that is actually `Complete` depending on
|
||
// fs.readdirSync order. The fix folds colliding statuses by precedence
|
||
// (Complete > Needs Review > Executed > In Progress > Planned > Not Started),
|
||
// so the furthest-along status wins regardless of read order.
|
||
|
||
test('#2408: colliding phase directories fold to the furthest-along status (Complete wins over Not Started)', () => {
|
||
// Two dirs that both normalize to phase key "05": `05-real/` (Complete)
|
||
// and `05-real-stray/` (empty → Not Started). The merged status MUST be
|
||
// Complete regardless of which directory the fs yields first.
|
||
const realDir = path.join(tmpDir, '.planning', 'phases', '05-real');
|
||
fs.mkdirSync(realDir, { recursive: true });
|
||
fs.writeFileSync(path.join(realDir, '01-01-PLAN.md'), '# Plan');
|
||
fs.writeFileSync(path.join(realDir, '01-01-SUMMARY.md'), '# Summary');
|
||
fs.writeFileSync(path.join(realDir, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified');
|
||
|
||
const strayDir = path.join(tmpDir, '.planning', 'phases', '05-real-stray');
|
||
fs.mkdirSync(strayDir, { recursive: true });
|
||
|
||
// ROADMAP declares Phase 5 so the dir-scan finds an explicit phase to populate.
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
[
|
||
'# Roadmap',
|
||
'',
|
||
'## Milestone v1',
|
||
'',
|
||
'### Phase 5: Real',
|
||
'**Goal:** The real phase',
|
||
].join('\n')
|
||
);
|
||
|
||
const result = runGsdTools('stats', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
|
||
const stats = JSON.parse(result.output);
|
||
assert.strictEqual(stats.phases_total, 1, 'two colliding dirs must merge into one phase');
|
||
assert.strictEqual(stats.phases_completed, 1, 'Complete status must win over Not Started after the fold');
|
||
const phase05 = stats.phases.find((p) => p.number === '05');
|
||
assert.ok(phase05, 'phase 05 must appear in stats output');
|
||
assert.strictEqual(phase05.status, 'Complete', 'folded status must be Complete, not Not Started');
|
||
});
|
||
|
||
test('#2408: foldPhaseStatus is commutative and order-independent (property)', () => {
|
||
// Direct unit test of the fold: a Complete colliding with a Not Started
|
||
// must yield Complete regardless of argument order. This is the property
|
||
// that makes the merge-site fix correct independent of fs read order.
|
||
const { foldPhaseStatus, PHASE_STATUS_PRECEDENCE } = require('../gsd-core/bin/lib/commands.cjs');
|
||
assert.strictEqual(foldPhaseStatus('Complete', 'Not Started'), 'Complete');
|
||
assert.strictEqual(foldPhaseStatus('Not Started', 'Complete'), 'Complete');
|
||
assert.strictEqual(foldPhaseStatus('Complete', 'Complete'), 'Complete');
|
||
// Every recognized status folded with a lower-precedence one wins.
|
||
for (let i = 0; i < PHASE_STATUS_PRECEDENCE.length - 1; i++) {
|
||
const higher = PHASE_STATUS_PRECEDENCE[i];
|
||
const lower = PHASE_STATUS_PRECEDENCE[i + 1];
|
||
assert.strictEqual(foldPhaseStatus(higher, lower), higher, `${higher} should beat ${lower}`);
|
||
assert.strictEqual(foldPhaseStatus(lower, higher), higher, `${higher} should beat ${lower} (commutative)`);
|
||
}
|
||
// Unrecognized status never beats a recognized one.
|
||
assert.strictEqual(foldPhaseStatus('Complete', '???'), 'Complete');
|
||
assert.strictEqual(foldPhaseStatus('???', 'Complete'), 'Complete');
|
||
// Two unrecognized → returns first arg (deterministic).
|
||
assert.strictEqual(foldPhaseStatus('foo', 'bar'), 'foo');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// check-commit command (#1395)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('check-commit command', () => {
|
||
const { createTempGitProject } = require('./helpers.cjs');
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempGitProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('allows commit when commit_docs is true', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({ commit_docs: true })
|
||
);
|
||
const result = runGsdTools('check-commit', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.allowed, true);
|
||
});
|
||
|
||
test('allows commit when no .planning/ files staged and commit_docs is false', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({ commit_docs: false })
|
||
);
|
||
// Stage a non-planning file
|
||
fs.writeFileSync(path.join(tmpDir, 'src.js'), 'console.log("hi")');
|
||
gitOrThrow(['add', 'src.js'], { cwd: tmpDir });
|
||
|
||
const result = runGsdTools('check-commit', tmpDir);
|
||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.allowed, true);
|
||
});
|
||
|
||
test('blocks commit when .planning/ files staged and commit_docs is false', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({ commit_docs: false })
|
||
);
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State');
|
||
gitOrThrow(['add', '.planning/STATE.md'], { cwd: tmpDir });
|
||
|
||
const result = runGsdTools('check-commit', tmpDir);
|
||
assert.ok(!result.success, 'should block commit');
|
||
assert.ok(result.error.includes('.planning/'), 'error should mention .planning/ files');
|
||
assert.ok(result.error.includes('unstage'), 'error should suggest unstage command');
|
||
});
|
||
|
||
// #3588 F1: cmdCheckCommit must resolve the SAME phase_commit_docs.<phase-id>
|
||
// tier `gsd-tools query commit` (cmdCommit) already honors (#3587/#3601).
|
||
// Before this fix, cmdCheckCommit read only project-level `commit_docs`, so
|
||
// a phase with `phase_commit_docs.<n>: true` under project `commit_docs:
|
||
// false` was ALLOWED by `query commit` and BLOCKED by this guard — the
|
||
// hook shipped in this same branch shells out to check-commit, so that
|
||
// contradiction was live. C4/C5 exercise both directions of the override;
|
||
// both fail against the pre-fix tree (project-level-only check).
|
||
test('C4 (#3588/#3587): project commit_docs:false + per-phase true ALLOWS the commit', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({ commit_docs: false, phase_commit_docs: { '03': true } })
|
||
);
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-widgets'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '03-widgets', 'SUMMARY.md'), '# Three');
|
||
gitOrThrow(['add', '.planning/phases/03-widgets/SUMMARY.md'], { cwd: tmpDir });
|
||
|
||
const result = runGsdTools('check-commit', tmpDir);
|
||
assert.ok(
|
||
result.success,
|
||
`phase_commit_docs.03:true must allow the commit even though project commit_docs is false: ${result.error || ''}`,
|
||
);
|
||
const output = JSON.parse(result.output);
|
||
assert.strictEqual(output.allowed, true);
|
||
});
|
||
|
||
test('C5 (#3588/#3587): project commit_docs:true + per-phase false BLOCKS the commit', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({ commit_docs: true, phase_commit_docs: { '03': false } })
|
||
);
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-widgets'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '03-widgets', 'SUMMARY.md'), '# Three');
|
||
gitOrThrow(['add', '.planning/phases/03-widgets/SUMMARY.md'], { cwd: tmpDir });
|
||
|
||
const result = runGsdTools('check-commit', tmpDir);
|
||
assert.ok(!result.success, 'phase_commit_docs.03:false must block the commit even though project commit_docs is true');
|
||
assert.ok(result.error.includes('03-widgets/SUMMARY.md'), result.error);
|
||
});
|
||
|
||
// #3588 C6: staged paths spanning two phase directories with DIFFERENT
|
||
// phase_commit_docs values must resolve against the FIRST phase (in
|
||
// detectPhaseNumberFromFiles's staged-path order) — the same first-match
|
||
// rule cmdCommit is pinned to (see the folded #3587 `multiPhaseFilesResolves
|
||
// AgainstFirstPhase` test above). This replaces the pre-fix baseline test,
|
||
// which could only assert the phase-blind "blocks everything" behavior
|
||
// because the per-phase tier did not exist here yet.
|
||
test('C6 (#3588): staged paths spanning two phase directories resolve against the FIRST phase, matching cmdCommit', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({ commit_docs: false, phase_commit_docs: { '01': true, '02': false } })
|
||
);
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-first'), { recursive: true });
|
||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-second'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '01-first', 'SUMMARY.md'), '# One');
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '02-second', 'SUMMARY.md'), '# Two');
|
||
gitOrThrow(
|
||
['add', '.planning/phases/01-first/SUMMARY.md', '.planning/phases/02-second/SUMMARY.md'],
|
||
{ cwd: tmpDir }
|
||
);
|
||
|
||
const result = runGsdTools('check-commit', tmpDir);
|
||
assert.ok(
|
||
result.success,
|
||
`phase 01 (first match) resolves phase_commit_docs.01:true, so the commit must be allowed despite phase 02:false: ${result.error || ''}`,
|
||
);
|
||
});
|
||
|
||
// #3588 F2: `git diff --cached --name-only` (no `-z`) C-style-quotes any
|
||
// path containing a non-ASCII byte or another special character — a staged
|
||
// `.planning/café.md` is reported as `".planning/caf\303\251.md"`, which
|
||
// does not start with `.planning/`, so the pre-fix guard MISSED it and
|
||
// allowed the commit — a false negative in the harm direction this guard
|
||
// exists to prevent. These MUST fail against the pre-fix (LF, no `-z`)
|
||
// tree and pass once `-z` + NUL-split lands.
|
||
test('F2 (#3588): a staged .planning/ file with a non-ASCII name is detected and blocked', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({ commit_docs: false })
|
||
);
|
||
const unicodeName = '.planning/café.md';
|
||
fs.writeFileSync(path.join(tmpDir, unicodeName), '# State');
|
||
gitOrThrow(['add', unicodeName], { cwd: tmpDir });
|
||
|
||
const result = runGsdTools('check-commit', tmpDir);
|
||
assert.ok(!result.success, 'a staged .planning/café.md must be detected and block the commit');
|
||
assert.ok(result.error.includes('café.md'), result.error);
|
||
});
|
||
|
||
test('F2 (#3588): a staged .planning/ file with a space in its name is detected and blocked', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({ commit_docs: false })
|
||
);
|
||
const spacedName = '.planning/with space.md';
|
||
fs.writeFileSync(path.join(tmpDir, spacedName), '# State');
|
||
gitOrThrow(['add', spacedName], { cwd: tmpDir });
|
||
|
||
const result = runGsdTools('check-commit', tmpDir);
|
||
assert.ok(!result.success, 'a staged .planning/ file with a space in its name must be detected and block the commit');
|
||
assert.ok(result.error.includes('with space.md'), result.error);
|
||
});
|
||
|
||
test('F2 (#3588): a staged .planning/ file with a quote character in its name is detected and blocked', (t) => {
|
||
// `"` is a reserved NTFS character — a file named `with"quote.md` cannot
|
||
// exist on Windows at all, so the fixture itself is unrepresentable
|
||
// there. This is not a gap in the guard's Windows behavior; it is an
|
||
// input that Windows filesystems reject outright. Do not re-enable this
|
||
// on win32 — see #3588.
|
||
if (process.platform === 'win32') {
|
||
t.skip('a `"` filename is illegal on Windows filesystems (#3588); fixture cannot be created');
|
||
return;
|
||
}
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({ commit_docs: false })
|
||
);
|
||
const quotedName = '.planning/with"quote.md';
|
||
fs.writeFileSync(path.join(tmpDir, quotedName), '# State');
|
||
gitOrThrow(['add', quotedName], { cwd: tmpDir });
|
||
|
||
const result = runGsdTools('check-commit', tmpDir);
|
||
assert.ok(!result.success, 'a staged .planning/ file with a quote character in its name must be detected and block the commit');
|
||
assert.ok(result.error.includes('quote.md'), result.error);
|
||
});
|
||
|
||
// #3588 C7 (flipped): the earlier pass's C7 test pinned a synthetic
|
||
// top-level filename (`.planning\STATE.md`, backslash as a literal
|
||
// character in a single path component, not a real nested directory — git
|
||
// never uses backslash as a tree separator, on any platform) as evidence
|
||
// that `f.startsWith('.planning\\')` was unreachable, and left the assertion
|
||
// at "currently allowed" pending a fix. That branch is now removed as dead
|
||
// code (git's plumbing output is always `/`-normalized, so a real Windows
|
||
// `.planning\<file>` path never reaches this filter as a `.planning\`
|
||
// prefix). This replaces it with the REAL analog of the same class of bug:
|
||
// a genuine `.planning/` file whose name merely CONTAINS a literal
|
||
// backslash character. Without `-z` that name is also C-style-quoted
|
||
// (`".planning/back\\slash.md"`) and missed; with `-z` it is read as raw,
|
||
// unquoted bytes and correctly detected via the plain `.planning/` prefix
|
||
// check alone — no backslash-specific branch needed.
|
||
test('C7 (#3588, flipped): a staged .planning/ file whose name contains a backslash character is detected and blocked', (t) => {
|
||
// `\` is the Windows path separator, not a legal character inside a
|
||
// single filename component — a file literally named `back\slash.md`
|
||
// cannot be created on Windows filesystems, so the fixture itself is
|
||
// unrepresentable there. This is not a gap in the guard's Windows
|
||
// behavior; it is an input Windows rejects outright. Do not re-enable
|
||
// this on win32 — see #3588.
|
||
if (process.platform === 'win32') {
|
||
t.skip('a `\\` filename is illegal on Windows filesystems (#3588); fixture cannot be created');
|
||
return;
|
||
}
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({ commit_docs: false })
|
||
);
|
||
const backslashInName = '.planning/back\\slash.md';
|
||
fs.writeFileSync(path.join(tmpDir, backslashInName), '# State');
|
||
gitOrThrow(['add', backslashInName], { cwd: tmpDir });
|
||
|
||
const result = runGsdTools('check-commit', tmpDir);
|
||
assert.ok(
|
||
!result.success,
|
||
'a staged .planning/ file whose name contains a backslash character must be detected and block the commit',
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// commit-docs-guard: opt-in pre-commit hook (#3588)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
// #3901: a developer's GLOBAL core.hooksPath (~/.gitconfig) applies to every
|
||
// fresh repo — the guard correctly refuses to install a hook git would never
|
||
// run, which used to fail the guard suites' beforeEach (18 tests across the
|
||
// A/B/D suites) on machines that centralize commit hooks. Pin
|
||
// GIT_CONFIG_GLOBAL to an empty file (runGsdTools and the git helpers
|
||
// propagate process.env to every child), making the fixtures independent of
|
||
// the host's git configuration. A LOCAL repo value cannot isolate this:
|
||
// `git config --get` returns any non-empty local value (same refusal), and an
|
||
// empty local value makes rev-parse --git-path hooks resolve to `./` — not
|
||
// `.git/hooks`. Returns a restore function for the suite's after().
|
||
function isolateGlobalGitConfig() {
|
||
const dir = createTempDir('gsd-3901-gitconfig-');
|
||
const prev = process.env.GIT_CONFIG_GLOBAL;
|
||
process.env.GIT_CONFIG_GLOBAL = path.join(dir, 'global.gitconfig');
|
||
fs.writeFileSync(process.env.GIT_CONFIG_GLOBAL, '');
|
||
return () => {
|
||
if (prev === undefined) delete process.env.GIT_CONFIG_GLOBAL;
|
||
else process.env.GIT_CONFIG_GLOBAL = prev;
|
||
cleanup(dir);
|
||
};
|
||
}
|
||
|
||
describe('commit-docs-guard hook script (#3588 A1-A5)', () => {
|
||
const { createTempGitProject, TEST_ENV_BASE } = require('./helpers.cjs');
|
||
const { runHook } = require('./helpers/process-seam.cjs');
|
||
const REPO_ROOT = path.join(__dirname, '..');
|
||
const HOOK_MARKER = '# gsd-core:commit-docs-guard';
|
||
let tmpDir;
|
||
let hookPath;
|
||
|
||
// #3901: see isolateGlobalGitConfig — shared by all three guard suites.
|
||
const restoreGitConfig = isolateGlobalGitConfig();
|
||
after(restoreGitConfig);
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempGitProject();
|
||
const enableResult = runGsdTools('commit-docs-guard enable --raw', tmpDir);
|
||
assert.ok(enableResult.success, `enable failed: ${enableResult.error}`);
|
||
hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
|
||
});
|
||
|
||
test('#3901: the suite isolates children from the host git config (global core.hooksPath)', () => {
|
||
// The developer-machine scenario this suite must survive: a hostile
|
||
// ~/.gitconfig with core.hooksPath set. The before() hook pins
|
||
// GIT_CONFIG_GLOBAL to an empty file; this pins the seam is actually
|
||
// armed and reaching children — a child git sees NO hooksPath from the
|
||
// host, so the guard never refuses and the 18 tests never fail. (A child
|
||
// given an explicitly hostile GIT_CONFIG_GLOBAL still refuses — that is
|
||
// the guard being correct, and it is covered where the refusal is
|
||
// asserted.)
|
||
assert.ok(
|
||
process.env.GIT_CONFIG_GLOBAL && fs.existsSync(process.env.GIT_CONFIG_GLOBAL),
|
||
'the isolation file is armed for this suite',
|
||
);
|
||
assert.equal(fs.readFileSync(process.env.GIT_CONFIG_GLOBAL, 'utf-8'), '',
|
||
'the isolation file is empty — children inherit no host config');
|
||
const { spawnSync } = require('node:child_process');
|
||
const probe = spawnSync('git', ['config', '--get', 'core.hooksPath'], {
|
||
cwd: tmpDir,
|
||
encoding: 'utf-8',
|
||
timeout: 15_000,
|
||
});
|
||
assert.notEqual(probe.status, 0, `a child git must not see a host core.hooksPath; got: ${probe.stdout}`);
|
||
assert.ok(fs.existsSync(hookPath), 'the beforeEach enable installed the hook at the repo-local default path');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('A1: hook script carries the gsd-core:commit-docs-guard marker', () => {
|
||
const content = fs.readFileSync(hookPath, 'utf8');
|
||
assert.ok(content.includes(HOOK_MARKER), 'written hook must carry the marker line');
|
||
});
|
||
|
||
test('A2: hook script uses LF-only line endings (boundary — Windows)', () => {
|
||
const content = fs.readFileSync(hookPath, 'utf8');
|
||
assert.ok(!content.includes('\r'), 'a CRLF shebang is not executable under Git Bash');
|
||
});
|
||
|
||
test('A3: hook file is executable after enable', () => {
|
||
if (process.platform === 'win32') return; // exec bit is not the Windows-relevant assertion
|
||
const mode = fs.statSync(hookPath).mode;
|
||
assert.ok((mode & 0o111) !== 0, 'pre-commit hook must carry the executable bit');
|
||
});
|
||
|
||
test('A4: hook exits zero when the guard allows', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ commit_docs: true }));
|
||
const result = runHook(hookPath, [], {
|
||
interpreter: 'bash',
|
||
cwd: tmpDir,
|
||
env: { ...process.env, ...TEST_ENV_BASE, RUNTIME_DIR: REPO_ROOT },
|
||
});
|
||
assert.strictEqual(result.exitCode, 0, `stdout=${result.stdout} stderr=${result.stderr}`);
|
||
});
|
||
|
||
test('A5: hook exits non-zero and names the staged files when the guard blocks', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ commit_docs: false }));
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State');
|
||
gitOrThrow(['add', '.planning/STATE.md'], { cwd: tmpDir });
|
||
const result = runHook(hookPath, [], {
|
||
interpreter: 'bash',
|
||
cwd: tmpDir,
|
||
env: { ...process.env, ...TEST_ENV_BASE, RUNTIME_DIR: REPO_ROOT },
|
||
});
|
||
assert.notStrictEqual(result.exitCode, 0, 'hook must exit non-zero when the guard blocks');
|
||
assert.ok(result.stderr.includes('.planning/STATE.md'), result.stderr);
|
||
});
|
||
});
|
||
|
||
describe('commit-docs-guard enable/disable (#3588 B1-B15)', () => {
|
||
const { createTempGitProject } = require('./helpers.cjs');
|
||
let tmpDir;
|
||
|
||
// #3901: this suite also runs `enable` against fresh repos — the same
|
||
// hostile-global exposure as the A suite (review finding).
|
||
const restoreGitConfigB = isolateGlobalGitConfig();
|
||
after(restoreGitConfigB);
|
||
|
||
afterEach(() => {
|
||
if (tmpDir) cleanup(tmpDir);
|
||
tmpDir = undefined;
|
||
});
|
||
|
||
test('B1: enable writes an executable hook and reports success', () => {
|
||
tmpDir = createTempGitProject();
|
||
const result = runGsdTools('commit-docs-guard enable --raw', tmpDir);
|
||
assert.ok(result.success, result.error);
|
||
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
|
||
assert.ok(fs.existsSync(hookPath));
|
||
if (process.platform !== 'win32') {
|
||
assert.ok((fs.statSync(hookPath).mode & 0o111) !== 0);
|
||
}
|
||
});
|
||
|
||
test('B2: enable refuses to clobber an existing foreign pre-commit hook', () => {
|
||
tmpDir = createTempGitProject();
|
||
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
|
||
const foreignContent = '#!/bin/sh\necho foreign\n';
|
||
fs.writeFileSync(hookPath, foreignContent);
|
||
fs.chmodSync(hookPath, 0o755);
|
||
const result = runGsdTools('commit-docs-guard enable --raw', tmpDir);
|
||
assert.ok(!result.success, 'enable must refuse to overwrite a foreign hook');
|
||
assert.ok(result.error.includes(hookPath), result.error);
|
||
assert.strictEqual(fs.readFileSync(hookPath, 'utf8'), foreignContent, 'foreign hook must be byte-unchanged');
|
||
});
|
||
|
||
test('B3: enable twice is idempotent — no duplicated content', () => {
|
||
tmpDir = createTempGitProject();
|
||
const r1 = runGsdTools('commit-docs-guard enable --raw', tmpDir);
|
||
assert.ok(r1.success, r1.error);
|
||
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
|
||
const first = fs.readFileSync(hookPath, 'utf8');
|
||
const r2 = runGsdTools('commit-docs-guard enable --raw', tmpDir);
|
||
assert.ok(r2.success, r2.error);
|
||
const second = fs.readFileSync(hookPath, 'utf8');
|
||
assert.strictEqual(second, first, 'a second enable must not change or duplicate content');
|
||
const markerCount = (second.match(/# gsd-core:commit-docs-guard/g) || []).length;
|
||
assert.strictEqual(markerCount, 1, 'exactly one marker line, never duplicated');
|
||
});
|
||
|
||
test('B4: disable removes our hook', () => {
|
||
tmpDir = createTempGitProject();
|
||
const enableResult = runGsdTools('commit-docs-guard enable --raw', tmpDir);
|
||
assert.ok(enableResult.success, enableResult.error);
|
||
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
|
||
assert.ok(fs.existsSync(hookPath));
|
||
const result = runGsdTools('commit-docs-guard disable --raw', tmpDir);
|
||
assert.ok(result.success, result.error);
|
||
assert.ok(!fs.existsSync(hookPath));
|
||
});
|
||
|
||
test('B5: disable refuses to remove a foreign hook', () => {
|
||
tmpDir = createTempGitProject();
|
||
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
|
||
const foreignContent = '#!/bin/sh\necho foreign\n';
|
||
fs.writeFileSync(hookPath, foreignContent);
|
||
fs.chmodSync(hookPath, 0o755);
|
||
const result = runGsdTools('commit-docs-guard disable --raw', tmpDir);
|
||
assert.ok(!result.success, 'disable must refuse to remove a foreign hook');
|
||
assert.strictEqual(fs.readFileSync(hookPath, 'utf8'), foreignContent, 'foreign hook must be byte-unchanged');
|
||
});
|
||
|
||
test('B6: disable with no hook present is a success no-op, not an error', () => {
|
||
tmpDir = createTempGitProject();
|
||
const result = runGsdTools('commit-docs-guard disable --raw', tmpDir);
|
||
assert.ok(result.success, result.error);
|
||
});
|
||
|
||
test('B7: enable outside a git repository fails cleanly, nothing written', () => {
|
||
tmpDir = createTempDir();
|
||
const before = fs.readdirSync(tmpDir);
|
||
const result = runGsdTools('commit-docs-guard enable --raw', tmpDir);
|
||
assert.ok(!result.success, 'enable must fail outside a git repository');
|
||
assert.deepStrictEqual(fs.readdirSync(tmpDir), before, 'nothing may be written');
|
||
});
|
||
|
||
test('B8: enable resolves the real hooks dir when .git is a worktree file', (t) => {
|
||
tmpDir = createTempGitProject();
|
||
const worktreeParent = createTempDir();
|
||
t.after(() => cleanup(worktreeParent));
|
||
|
||
const wtDir = path.join(worktreeParent, 'wt');
|
||
gitOrThrow(['worktree', 'add', wtDir, '-b', 'gsd-test-commit-docs-guard-wt'], { cwd: tmpDir });
|
||
assert.ok(fs.statSync(path.join(wtDir, '.git')).isFile(), 'precondition: .git must be a file in a linked worktree');
|
||
|
||
const result = runGsdTools('commit-docs-guard enable --raw', wtDir);
|
||
assert.ok(result.success, result.error);
|
||
// Hooks are shared across worktrees in the COMMON git dir — never a
|
||
// literal `<worktree>/.git/hooks`.
|
||
assert.ok(!fs.existsSync(path.join(wtDir, '.git', 'hooks')), 'must never write a literal <worktree>/.git/hooks path');
|
||
const commonHookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
|
||
assert.ok(fs.existsSync(commonHookPath), 'hook must land in the real (common) hooks directory');
|
||
});
|
||
|
||
test('B9: enable refuses when core.hooksPath is already set', () => {
|
||
tmpDir = createTempGitProject();
|
||
gitOrThrow(['config', 'core.hooksPath', 'custom-hooks'], { cwd: tmpDir });
|
||
const result = runGsdTools('commit-docs-guard enable --raw', tmpDir);
|
||
assert.ok(!result.success, 'enable must refuse when core.hooksPath is set');
|
||
assert.ok(result.error.includes('core.hooksPath'), result.error);
|
||
assert.ok(!fs.existsSync(path.join(tmpDir, 'custom-hooks', 'pre-commit')), 'must not write into the hooksPath-configured dir either');
|
||
assert.ok(!fs.existsSync(path.join(tmpDir, '.git', 'hooks', 'pre-commit')), 'must not write the ordinary hooks dir either');
|
||
});
|
||
|
||
test('B10: marker detection tolerates a user-appended line', () => {
|
||
tmpDir = createTempGitProject();
|
||
const r1 = runGsdTools('commit-docs-guard enable --raw', tmpDir);
|
||
assert.ok(r1.success, r1.error);
|
||
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
|
||
fs.appendFileSync(hookPath, '\n# a user comment appended after install\n');
|
||
|
||
const r2 = runGsdTools('commit-docs-guard enable --raw', tmpDir);
|
||
assert.ok(r2.success, `enable must still recognize the edited hook as ours: ${r2.error}`);
|
||
assert.ok(
|
||
fs.readFileSync(hookPath, 'utf8').includes('a user comment appended after install'),
|
||
'enable must not silently discard the user edit on a recognized hook'
|
||
);
|
||
|
||
const r3 = runGsdTools('commit-docs-guard disable --raw', tmpDir);
|
||
assert.ok(r3.success, `disable must still recognize the edited hook as ours: ${r3.error}`);
|
||
assert.ok(!fs.existsSync(hookPath));
|
||
});
|
||
|
||
test('B11: no subcommand at all hits the unknown-subcommand routing guard', () => {
|
||
tmpDir = createTempGitProject();
|
||
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
|
||
const result = runGsdTools(['--json-errors', 'commit-docs-guard'], tmpDir);
|
||
assert.ok(!result.success, 'missing subcommand must not succeed');
|
||
assert.notStrictEqual(result.exitCode, 0, 'missing subcommand must exit non-zero');
|
||
const parsed = JSON.parse(result.error);
|
||
assert.deepStrictEqual(Object.keys(parsed).sort(), ['message', 'ok', 'reason']);
|
||
assert.strictEqual(parsed.ok, false);
|
||
assert.strictEqual(parsed.reason, 'sdk_unknown_command');
|
||
assert.strictEqual(/\n\s*at\s/.test(result.error), false, 'non-debug failure must not print a stack trace');
|
||
assert.ok(!fs.existsSync(hookPath), 'no hook may be written for a missing subcommand');
|
||
});
|
||
|
||
test('B12: an unknown subcommand hits the unknown-subcommand routing guard', () => {
|
||
tmpDir = createTempGitProject();
|
||
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
|
||
const result = runGsdTools(['--json-errors', 'commit-docs-guard', 'bogus'], tmpDir);
|
||
assert.ok(!result.success, 'an unrecognized subcommand must not succeed');
|
||
assert.notStrictEqual(result.exitCode, 0, 'an unrecognized subcommand must exit non-zero');
|
||
const parsed = JSON.parse(result.error);
|
||
assert.deepStrictEqual(Object.keys(parsed).sort(), ['message', 'ok', 'reason']);
|
||
assert.strictEqual(parsed.ok, false);
|
||
assert.strictEqual(parsed.reason, 'sdk_unknown_command');
|
||
assert.strictEqual(/\n\s*at\s/.test(result.error), false, 'non-debug failure must not print a stack trace');
|
||
assert.ok(!fs.existsSync(hookPath), 'no hook may be written for an unrecognized subcommand');
|
||
});
|
||
|
||
test('B13: an empty-string subcommand hits the unknown-subcommand routing guard', () => {
|
||
tmpDir = createTempGitProject();
|
||
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
|
||
const result = runGsdTools(['--json-errors', 'commit-docs-guard', ''], tmpDir);
|
||
assert.ok(!result.success, 'an empty-string subcommand must not succeed');
|
||
assert.notStrictEqual(result.exitCode, 0, 'an empty-string subcommand must exit non-zero');
|
||
const parsed = JSON.parse(result.error);
|
||
assert.deepStrictEqual(Object.keys(parsed).sort(), ['message', 'ok', 'reason']);
|
||
assert.strictEqual(parsed.ok, false);
|
||
assert.strictEqual(parsed.reason, 'sdk_unknown_command');
|
||
assert.strictEqual(/\n\s*at\s/.test(result.error), false, 'non-debug failure must not print a stack trace');
|
||
assert.ok(!fs.existsSync(hookPath), 'no hook may be written for an empty-string subcommand');
|
||
});
|
||
|
||
test('B14: a whitespace-only subcommand hits the unknown-subcommand routing guard', () => {
|
||
tmpDir = createTempGitProject();
|
||
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
|
||
const result = runGsdTools(['--json-errors', 'commit-docs-guard', ' '], tmpDir);
|
||
assert.ok(!result.success, 'a whitespace-only subcommand must not succeed');
|
||
assert.notStrictEqual(result.exitCode, 0, 'a whitespace-only subcommand must exit non-zero');
|
||
const parsed = JSON.parse(result.error);
|
||
assert.deepStrictEqual(Object.keys(parsed).sort(), ['message', 'ok', 'reason']);
|
||
assert.strictEqual(parsed.ok, false);
|
||
assert.strictEqual(parsed.reason, 'sdk_unknown_command');
|
||
assert.strictEqual(/\n\s*at\s/.test(result.error), false, 'non-debug failure must not print a stack trace');
|
||
assert.ok(!fs.existsSync(hookPath), 'no hook may be written for a whitespace-only subcommand');
|
||
});
|
||
|
||
test('B15: a flag-shaped value in the subcommand position hits the unknown-subcommand routing guard', () => {
|
||
tmpDir = createTempGitProject();
|
||
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
|
||
const result = runGsdTools(['--json-errors', 'commit-docs-guard', '--enable'], tmpDir);
|
||
assert.ok(!result.success, 'a flag-shaped subcommand must not succeed');
|
||
assert.notStrictEqual(result.exitCode, 0, 'a flag-shaped subcommand must exit non-zero');
|
||
const parsed = JSON.parse(result.error);
|
||
assert.deepStrictEqual(Object.keys(parsed).sort(), ['message', 'ok', 'reason']);
|
||
assert.strictEqual(parsed.ok, false);
|
||
assert.strictEqual(parsed.reason, 'sdk_unknown_command');
|
||
assert.strictEqual(/\n\s*at\s/.test(result.error), false, 'non-debug failure must not print a stack trace');
|
||
assert.ok(!fs.existsSync(hookPath), 'no hook may be written for a flag-shaped subcommand');
|
||
});
|
||
});
|
||
|
||
describe('commit-docs-guard real git commit wiring (#3588 D1-D3)', () => {
|
||
const { createTempGitProject, TEST_ENV_BASE } = require('./helpers.cjs');
|
||
const { runGit } = require('./helpers/process-seam.cjs');
|
||
const REPO_ROOT = path.join(__dirname, '..');
|
||
let tmpDir;
|
||
|
||
// #3901: the D suite's premise is the hook firing from .git/hooks/pre-commit
|
||
// — a hostile global core.hooksPath broke its beforeEach identically.
|
||
const restoreGitConfigD = isolateGlobalGitConfig();
|
||
after(restoreGitConfigD);
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempGitProject();
|
||
const enableResult = runGsdTools('commit-docs-guard enable --raw', tmpDir);
|
||
assert.ok(enableResult.success, `enable failed: ${enableResult.error}`);
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
function stagePlanningFile(name) {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', name), '# State\n');
|
||
gitOrThrow(['add', `.planning/${name}`], { cwd: tmpDir });
|
||
}
|
||
|
||
function commitEnv() {
|
||
// RUNTIME_DIR pins the hook's gsd_run resolution (the
|
||
// _runtime-launcher.snippet.sh preamble) to THIS checkout's own
|
||
// gsd-core/bin/gsd-tools.cjs rather than relying on an ambient PATH
|
||
// install or a config-dir fallback that would not exist in CI.
|
||
return { ...process.env, ...TEST_ENV_BASE, RUNTIME_DIR: REPO_ROOT };
|
||
}
|
||
|
||
function commitCount() {
|
||
return Number(gitOrThrow(['rev-list', '--count', 'HEAD'], { cwd: tmpDir }).trim());
|
||
}
|
||
|
||
test('D1: a real `git commit` is refused when commit_docs is false and .planning/ is staged', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ commit_docs: false }));
|
||
stagePlanningFile('STATE.md');
|
||
const before = commitCount();
|
||
const result = runGit(['commit', '-m', 'chore: should be refused'], { cwd: tmpDir, env: commitEnv() });
|
||
assert.notStrictEqual(result.exitCode, 0, `commit should have been refused; stdout=${result.stdout} stderr=${result.stderr}`);
|
||
assert.strictEqual(commitCount(), before, 'nothing should have been committed');
|
||
});
|
||
|
||
test('D2: a real `git commit` succeeds when commit_docs is true', () => {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ commit_docs: true }));
|
||
stagePlanningFile('STATE.md');
|
||
const before = commitCount();
|
||
const result = runGit(['commit', '-m', 'chore: should succeed'], { cwd: tmpDir, env: commitEnv() });
|
||
assert.strictEqual(result.exitCode, 0, `commit should have succeeded; stdout=${result.stdout} stderr=${result.stderr}`);
|
||
assert.strictEqual(commitCount(), before + 1);
|
||
});
|
||
|
||
test('D3: disable actually unwires the hook — the same D1 scenario now succeeds', () => {
|
||
const disableResult = runGsdTools('commit-docs-guard disable --raw', tmpDir);
|
||
assert.ok(disableResult.success, disableResult.error);
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ commit_docs: false }));
|
||
stagePlanningFile('STATE.md');
|
||
const before = commitCount();
|
||
const result = runGit(['commit', '-m', 'chore: allowed after disable'], { cwd: tmpDir, env: commitEnv() });
|
||
assert.strictEqual(result.exitCode, 0, `commit should have succeeded after disable; stdout=${result.stdout} stderr=${result.stderr}`);
|
||
assert.strictEqual(commitCount(), before + 1);
|
||
});
|
||
});
|
||
|
||
describe('commit-docs-guard default install scope guarantee (#3588 E2)', () => {
|
||
test('E2: the default install path wires nothing new — commit-docs-guard is opt-in only', () => {
|
||
// #3588 scope guarantee (40-design.md): bin/install.js wiring is
|
||
// explicitly OUT of scope. This is the regression lock for that
|
||
// narrowing — checked structurally (require()'d typed exports, never
|
||
// source-text grep) against the THREE surfaces that would make the hook
|
||
// install by default.
|
||
const { MANAGED_HOOKS } = require('../hooks/managed-hooks-registry.cjs');
|
||
assert.ok(
|
||
!MANAGED_HOOKS.some((h) => h.includes('commit-docs-guard')),
|
||
'commit-docs-guard must not be a MANAGED_HOOKS install-time hook'
|
||
);
|
||
|
||
// scripts/build-hooks.js HOOKS_TO_COPY is the single source of truth for
|
||
// both the shared hooks/dist/ bundle AND bin/install.js's
|
||
// INSTALLED_HOOK_FILES/GSD_UNINSTALL_HOOKS (see the "new hook-script
|
||
// registration invariants" ripple) — asserting against the exported
|
||
// array itself, not grepping either file's source text.
|
||
const { HOOKS_TO_COPY } = require('../scripts/build-hooks.js');
|
||
assert.ok(
|
||
!HOOKS_TO_COPY.includes('commit-docs-guard'),
|
||
'commit-docs-guard must not be copied into the shared hooks bundle'
|
||
);
|
||
|
||
const { GSD_UNINSTALL_HOOKS } = require('../bin/install.js');
|
||
assert.ok(
|
||
!GSD_UNINSTALL_HOOKS.includes('commit-docs-guard'),
|
||
'bin/install.js must not list commit-docs-guard among installed/uninstalled hook files'
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('_wsParseRetryAfter (#308)', () => {
|
||
const { _wsParseRetryAfter } = require('../gsd-core/bin/lib/commands.cjs');
|
||
|
||
test('integer seconds: "120" → 60000 (capped at 60s)', () => {
|
||
assert.strictEqual(_wsParseRetryAfter('120'), 60000);
|
||
});
|
||
|
||
test('leading zero: "01" → 1000', () => {
|
||
assert.strictEqual(_wsParseRetryAfter('01'), 1000);
|
||
});
|
||
|
||
test('whitespace: " 5 " → 5000', () => {
|
||
assert.strictEqual(_wsParseRetryAfter(' 5 '), 5000);
|
||
});
|
||
|
||
test('"0" → 0', () => {
|
||
assert.strictEqual(_wsParseRetryAfter('0'), 0);
|
||
});
|
||
|
||
test('value > 60s cap: "120000" → 60000', () => {
|
||
assert.strictEqual(_wsParseRetryAfter('120000'), 60000);
|
||
});
|
||
|
||
// ADR-456 §(a) reachability rule: this function is required directly
|
||
// (in-process), so t.mock.timers reaches it without any production change —
|
||
// it patches the global `Date` that `Date.now()` reads from regardless of
|
||
// whether the SUT goes through realClock. Fixed, second-aligned pin so the
|
||
// HTTP-date's whole-second precision doesn't round the expected value away.
|
||
const PINNED_MS = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z
|
||
|
||
test('future HTTP-date 5s ahead → exactly 5000 (deterministic)', (t) => {
|
||
t.mock.timers.enable(['Date']);
|
||
t.mock.timers.setTime(PINNED_MS);
|
||
const futureDate = new Date(PINNED_MS + 5000).toUTCString();
|
||
assert.strictEqual(_wsParseRetryAfter(futureDate), 5000);
|
||
});
|
||
|
||
test('past HTTP-date 5s behind → exactly 0 (deterministic)', (t) => {
|
||
t.mock.timers.enable(['Date']);
|
||
t.mock.timers.setTime(PINNED_MS);
|
||
const pastDate = new Date(PINNED_MS - 5000).toUTCString();
|
||
assert.strictEqual(_wsParseRetryAfter(pastDate), 0);
|
||
});
|
||
|
||
test('boundary: HTTP-date 59s ahead → 59000, not clamped', (t) => {
|
||
t.mock.timers.enable(['Date']);
|
||
t.mock.timers.setTime(PINNED_MS);
|
||
const d = new Date(PINNED_MS + 59_000).toUTCString();
|
||
assert.strictEqual(_wsParseRetryAfter(d), 59_000);
|
||
});
|
||
|
||
test('boundary: HTTP-date 60s ahead → 60000, at cap exactly', (t) => {
|
||
t.mock.timers.enable(['Date']);
|
||
t.mock.timers.setTime(PINNED_MS);
|
||
const d = new Date(PINNED_MS + 60_000).toUTCString();
|
||
assert.strictEqual(_wsParseRetryAfter(d), 60_000);
|
||
});
|
||
|
||
test('boundary: HTTP-date 61s ahead → clamped to 60000', (t) => {
|
||
t.mock.timers.enable(['Date']);
|
||
t.mock.timers.setTime(PINNED_MS);
|
||
const d = new Date(PINNED_MS + 61_000).toUTCString();
|
||
assert.strictEqual(_wsParseRetryAfter(d), 60_000);
|
||
});
|
||
|
||
test('"garbage" → null', () => {
|
||
assert.strictEqual(_wsParseRetryAfter('garbage'), null);
|
||
});
|
||
|
||
test('"" → null', () => {
|
||
assert.strictEqual(_wsParseRetryAfter(''), null);
|
||
});
|
||
|
||
test('null → null', () => {
|
||
assert.strictEqual(_wsParseRetryAfter(null), null);
|
||
});
|
||
});
|
||
|
||
// ─── Regressions: bug #1145 — query user-story.validate phantom command ────
|
||
//
|
||
// `query user-story.validate` was invoked by mvp-phase.md and verify-work.md
|
||
// but had no CJS handler (phantom command). Every invocation errored with
|
||
// "Unknown command: user-story — did you mean: user-story validate?".
|
||
//
|
||
// Calls the CLI via runGsdTools; no readFileSync source-grep.
|
||
|
||
describe('user-story validate command (bug #1145)', () => {
|
||
// Helper: call `query user-story.validate --story <story>` and parse JSON.
|
||
function validateStory(story) {
|
||
const result = runGsdTools(['query', 'user-story.validate', '--story', story]);
|
||
assert.equal(result.success, true, `user-story.validate exited non-zero: ${result.error || result.output}`);
|
||
let parsed;
|
||
try { parsed = JSON.parse(result.output); } catch {
|
||
assert.fail(`output was not valid JSON: ${result.output}`);
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
// Helper: call with --pick valid, return trimmed output string.
|
||
function validateStoryPickValid(story) {
|
||
const result = runGsdTools(['query', 'user-story.validate', '--story', story, '--pick', 'valid']);
|
||
assert.equal(result.success, true, `user-story.validate --pick valid exited non-zero: ${result.error || result.output}`);
|
||
return result.output.trim();
|
||
}
|
||
|
||
test('command is reachable — not a phantom (negative proof of bug #1145)', () => {
|
||
// Before the fix: exit 1 with "Unknown command: user-story"
|
||
const result = runGsdTools(['query', 'user-story.validate', '--story', 'As a user, I want to log in, so that I can access my account.']);
|
||
assert.equal(result.success, true, `Expected exit 0 but got: ${result.error || result.output}`);
|
||
});
|
||
|
||
test('canonical well-formed story returns { valid: true, errors: [], slots }', () => {
|
||
const out = validateStory('As a new user, I want to register and log in, so that I can access my account.');
|
||
assert.equal(typeof out, 'object');
|
||
assert.equal(out.valid, true, `expected valid:true, got: ${JSON.stringify(out)}`);
|
||
assert.ok(!out.errors || out.errors.length === 0, `unexpected errors: ${JSON.stringify(out.errors)}`);
|
||
// Slot extraction (see verify-work.md: "returns slot extractions")
|
||
assert.ok(out.slots && typeof out.slots === 'object', `expected slots object, got: ${JSON.stringify(out.slots)}`);
|
||
assert.equal(out.slots.role, 'new user');
|
||
assert.equal(out.slots.capability, 'register and log in');
|
||
assert.equal(out.slots.outcome, 'I can access my account');
|
||
});
|
||
|
||
test('whitespace-only role slot returns { valid: false } (Codex finding: .+ accepted spaces)', () => {
|
||
// "As a ," — role is whitespace-only; must be rejected
|
||
const out = validateStory('As a , I want to build reports, so that I can share status.');
|
||
assert.equal(out.valid, false, `whitespace role must be invalid: ${JSON.stringify(out)}`);
|
||
assert.ok(Array.isArray(out.errors) && out.errors.length > 0);
|
||
assert.equal(out.slots, null, 'slots must be null on invalid story');
|
||
});
|
||
|
||
test('whitespace-only capability slot returns { valid: false }', () => {
|
||
// ", I want to ," — capability is whitespace-only
|
||
const out = validateStory('As a user, I want to , so that I can share status.');
|
||
assert.equal(out.valid, false, `whitespace capability must be invalid: ${JSON.stringify(out)}`);
|
||
assert.ok(Array.isArray(out.errors) && out.errors.length > 0);
|
||
});
|
||
|
||
test('whitespace-only outcome slot returns { valid: false }', () => {
|
||
// ", so that ." — outcome is whitespace-only
|
||
const out = validateStory('As a user, I want to build reports, so that .');
|
||
assert.equal(out.valid, false, `whitespace outcome must be invalid: ${JSON.stringify(out)}`);
|
||
assert.ok(Array.isArray(out.errors) && out.errors.length > 0);
|
||
});
|
||
|
||
test('empty string returns { valid: false } with non-empty errors array', () => {
|
||
const out = validateStory('');
|
||
assert.equal(out.valid, false);
|
||
assert.ok(Array.isArray(out.errors) && out.errors.length > 0);
|
||
});
|
||
|
||
test('story missing "As a" prefix returns { valid: false }', () => {
|
||
const out = validateStory('I want to register so that I can log in.');
|
||
assert.equal(out.valid, false);
|
||
assert.ok(Array.isArray(out.errors) && out.errors.length > 0);
|
||
});
|
||
|
||
test('story missing ", I want to" clause returns { valid: false }', () => {
|
||
const out = validateStory('As a user, so that I can log in.');
|
||
assert.equal(out.valid, false);
|
||
assert.ok(Array.isArray(out.errors) && out.errors.length > 0);
|
||
});
|
||
|
||
test('story missing ", so that" clause returns { valid: false }', () => {
|
||
const out = validateStory('As a user, I want to register and log in.');
|
||
assert.equal(out.valid, false);
|
||
assert.ok(Array.isArray(out.errors) && out.errors.length > 0);
|
||
});
|
||
|
||
test('story missing trailing period returns { valid: false }', () => {
|
||
const out = validateStory('As a user, I want to register, so that I can log in');
|
||
assert.equal(out.valid, false);
|
||
assert.ok(Array.isArray(out.errors) && out.errors.length > 0);
|
||
});
|
||
|
||
test('whitespace-only story returns { valid: false }', () => {
|
||
const out = validateStory(' ');
|
||
assert.equal(out.valid, false);
|
||
assert.ok(Array.isArray(out.errors) && out.errors.length > 0);
|
||
});
|
||
|
||
test('--pick valid returns bare "true" for valid story (verify-work.md call shape)', () => {
|
||
const out = validateStoryPickValid('As a developer, I want to run tests, so that I can catch regressions.');
|
||
assert.equal(out, 'true', `expected bare "true" but got: ${JSON.stringify(out)}`);
|
||
});
|
||
|
||
test('--pick valid returns bare "false" for invalid story (verify-work.md call shape)', () => {
|
||
const out = validateStoryPickValid('Not a user story at all.');
|
||
assert.equal(out, 'false', `expected bare "false" but got: ${JSON.stringify(out)}`);
|
||
});
|
||
|
||
test('mvp-phase.md call shape: result has .valid boolean, .errors array, and .slots', () => {
|
||
// gsd_run query user-story.validate --story "$USER_STORY"
|
||
// mvp-phase.md uses: jq -r '.valid' and jq -r '.errors[]'
|
||
const out = validateStory('As a product manager, I want to export reports, so that I can share progress with stakeholders.');
|
||
assert.ok(Object.prototype.hasOwnProperty.call(out, 'valid'), 'missing "valid" field');
|
||
assert.ok(Object.prototype.hasOwnProperty.call(out, 'errors'), 'missing "errors" field');
|
||
assert.ok(Object.prototype.hasOwnProperty.call(out, 'slots'), 'missing "slots" field');
|
||
assert.equal(typeof out.valid, 'boolean');
|
||
assert.ok(Array.isArray(out.errors));
|
||
// slots is object on success, null on failure
|
||
assert.equal(out.valid, true);
|
||
assert.equal(typeof out.slots, 'object');
|
||
assert.notEqual(out.slots, null);
|
||
});
|
||
|
||
test('dotted-form (user-story.validate) works identically to spaced form', () => {
|
||
// Canonical dotted invocation used by workflows
|
||
const result = runGsdTools(['query', 'user-story.validate', '--story', 'As a user, I want to log in, so that I can see my dashboard.']);
|
||
assert.equal(result.success, true, `dotted form failed: ${result.error}`);
|
||
const out = JSON.parse(result.output);
|
||
assert.equal(out.valid, true);
|
||
});
|
||
|
||
test('boundary — minimal valid story passes', () => {
|
||
const out = validateStory('As a X, I want to Y, so that Z.');
|
||
assert.equal(out.valid, true, `minimal valid story should pass: ${JSON.stringify(out)}`);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// pr-subrepo — regressions (#666) + workflow source invariants
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('pr-subrepo', () => {
|
||
function writePrSubrepoConfig(dir, obj) {
|
||
const planningDir = path.join(dir, '.planning');
|
||
fs.mkdirSync(planningDir, { recursive: true });
|
||
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify(obj, null, 2));
|
||
}
|
||
|
||
function initPrSubrepo(dir) {
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
gitOrThrow(['init'], { cwd: dir });
|
||
gitOrThrow(['config', 'user.email', 'test@example.com'], { cwd: dir });
|
||
gitOrThrow(['config', 'user.name', 'Test'], { cwd: dir });
|
||
fs.writeFileSync(path.join(dir, '.gitkeep'), '');
|
||
fs.writeFileSync(path.join(dir, 'feature.js'), '// initial\n');
|
||
fs.writeFileSync(path.join(dir, 'a.js'), '// initial\n');
|
||
fs.writeFileSync(path.join(dir, 'b.js'), '// initial\n');
|
||
gitOrThrow(['add', '.gitkeep', 'feature.js', 'a.js', 'b.js'], { cwd: dir });
|
||
gitOrThrow(['commit', '-m', 'chore: initial commit'], { cwd: dir });
|
||
}
|
||
|
||
function wirePrSubrepoRemote(repoDir, bareDir) {
|
||
fs.mkdirSync(bareDir, { recursive: true });
|
||
gitOrThrow(['init', '--bare'], { cwd: bareDir });
|
||
gitOrThrow(['remote', 'add', 'origin', bareDir], { cwd: repoDir });
|
||
const branch = gitOrThrow(['branch', '--show-current'], { cwd: repoDir }).trim();
|
||
gitOrThrow(['push', 'origin', branch], { cwd: repoDir });
|
||
}
|
||
|
||
describe('regressions (#666 — cmdPrSubrepo seam)', () => {
|
||
let rootDir;
|
||
let subDir;
|
||
let bareDir;
|
||
|
||
beforeEach(() => {
|
||
rootDir = createTempDir('gsd-666-root-');
|
||
subDir = path.join(rootDir, 'backend');
|
||
bareDir = path.join(rootDir, '_bare-backend.git');
|
||
writePrSubrepoConfig(rootDir, { planning: { sub_repos: ['backend'] } });
|
||
initPrSubrepo(subDir);
|
||
wirePrSubrepoRemote(subDir, bareDir);
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(rootDir);
|
||
});
|
||
|
||
test('config-get planning.sub_repos resolves canonical config location', () => {
|
||
const res = runGsdTools(['query', 'config-get', 'planning.sub_repos'], rootDir);
|
||
assert.ok(res.success, `config-get planning.sub_repos failed: ${res.error}`);
|
||
assert.deepStrictEqual(JSON.parse(res.output), ['backend']);
|
||
});
|
||
|
||
test('config-get sub_repos (top-level) fails — confirming bug #666 Blocker 1 is gone', () => {
|
||
const res = runGsdTools(['query', 'config-get', 'sub_repos'], rootDir);
|
||
assert.ok(!res.success, 'top-level sub_repos key must not resolve — fix requires planning.sub_repos');
|
||
});
|
||
|
||
test('pr-subrepo happy path: branch created, files staged explicitly, commit pushed', () => {
|
||
fs.writeFileSync(path.join(subDir, 'feature.js'), 'module.exports = 42;\n');
|
||
|
||
const res = runGsdTools(
|
||
['query', 'pr-subrepo', 'fix(backend): add feature',
|
||
'--repo', 'backend', '--branch', 'fix-666-backend-pr'],
|
||
rootDir
|
||
);
|
||
assert.ok(res.success, `pr-subrepo failed: ${res.error}`);
|
||
|
||
const result = JSON.parse(res.output);
|
||
assert.strictEqual(result.ok, true);
|
||
assert.strictEqual(result.repo, 'backend');
|
||
assert.strictEqual(result.branch, 'fix-666-backend-pr');
|
||
assert.strictEqual(result.committed, true);
|
||
assert.ok(Array.isArray(result.files) && result.files.length > 0);
|
||
assert.ok(result.files.includes('feature.js'), `feature.js missing from files: ${JSON.stringify(result.files)}`);
|
||
assert.ok(typeof result.commit_hash === 'string' && result.commit_hash.length > 0);
|
||
});
|
||
|
||
test('pr-subrepo stages files explicitly — result.files lists every changed file', () => {
|
||
fs.writeFileSync(path.join(subDir, 'a.js'), '1\n');
|
||
fs.writeFileSync(path.join(subDir, 'b.js'), '2\n');
|
||
|
||
const res = runGsdTools(
|
||
['query', 'pr-subrepo', 'fix(backend): two files',
|
||
'--repo', 'backend', '--branch', 'fix-666-explicit-pr'],
|
||
rootDir
|
||
);
|
||
assert.ok(res.success, `pr-subrepo failed: ${res.error}`);
|
||
|
||
const result = JSON.parse(res.output);
|
||
assert.ok(result.files.includes('a.js'), 'a.js must be staged');
|
||
assert.ok(result.files.includes('b.js'), 'b.js must be staged');
|
||
});
|
||
|
||
test('pr-subrepo: nothing_to_commit when sub-repo is clean', () => {
|
||
const res = runGsdTools(
|
||
['query', 'pr-subrepo', 'fix(backend): nothing',
|
||
'--repo', 'backend', '--branch', 'fix-666-clean-pr'],
|
||
rootDir
|
||
);
|
||
assert.ok(res.success, `pr-subrepo should succeed on clean repo: ${res.error}`);
|
||
const result = JSON.parse(res.output);
|
||
assert.strictEqual(result.ok, true);
|
||
assert.strictEqual(result.committed, false);
|
||
assert.strictEqual(result.reason, 'nothing_to_commit');
|
||
});
|
||
|
||
test('pr-subrepo: duplicate branch guard — errors when branch already exists', () => {
|
||
fs.writeFileSync(path.join(subDir, 'a.js'), '1\n');
|
||
const first = runGsdTools(
|
||
['query', 'pr-subrepo', 'fix(backend): first',
|
||
'--repo', 'backend', '--branch', 'fix-666-dup-pr'],
|
||
rootDir
|
||
);
|
||
assert.ok(first.success, `first call failed: ${first.error}`);
|
||
|
||
fs.writeFileSync(path.join(subDir, 'b.js'), '2\n');
|
||
const second = runGsdTools(
|
||
['query', 'pr-subrepo', 'fix(backend): second',
|
||
'--repo', 'backend', '--branch', 'fix-666-dup-pr'],
|
||
rootDir
|
||
);
|
||
assert.ok(!second.success, 'Expected failure on duplicate branch name');
|
||
assert.ok(second.error.includes('already exists'), `Got: ${second.error}`);
|
||
});
|
||
|
||
test('pr-subrepo: missing --repo returns descriptive error', () => {
|
||
const res = runGsdTools(
|
||
['query', 'pr-subrepo', 'fix: msg', '--branch', 'some-branch'],
|
||
rootDir
|
||
);
|
||
assert.ok(!res.success);
|
||
assert.ok(res.error.includes('--repo required'), `Got: ${res.error}`);
|
||
});
|
||
|
||
test('pr-subrepo: missing --branch returns descriptive error', () => {
|
||
const res = runGsdTools(
|
||
['query', 'pr-subrepo', 'fix: msg', '--repo', 'backend'],
|
||
rootDir
|
||
);
|
||
assert.ok(!res.success);
|
||
assert.ok(res.error.includes('--branch required'), `Got: ${res.error}`);
|
||
});
|
||
|
||
test('pr-subrepo: missing commit message returns descriptive error', () => {
|
||
const res = runGsdTools(
|
||
['query', 'pr-subrepo', '--repo', 'backend', '--branch', 'some-branch'],
|
||
rootDir
|
||
);
|
||
assert.ok(!res.success);
|
||
assert.ok(res.error.includes('commit message required'), `Got: ${res.error}`);
|
||
});
|
||
|
||
test('pr-subrepo: non-existent repo path returns descriptive error', () => {
|
||
const res = runGsdTools(
|
||
['query', 'pr-subrepo', 'fix: msg', '--repo', 'nonexistent', '--branch', 'some-branch'],
|
||
rootDir
|
||
);
|
||
assert.ok(!res.success);
|
||
assert.ok(
|
||
res.error.includes('not found') || res.error.includes('nonexistent'),
|
||
`Got: ${res.error}`
|
||
);
|
||
});
|
||
|
||
test('pr-subrepo: path traversal (../escape) is rejected', () => {
|
||
const res = runGsdTools(
|
||
['query', 'pr-subrepo', 'fix: msg', '--repo', '../escape', '--branch', 'some-branch'],
|
||
rootDir
|
||
);
|
||
assert.ok(!res.success, 'Expected failure on path traversal attempt');
|
||
assert.ok(
|
||
res.error.includes('unsafe') || res.error.includes('escape'),
|
||
`Got: ${res.error}`
|
||
);
|
||
});
|
||
|
||
test('pr-subrepo push failure: branch+commit survive when push is rejected (no data loss)', () => {
|
||
// Reproduce the data-loss scenario flagged in review: a rejecting remote must leave
|
||
// the local branch+commit intact so the user can retry git push manually.
|
||
const branch = 'fix-666-push-fail-pr';
|
||
|
||
// Wire a bare remote with a pre-receive hook that rejects all pushes.
|
||
const rejectingBare = path.join(rootDir, '_rejecting-bare.git');
|
||
fs.mkdirSync(rejectingBare, { recursive: true });
|
||
gitOrThrow(['init', '--bare'], { cwd: rejectingBare });
|
||
const hookPath = path.join(rejectingBare, 'hooks', 'pre-receive');
|
||
fs.writeFileSync(hookPath, '#!/bin/sh\nexit 1\n');
|
||
fs.chmodSync(hookPath, 0o755);
|
||
|
||
// Point origin at the rejecting bare (overwrite the working one wired in beforeEach).
|
||
gitOrThrow(['remote', 'set-url', 'origin', rejectingBare], { cwd: subDir });
|
||
|
||
fs.writeFileSync(path.join(subDir, 'feature.js'), 'IMPORTANT USER WORK\n');
|
||
|
||
const res = runGsdTools(
|
||
['query', 'pr-subrepo', 'fix(backend): push-fail test',
|
||
'--repo', 'backend', '--branch', branch],
|
||
rootDir
|
||
);
|
||
|
||
// Command must fail because push was rejected.
|
||
assert.ok(!res.success, `Expected failure on rejected push, got success: ${res.output}`);
|
||
|
||
// The local branch must still exist — work must not be lost.
|
||
const branches = gitOrThrow(['branch', '--list', branch], { cwd: subDir });
|
||
assert.ok(branches.trim().length > 0, `Branch ${branch} was deleted after push failure — user work lost`);
|
||
|
||
// The commit on that branch must contain the user's changes.
|
||
const log = gitOrThrow(['log', branch, '--oneline', '-1'], { cwd: subDir });
|
||
assert.ok(log.trim().length > 0, `No commit on ${branch} — staged work was lost`);
|
||
});
|
||
|
||
test('pr-subrepo porcelain: staged rename — both old and new paths in result.files', () => {
|
||
// git mv produces "R old -> new" in porcelain v1; both paths must be staged.
|
||
gitOrThrow(['mv', 'feature.js', 'renamed-feature.js'], { cwd: subDir });
|
||
|
||
const res = runGsdTools(
|
||
['query', 'pr-subrepo', 'fix(backend): rename',
|
||
'--repo', 'backend', '--branch', 'fix-666-rename-pr'],
|
||
rootDir
|
||
);
|
||
assert.ok(res.success, `pr-subrepo failed: ${res.error}`);
|
||
const result = JSON.parse(res.output);
|
||
assert.ok(result.files.includes('feature.js'), `old path missing: ${JSON.stringify(result.files)}`);
|
||
assert.ok(result.files.includes('renamed-feature.js'), `new path missing: ${JSON.stringify(result.files)}`);
|
||
});
|
||
|
||
test('pr-subrepo porcelain: non-ASCII filename (core.quotePath=false)', () => {
|
||
// Without -c core.quotePath=false, "café.js" is C-escaped → slice(2) parse breaks.
|
||
fs.writeFileSync(path.join(subDir, 'café.js'), '// initial\n');
|
||
gitOrThrow(['add', 'café.js'], { cwd: subDir });
|
||
gitOrThrow(['commit', '-m', 'chore: add café.js'], { cwd: subDir });
|
||
fs.writeFileSync(path.join(subDir, 'café.js'), 'updated\n');
|
||
|
||
const res = runGsdTools(
|
||
['query', 'pr-subrepo', 'fix(backend): non-ascii',
|
||
'--repo', 'backend', '--branch', 'fix-666-nonascii-pr'],
|
||
rootDir
|
||
);
|
||
assert.ok(res.success, `pr-subrepo failed: ${res.error}`);
|
||
const result = JSON.parse(res.output);
|
||
assert.ok(result.files.includes('café.js'), `non-ASCII file missing: ${JSON.stringify(result.files)}`);
|
||
});
|
||
|
||
test('pr-subrepo porcelain: fc property — parsed filenames are always non-empty strings', () => {
|
||
// Local mirror of cmdPrSubrepo's porcelain line-parsing logic (commands.cts).
|
||
// Tests the transformation contract without needing a real git repo.
|
||
function parsePorcelainLine(line) {
|
||
const normalized = line.trimStart();
|
||
const file = normalized.slice(2).trim();
|
||
const arrowIdx = file.indexOf(' -> ');
|
||
return arrowIdx !== -1
|
||
? [file.slice(0, arrowIdx).trim(), file.slice(arrowIdx + 4).trim()]
|
||
: [file];
|
||
}
|
||
|
||
const safeFilename = fc.stringMatching(/^[a-zA-Z0-9._-]+$/);
|
||
const xyChar = fc.constantFrom('M', 'A', 'D', 'R', 'C', 'U');
|
||
const normalLine = fc.tuple(xyChar, xyChar, safeFilename)
|
||
.map(([x, y, f]) => `${x}${y} ${f}`);
|
||
const renameLine = fc.tuple(xyChar, safeFilename, safeFilename)
|
||
.map(([x, o, n]) => `${x} ${o} -> ${n}`);
|
||
// First-line trim edge case: leading space stripped by execGit global trim
|
||
const trimmedLine = fc.tuple(xyChar, safeFilename)
|
||
.map(([y, f]) => ` ${y} ${f}`);
|
||
|
||
fc.assert(fc.property(
|
||
fc.oneof(normalLine, renameLine, trimmedLine),
|
||
(line) => {
|
||
const files = parsePorcelainLine(line);
|
||
return files.length > 0 && files.every(f => typeof f === 'string' && f.length > 0);
|
||
}
|
||
));
|
||
});
|
||
});
|
||
|
||
describe('workflow source invariants (#666 — pr-branch.md)', () => {
|
||
// allow-test-rule: source-text-is-the-product see #666
|
||
// pr-branch.md is a workflow file whose deployed text IS the runtime contract.
|
||
const workflowPath = path.resolve(__dirname, '..', 'gsd-core', 'workflows', 'pr-branch.md');
|
||
let wfContent;
|
||
|
||
test('setup', () => {
|
||
wfContent = fs.readFileSync(workflowPath, 'utf-8');
|
||
assert.ok(wfContent.length > 0);
|
||
});
|
||
|
||
test('uses planning.sub_repos (canonical key) — not legacy top-level sub_repos', () => {
|
||
wfContent = wfContent || fs.readFileSync(workflowPath, 'utf-8');
|
||
assert.ok(wfContent.includes('planning.sub_repos'), 'must call config-get planning.sub_repos');
|
||
assert.ok(
|
||
!/config-get sub_repos(?!\.)/.test(wfContent),
|
||
'must not call config-get sub_repos without the planning. prefix'
|
||
);
|
||
});
|
||
|
||
test('delegates git work to gsd_run query pr-subrepo — no inline git add -A in code', () => {
|
||
wfContent = wfContent || fs.readFileSync(workflowPath, 'utf-8');
|
||
assert.ok(wfContent.includes('pr-subrepo'), 'must invoke the pr-subrepo seam');
|
||
const hasForbiddenGitAdd = /^\s*git(?:\s+-C\s+\S+)?\s+add\s+(?:-A|\.)\b/m.test(wfContent);
|
||
assert.ok(!hasForbiddenGitAdd, 'must not use git add -A or git add . as a shell command');
|
||
});
|
||
|
||
test('persists dirty-repo list without bash arrays (temp file or inline string)', () => {
|
||
wfContent = wfContent || fs.readFileSync(workflowPath, 'utf-8');
|
||
assert.ok(
|
||
!wfContent.includes('DIRTY_REPOS=()') && !wfContent.includes('DIRTY_REPOS+='),
|
||
'bash arrays must not be used — they do not survive across command blocks'
|
||
);
|
||
});
|
||
|
||
test('branch name includes repo-specific slug to avoid root PR_BRANCH collision', () => {
|
||
wfContent = wfContent || fs.readFileSync(workflowPath, 'utf-8');
|
||
assert.ok(
|
||
/REPO_SAFE|SUB_BRANCH.*REPO/.test(wfContent),
|
||
'sub-repo branch name must embed a repo-specific component'
|
||
);
|
||
});
|
||
|
||
test('handle_sub_repos positioned before analyze_commits', () => {
|
||
wfContent = wfContent || fs.readFileSync(workflowPath, 'utf-8');
|
||
const a = wfContent.indexOf('handle_sub_repos');
|
||
const b = wfContent.indexOf('analyze_commits');
|
||
assert.ok(a !== -1 && b !== -1 && a < b);
|
||
});
|
||
|
||
test('dirty-scan rejects traversal, newline, and symlink entries before invoking git (security)', () => {
|
||
// Extracts and executes the ACTUAL node -e script shipped in pr-branch.md — not a
|
||
// mirror — so this test fails if the real script regresses, not just a copy of it.
|
||
wfContent = wfContent || fs.readFileSync(workflowPath, 'utf-8');
|
||
const match = wfContent.match(/node -e "([\s\S]*?)"\s+"\$SUB_REPOS_JSON" "\$ROOT" "\$DIRTY_FILE"/);
|
||
assert.ok(match, 'could not extract dirty-scan node script from pr-branch.md');
|
||
const script = match[1];
|
||
|
||
// Helper: init a git repo with a TRACKED dirty change. An untracked file would be
|
||
// filtered by the ?? exclusion and the repo would look clean even without the guard,
|
||
// making the assertions vacuous. A tracked modification ensures that WITHOUT the
|
||
// guard the repo WOULD be reported dirty, so the test genuinely fails-first.
|
||
const initDirtyRepo = (dir, file) => {
|
||
gitOrThrow(['init'], { cwd: dir });
|
||
gitOrThrow(['config', 'user.email', 'test@example.com'], { cwd: dir });
|
||
gitOrThrow(['config', 'user.name', 'Test'], { cwd: dir });
|
||
fs.writeFileSync(path.join(dir, file), 'committed\n');
|
||
gitOrThrow(['add', file], { cwd: dir });
|
||
gitOrThrow(['-c', 'commit.gpgsign=false', 'commit', '-m', 'init'], { cwd: dir });
|
||
fs.writeFileSync(path.join(dir, file), 'modified\n');
|
||
};
|
||
|
||
const scanRoot = createTempDir('gsd-666-scan-root-');
|
||
const outsideDir = createTempDir('gsd-666-scan-outside-');
|
||
initDirtyRepo(outsideDir, 'secret.txt');
|
||
|
||
// Positive control: a legit dirty sub-repo INSIDE the workspace must still be reported,
|
||
// so the test can't pass by a guard that simply rejects everything.
|
||
const backendDir = path.join(scanRoot, 'backend');
|
||
fs.mkdirSync(backendDir, { recursive: true });
|
||
initDirtyRepo(backendDir, 'app.js');
|
||
|
||
// Symlink escape: an in-tree name with no ".." and no "/" that points outside root.
|
||
// path.resolve would keep it "inside"; only realpathSync catches it. Symlink
|
||
// creation needs privileges on Windows — skip just this vector if it throws.
|
||
let symlinked = true;
|
||
try { fs.symlinkSync(outsideDir, path.join(scanRoot, 'evil')); } catch { symlinked = false; }
|
||
|
||
const traversalEntry = path.relative(scanRoot, outsideDir); // e.g. "../gsd-666-scan-outside-XXXX"
|
||
const newlineEntry = 'good\nbad'; // record-separator injection attempt
|
||
const dirtyFile = path.join(scanRoot, '_dirty');
|
||
const entries = symlinked
|
||
? ['evil', traversalEntry, newlineEntry, 'backend']
|
||
: [traversalEntry, newlineEntry, 'backend'];
|
||
const subReposJson = JSON.stringify(entries);
|
||
|
||
try {
|
||
const scanResult = runNode(['-e', script, subReposJson, scanRoot, dirtyFile]);
|
||
throwIfFailed(scanResult, 'node -e <dirty-scan script from pr-branch.md>');
|
||
const dirty = fs.existsSync(dirtyFile) ? fs.readFileSync(dirtyFile, 'utf-8') : '';
|
||
const lines = dirty.split('\n').filter(Boolean);
|
||
assert.ok(
|
||
!dirty.includes(path.basename(outsideDir)),
|
||
`Path traversal reached git outside the workspace: ${JSON.stringify(dirty)}`
|
||
);
|
||
if (symlinked) {
|
||
assert.ok(
|
||
!lines.includes('evil'),
|
||
`Symlink entry reached git outside the workspace: ${JSON.stringify(dirty)}`
|
||
);
|
||
}
|
||
assert.ok(
|
||
!lines.includes('bad'),
|
||
`Embedded-newline entry injected a spurious record: ${JSON.stringify(dirty)}`
|
||
);
|
||
assert.deepStrictEqual(
|
||
lines, ['backend'],
|
||
`Positive control failed — expected only 'backend', got: ${JSON.stringify(lines)}`
|
||
);
|
||
} finally {
|
||
cleanup(scanRoot);
|
||
cleanup(outsideDir);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/feat-1754-cli-skew-detection.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:feat-1754-cli-skew-detection (consolidation epic #1969 B3 #1972)", () => {
|
||
'use strict';
|
||
|
||
/**
|
||
* feat-1754-cli-skew-detection.test.cjs
|
||
*
|
||
* Tests for the CLI version-skew detection module (src/cli-skew-check.cts).
|
||
*
|
||
* The check warns (returns a string) when the running gsd-tools.cjs is NOT the
|
||
* project-local install while a project-local install EXISTS — the shadowing
|
||
* scenario from #1748 (a stale global canary from @gsd-build/sdk shadowing
|
||
* project-local 1.6.0).
|
||
*
|
||
* DEFECT class: environment / version skew (enhancement #1754)
|
||
*
|
||
* The function is PURE (no I/O — the caller provides paths + existence flags),
|
||
* making it trivially testable without filesystem setup.
|
||
*/
|
||
|
||
const { test, describe } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const path = require('node:path');
|
||
|
||
const { checkCliSkew } = require('../gsd-core/bin/lib/cli-skew-check.cjs');
|
||
|
||
describe('#1754: checkCliSkew — pure path-comparison skew detection', () => {
|
||
test('SKEW: resolved CLI outside project root + project-local exists → returns warning', () => {
|
||
const warning = checkCliSkew({
|
||
resolvedPath: '/opt/homebrew/bin/gsd-tools',
|
||
projectRoot: '/home/user/my-project',
|
||
projectLocalExists: true,
|
||
});
|
||
assert.ok(warning, 'Expected a warning string when resolved CLI is outside project root and project-local exists');
|
||
assert.ok(warning.includes('shadow') || warning.includes('outside') || warning.includes('may'),
|
||
`Warning should mention the shadowing/outside nature, got: "${warning}"`);
|
||
});
|
||
|
||
test('NO-SKEW: resolved CLI is the project-local install → returns null', () => {
|
||
const warning = checkCliSkew({
|
||
resolvedPath: '/home/user/my-project/.claude/gsd-core/bin/gsd-tools.cjs',
|
||
projectRoot: '/home/user/my-project',
|
||
projectLocalExists: true,
|
||
});
|
||
assert.strictEqual(warning, null, 'No warning expected when resolved CLI IS the project-local install');
|
||
});
|
||
|
||
test('NO-SKEW: resolved CLI outside project root but NO project-local install → returns null', () => {
|
||
const warning = checkCliSkew({
|
||
resolvedPath: '/usr/local/bin/gsd-tools',
|
||
projectRoot: '/home/user/my-project',
|
||
projectLocalExists: false,
|
||
});
|
||
assert.strictEqual(warning, null, 'No warning expected when no project-local install exists (legitimate global-only)');
|
||
});
|
||
|
||
test('NO-SKEW: projectRoot is null (no project context) → returns null', () => {
|
||
const warning = checkCliSkew({
|
||
resolvedPath: '/usr/local/bin/gsd-tools',
|
||
projectRoot: null,
|
||
projectLocalExists: false,
|
||
});
|
||
assert.strictEqual(warning, null, 'No warning expected when there is no project root');
|
||
});
|
||
|
||
test('LEGACY-SDK: resolved path contains @gsd-build → warning includes removal instructions', () => {
|
||
const warning = checkCliSkew({
|
||
resolvedPath: '/opt/homebrew/lib/node_modules/@gsd-build/sdk/bin/gsd-tools',
|
||
projectRoot: '/home/user/my-project',
|
||
projectLocalExists: true,
|
||
});
|
||
assert.ok(warning, 'Expected a warning for @gsd-build/sdk paths');
|
||
assert.ok(warning.includes('@gsd-build/sdk') || warning.includes('npm uninstall'),
|
||
`Warning should include @gsd-build/sdk removal instructions, got: "${warning}"`);
|
||
});
|
||
|
||
test('PATH-NORMALIZATION: resolved under project root via realpath → no false positive', () => {
|
||
// Even if the resolved path differs in symlink resolution, if it's under the
|
||
// project root, it's not a skew. The caller normalizes paths before calling.
|
||
const warning = checkCliSkew({
|
||
resolvedPath: path.resolve('/home/user/my-project/.claude/gsd-core/bin/gsd-tools.cjs'),
|
||
projectRoot: path.resolve('/home/user/my-project'),
|
||
projectLocalExists: true,
|
||
});
|
||
assert.strictEqual(warning, null, 'No warning when resolved path is under project root (even with realpath normalization)');
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/feat-3251-command-aliases-manifest-coverage.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:feat-3251-command-aliases-manifest-coverage (consolidation epic #1969 B3 #1972)", () => {
|
||
'use strict';
|
||
/**
|
||
* Regression guard for issue #3251:
|
||
* 14 commands used in workflows must be present in command-aliases.cjs.
|
||
*
|
||
* Asserts structurally by requiring the manifest and checking each canonical
|
||
* command appears in either the family arrays or the non-family array.
|
||
* Never greps the source file — see feedback_no_source_grep_tests.md.
|
||
*/
|
||
|
||
const { describe, test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const crypto = require('node:crypto');
|
||
const fs = require('node:fs');
|
||
const os = require('node:os');
|
||
const path = require('path');
|
||
const { spawnSync } = require('node:child_process');
|
||
const { cleanup } = require('./helpers.cjs');
|
||
|
||
const REPO_ROOT = path.join(__dirname, '..');
|
||
const COMMAND_ALIASES_FILE = path.join(
|
||
REPO_ROOT,
|
||
'gsd-core',
|
||
'bin',
|
||
'lib',
|
||
'command-aliases.cjs',
|
||
);
|
||
const GSD_TOOLS = path.join(REPO_ROOT, 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||
|
||
const MISSING_14 = [
|
||
'check.decision-coverage-plan',
|
||
'check.decision-coverage-verify',
|
||
'frontmatter.get',
|
||
'frontmatter.set',
|
||
'learnings.copy',
|
||
'milestone.complete',
|
||
'phase.mvp-mode',
|
||
'progress.bar',
|
||
'requirements.mark-complete',
|
||
'stats.json',
|
||
'task.is-behavior-adding',
|
||
'todo.match-phase',
|
||
'uat.render-checkpoint',
|
||
'workstream.list',
|
||
];
|
||
|
||
describe('feat-3251: command-aliases.cjs manifest coverage', () => {
|
||
let manifest;
|
||
|
||
test('manifest file can be required without error', () => {
|
||
try {
|
||
manifest = require(COMMAND_ALIASES_FILE);
|
||
} catch (err) {
|
||
assert.fail(`Failed to require manifest: ${err.message}`);
|
||
}
|
||
assert.ok(manifest, 'manifest should be truthy');
|
||
});
|
||
|
||
test('manifest exports NON_FAMILY_COMMAND_ALIASES array', () => {
|
||
manifest = manifest ?? require(COMMAND_ALIASES_FILE);
|
||
assert.ok(
|
||
Array.isArray(manifest.NON_FAMILY_COMMAND_ALIASES),
|
||
'NON_FAMILY_COMMAND_ALIASES must be an exported array in command-aliases.cjs',
|
||
);
|
||
});
|
||
|
||
test('all 14 missing commands are present in the manifest (family or non-family)', () => {
|
||
manifest = manifest ?? require(COMMAND_ALIASES_FILE);
|
||
|
||
const allCanonicalsInManifest = new Set();
|
||
|
||
// Collect from all family arrays
|
||
const familyArrayKeys = [
|
||
'STATE_COMMAND_ALIASES',
|
||
'VERIFY_COMMAND_ALIASES',
|
||
'INIT_COMMAND_ALIASES',
|
||
'PHASE_COMMAND_ALIASES',
|
||
'PHASES_COMMAND_ALIASES',
|
||
'VALIDATE_COMMAND_ALIASES',
|
||
'ROADMAP_COMMAND_ALIASES',
|
||
'EVAL_COMMAND_ALIASES',
|
||
];
|
||
for (const key of familyArrayKeys) {
|
||
const arr = manifest[key];
|
||
if (!Array.isArray(arr)) continue;
|
||
for (const entry of arr) {
|
||
if (entry && entry.canonical) allCanonicalsInManifest.add(entry.canonical);
|
||
}
|
||
}
|
||
|
||
// Collect from non-family array
|
||
const nonFamily = manifest.NON_FAMILY_COMMAND_ALIASES;
|
||
if (Array.isArray(nonFamily)) {
|
||
for (const entry of nonFamily) {
|
||
if (entry && entry.canonical) allCanonicalsInManifest.add(entry.canonical);
|
||
}
|
||
}
|
||
|
||
const missing = MISSING_14.filter((cmd) => !allCanonicalsInManifest.has(cmd));
|
||
assert.deepStrictEqual(
|
||
missing,
|
||
[],
|
||
`${missing.length} command(s) still missing from manifest: ${missing.join(', ')}`,
|
||
);
|
||
});
|
||
|
||
test('each non-family entry has required fields: canonical, aliases, mutation', () => {
|
||
manifest = manifest ?? require(COMMAND_ALIASES_FILE);
|
||
const nonFamily = manifest.NON_FAMILY_COMMAND_ALIASES;
|
||
if (!Array.isArray(nonFamily)) return; // caught by earlier test
|
||
|
||
for (const entry of nonFamily) {
|
||
assert.ok(typeof entry.canonical === 'string' && entry.canonical.length > 0,
|
||
`entry missing canonical: ${JSON.stringify(entry)}`);
|
||
assert.ok(Array.isArray(entry.aliases),
|
||
`entry missing aliases array for canonical=${entry.canonical}`);
|
||
assert.ok(typeof entry.mutation === 'boolean',
|
||
`entry missing mutation boolean for canonical=${entry.canonical}`);
|
||
}
|
||
});
|
||
|
||
test('NON_FAMILY_COMMAND_ALIASES is sorted by canonical (deterministic output)', () => {
|
||
manifest = manifest ?? require(COMMAND_ALIASES_FILE);
|
||
const nonFamily = manifest.NON_FAMILY_COMMAND_ALIASES;
|
||
if (!Array.isArray(nonFamily)) return; // caught by earlier test
|
||
|
||
const canonicals = nonFamily.map((e) => e.canonical);
|
||
const sorted = [...canonicals].sort((a, b) => a.localeCompare(b));
|
||
assert.deepStrictEqual(
|
||
canonicals,
|
||
sorted,
|
||
'NON_FAMILY_COMMAND_ALIASES must be sorted by canonical for deterministic regeneration',
|
||
);
|
||
});
|
||
});
|
||
|
||
function createProject() {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3251-dispatch-'));
|
||
fs.mkdirSync(path.join(dir, '.planning', 'phases'), { recursive: true });
|
||
return dir;
|
||
}
|
||
|
||
function runGsdTools(args, projectDir) {
|
||
return spawnSync(process.execPath, [GSD_TOOLS, ...args], {
|
||
cwd: projectDir,
|
||
encoding: 'utf8',
|
||
timeout: 30000,
|
||
killSignal: 'SIGKILL',
|
||
});
|
||
}
|
||
|
||
function snapshotProjectState(projectDir) {
|
||
const files = [];
|
||
function walk(dir) {
|
||
if (!fs.existsSync(dir)) return;
|
||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
const full = path.join(dir, entry.name);
|
||
const rel = path.relative(projectDir, full);
|
||
if (entry.isDirectory()) walk(full);
|
||
else {
|
||
files.push({
|
||
path: rel,
|
||
sha256: crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex'),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
walk(projectDir);
|
||
return files.sort((a, b) => a.path.localeCompare(b.path));
|
||
}
|
||
|
||
describe('feat-3251: generated aliases dispatch through real gsd-tools behavior', () => {
|
||
test('phase.mvp-mode spaced alias resolves CLI flag precedence', () => {
|
||
const projectDir = createProject();
|
||
try {
|
||
const result = runGsdTools(['phase', 'mvp-mode', '1', '--cli-flag'], projectDir);
|
||
assert.equal(result.status, 0, result.stderr);
|
||
|
||
const output = JSON.parse(result.stdout);
|
||
assert.deepEqual(output, {
|
||
active: true,
|
||
source: 'cli_flag',
|
||
roadmap_mode: null,
|
||
config_mvp_mode: false,
|
||
cli_flag_present: true,
|
||
});
|
||
} finally {
|
||
cleanup(projectDir);
|
||
}
|
||
});
|
||
|
||
test('phase.mvp-mode spaced alias resolves ROADMAP mode without mutating files', () => {
|
||
const projectDir = createProject();
|
||
try {
|
||
fs.writeFileSync(
|
||
path.join(projectDir, '.planning', 'ROADMAP.md'),
|
||
[
|
||
'# Roadmap',
|
||
'',
|
||
'## v1.0.0',
|
||
'',
|
||
'### Phase 1: User Auth',
|
||
'**Goal:** Users can sign in.',
|
||
'**Mode:** mvp',
|
||
'',
|
||
].join('\n'),
|
||
);
|
||
const beforeFiles = snapshotProjectState(projectDir);
|
||
|
||
const result = runGsdTools(['phase', 'mvp-mode', '1'], projectDir);
|
||
assert.equal(result.status, 0, result.stderr);
|
||
|
||
const output = JSON.parse(result.stdout);
|
||
assert.equal(output.active, true);
|
||
assert.equal(output.source, 'roadmap');
|
||
assert.equal(output.roadmap_mode, 'mvp');
|
||
assert.equal(output.config_mvp_mode, false);
|
||
assert.equal(output.cli_flag_present, false);
|
||
assert.deepEqual(snapshotProjectState(projectDir), beforeFiles);
|
||
} finally {
|
||
cleanup(projectDir);
|
||
}
|
||
});
|
||
|
||
test('phase.mvp-mode ROADMAP lookup stops before custom-id next phase', () => {
|
||
const projectDir = createProject();
|
||
try {
|
||
fs.writeFileSync(
|
||
path.join(projectDir, '.planning', 'ROADMAP.md'),
|
||
[
|
||
'# Roadmap',
|
||
'',
|
||
'## v1.0.0',
|
||
'',
|
||
'### Phase 1: Numeric Phase',
|
||
'**Goal:** Users can sign in.',
|
||
'',
|
||
'### Phase custom-alpha: Custom Phase',
|
||
'**Goal:** Custom work.',
|
||
'**Mode:** mvp',
|
||
'',
|
||
].join('\n'),
|
||
);
|
||
const beforeFiles = snapshotProjectState(projectDir);
|
||
|
||
const result = runGsdTools(['phase', 'mvp-mode', '1'], projectDir);
|
||
assert.equal(result.status, 0, result.stderr);
|
||
|
||
const output = JSON.parse(result.stdout);
|
||
assert.equal(output.active, false);
|
||
assert.equal(output.source, 'none');
|
||
assert.equal(output.roadmap_mode, null);
|
||
assert.deepEqual(snapshotProjectState(projectDir), beforeFiles);
|
||
} finally {
|
||
cleanup(projectDir);
|
||
}
|
||
});
|
||
|
||
test('phase.mvp-mode JSON error is typed and leaves project files untouched', () => {
|
||
const projectDir = createProject();
|
||
try {
|
||
const beforeFiles = snapshotProjectState(projectDir);
|
||
const result = runGsdTools(['--json-errors', 'phase', 'mvp-mode'], projectDir);
|
||
assert.notEqual(result.status, 0);
|
||
assert.equal(result.stdout, '');
|
||
|
||
const error = JSON.parse(result.stderr);
|
||
assert.deepEqual(Object.keys(error).sort(), ['message', 'ok', 'reason']);
|
||
assert.equal(error.ok, false);
|
||
assert.equal(error.reason, 'usage');
|
||
assert.equal(typeof error.message, 'string');
|
||
assert.equal(/\n\s*at\s/.test(result.stderr), false, 'non-debug failure must not print a stack trace');
|
||
assert.deepEqual(snapshotProjectState(projectDir), beforeFiles);
|
||
} finally {
|
||
cleanup(projectDir);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/feat-488-effort-sync.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:feat-488-effort-sync (consolidation epic #1969 B3 #1972)", () => {
|
||
// Tests for gsd-tools effort sync command (#488)
|
||
// Verifies that effort frontmatter in installed agent files can be re-synced
|
||
// when effort config changes after initial install.
|
||
// allow-test-rule: structural-regression-guard — readFileSync asserts on installed agent .md files (the product under mutation) to verify dry-run safety and apply correctness; stderr.includes guards the CLI argument-rejection contract. (see #488)
|
||
|
||
'use strict';
|
||
|
||
process.env.GSD_TEST_MODE = '1';
|
||
|
||
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, captureFdSync } = require('./helpers.cjs');
|
||
const { runNode } = require('./helpers/process-seam.cjs');
|
||
const { toLegacyResult } = require('./helpers/git-fixture.cjs');
|
||
|
||
const GSD_TOOLS = path.resolve(__dirname, '../gsd-core/bin/gsd-tools.cjs');
|
||
|
||
// This is a DISTINCT, independently-scoped `runCli` — not the file's other
|
||
// local helper of a similar shape (`runGsdTools` above, folded from
|
||
// feat-3251, which already carries its own `timeout: 30000`). This one
|
||
// returns the legacy `{status, stdout, stderr}` shape its callers below read
|
||
// directly (never a throw contract), so it is bounded via `runNode` +
|
||
// `toLegacyResult` rather than `gitOrThrow`/`throwIfFailed`.
|
||
function runCli(args, env = {}) {
|
||
const result = runNode([GSD_TOOLS, ...args], {
|
||
env: { ...process.env, GSD_TEST_MODE: '1', ...env },
|
||
});
|
||
return toLegacyResult(result);
|
||
}
|
||
|
||
function makeTmpDir(prefix) {
|
||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||
}
|
||
|
||
// output() in core.cjs uses fs.writeSync(1, data) — intercept fd=1 writes.
|
||
// Pass raw=false so output() emits JSON (raw=true emits the plain rawValue string).
|
||
function captureOutput(fn) {
|
||
return JSON.parse(captureFdSync(1, fn));
|
||
}
|
||
|
||
function makeAgentsDir(tmpDir) {
|
||
const agentsDir = path.join(tmpDir, 'agents');
|
||
fs.mkdirSync(agentsDir, { recursive: true });
|
||
return agentsDir;
|
||
}
|
||
|
||
function writePlanningConfig(tmpDir, effortConfig) {
|
||
const planningDir = path.join(tmpDir, '.planning');
|
||
fs.mkdirSync(planningDir, { recursive: true });
|
||
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ effort: effortConfig }));
|
||
}
|
||
|
||
const AGENT_WITH_EFFORT = `---
|
||
name: gsd-planner
|
||
description: Plans phases for GSD milestones
|
||
effort: medium
|
||
---
|
||
Body of the agent.
|
||
`;
|
||
|
||
const AGENT_WITHOUT_EFFORT = `---
|
||
name: gsd-executor
|
||
description: Executes GSD phase plans
|
||
---
|
||
Body of the agent.
|
||
`;
|
||
|
||
describe('#3533 effort sync: inherit means the key must not exist', () => {
|
||
test('10d: sync does not re-add a hand-stripped key when inherit is configured', () => {
|
||
const tmpDir = makeTmpDir('effort-sync-inherit-absent-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), AGENT_WITHOUT_EFFORT);
|
||
// Tier standard -> inherit.
|
||
writePlanningConfig(tmpDir, { routing_tier_defaults: { light: 'high', standard: 'inherit', heavy: 'xhigh' } });
|
||
|
||
const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs');
|
||
const result = captureOutput(() =>
|
||
cmdEffortSync(tmpDir, false, { dryRun: false, configDir: tmpDir, runtime: 'claude' })
|
||
);
|
||
|
||
assert.equal(result.synced, 0, `absent key + inherit is IN SYNC, not drift: ${JSON.stringify(result.changes)}`);
|
||
assert.equal(result.changes.length, 0, 'no change may be reported for an absent key under inherit');
|
||
const after = fs.readFileSync(path.join(agentsDir, 'gsd-executor.md'), 'utf8');
|
||
assert.ok(!/^effort:/m.test(after), 'the effort: key must NOT be re-added');
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('10d: sync strips the key when inherit is configured and a value is present', () => {
|
||
const tmpDir = makeTmpDir('effort-sync-inherit-strip-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
// Fixture carries its own name so the survivor assertion below is
|
||
// satisfiable (AGENT_WITH_EFFORT names gsd-planner — wrong file).
|
||
fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), AGENT_WITH_EFFORT.replace('name: gsd-planner', 'name: gsd-executor'));
|
||
// #3531+#3533 combined: pin every TIER to inherit — a bare effort.default
|
||
// no longer reaches a tiered agent now that the config block merges over
|
||
// the built-in tier ladder (the manifest standard tier would answer 'high').
|
||
writePlanningConfig(tmpDir, { routing_tier_defaults: { light: 'inherit', standard: 'inherit', heavy: 'inherit' } });
|
||
|
||
const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs');
|
||
const result = captureOutput(() =>
|
||
cmdEffortSync(tmpDir, false, { dryRun: false, configDir: tmpDir, runtime: 'claude' })
|
||
);
|
||
|
||
assert.equal(result.synced, 1);
|
||
assert.equal(result.changes[0].agent, 'gsd-executor');
|
||
assert.equal(result.changes[0].from, 'medium');
|
||
assert.equal(result.changes[0].to, null, 'to: null is the typed IR for omission');
|
||
const after = fs.readFileSync(path.join(agentsDir, 'gsd-executor.md'), 'utf8');
|
||
assert.ok(!/^effort:/m.test(after), 'the effort: line must be stripped');
|
||
assert.ok(after.includes('name: gsd-executor'), 'every other frontmatter line survives');
|
||
assert.ok(after.includes('Body of the agent.'), 'the body survives');
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('10d: strip preserves CRLF files and leaves comments and sibling keys intact', () => {
|
||
const tmpDir = makeTmpDir('effort-sync-inherit-crlf-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
const crlfAgent = [
|
||
'---',
|
||
'name: gsd-executor',
|
||
'# a hand comment that must survive',
|
||
'effort: high',
|
||
'description: Executes GSD phase plans',
|
||
'---',
|
||
'Body.',
|
||
'',
|
||
].join('\r\n');
|
||
const agentPath = path.join(agentsDir, 'gsd-executor.md');
|
||
fs.writeFileSync(agentPath, crlfAgent);
|
||
writePlanningConfig(tmpDir, { agent_overrides: { 'gsd-executor': 'inherit' } });
|
||
|
||
const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs');
|
||
const result = captureOutput(() =>
|
||
cmdEffortSync(tmpDir, false, { dryRun: false, configDir: tmpDir, runtime: 'claude' })
|
||
);
|
||
|
||
assert.equal(result.synced, 1, `expected one strip: ${JSON.stringify(result.changes)}`);
|
||
const after = fs.readFileSync(agentPath, 'utf8');
|
||
assert.ok(!/^effort:/m.test(after), 'effort line gone');
|
||
assert.ok(after.includes('\r\n'), 'CRLF endings preserved');
|
||
assert.ok(after.includes('# a hand comment that must survive'), 'comment preserved');
|
||
assert.ok(/^description: Executes GSD phase plans\r?$/m.test(after), 'sibling key preserved');
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
});
|
||
|
||
describe('feat-488: effort sync command', () => {
|
||
test('dry-run mode reports pending changes without writing files', () => {
|
||
const tmpDir = makeTmpDir('effort-sync-dry-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
const agentPath = path.join(agentsDir, 'gsd-planner.md');
|
||
fs.writeFileSync(agentPath, AGENT_WITH_EFFORT);
|
||
writePlanningConfig(tmpDir, { default: 'high', agent_overrides: { 'gsd-planner': 'xhigh' } });
|
||
|
||
const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs');
|
||
const result = captureOutput(() =>
|
||
cmdEffortSync(tmpDir, false, { dryRun: true, configDir: tmpDir, runtime: 'claude' })
|
||
);
|
||
|
||
assert.equal(result.dry_run, true);
|
||
assert.equal(result.synced, 1, 'should report 1 pending change');
|
||
assert.equal(result.changes[0].agent, 'gsd-planner');
|
||
assert.equal(result.changes[0].from, 'medium');
|
||
assert.equal(result.changes[0].to, 'xhigh');
|
||
|
||
// dry-run must not modify the file
|
||
assert.ok(fs.readFileSync(agentPath, 'utf8').includes('effort: medium'), 'dry-run must not write file');
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('--apply mode rewrites effort: frontmatter to new config value', () => {
|
||
const tmpDir = makeTmpDir('effort-sync-apply-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
const agentPath = path.join(agentsDir, 'gsd-planner.md');
|
||
fs.writeFileSync(agentPath, AGENT_WITH_EFFORT);
|
||
writePlanningConfig(tmpDir, { default: 'low', agent_overrides: { 'gsd-planner': 'xhigh' } });
|
||
|
||
const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs');
|
||
const result = captureOutput(() =>
|
||
cmdEffortSync(tmpDir, false, { dryRun: false, configDir: tmpDir, runtime: 'claude' })
|
||
);
|
||
|
||
assert.equal(result.dry_run, false);
|
||
assert.equal(result.synced, 1);
|
||
|
||
const updated = fs.readFileSync(agentPath, 'utf8');
|
||
assert.ok(updated.includes('effort: xhigh'), 'file must be updated to xhigh');
|
||
assert.ok(!updated.includes('effort: medium'), 'old effort value must be gone');
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('skips agents where effort: already matches config', () => {
|
||
const tmpDir = makeTmpDir('effort-sync-noop-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
const agentPath = path.join(agentsDir, 'gsd-planner.md');
|
||
// Already has the correct value
|
||
fs.writeFileSync(agentPath, AGENT_WITH_EFFORT.replace('effort: medium', 'effort: xhigh'));
|
||
writePlanningConfig(tmpDir, { agent_overrides: { 'gsd-planner': 'xhigh' } });
|
||
|
||
const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs');
|
||
const result = captureOutput(() =>
|
||
cmdEffortSync(tmpDir, false, { dryRun: false, configDir: tmpDir, runtime: 'claude' })
|
||
);
|
||
|
||
assert.equal(result.synced, 0, 'nothing to sync when already matching');
|
||
assert.equal(result.skipped, 1);
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('injects effort: into agent files that lack the frontmatter key', () => {
|
||
const tmpDir = makeTmpDir('effort-sync-inject-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
const agentPath = path.join(agentsDir, 'gsd-executor.md');
|
||
fs.writeFileSync(agentPath, AGENT_WITHOUT_EFFORT);
|
||
// #3531: pin every tier so the injected value is tier-independent — an
|
||
// effort.default alone no longer answers for a tiered agent now that the
|
||
// config block merges over the built-in tier ladder.
|
||
writePlanningConfig(tmpDir, { routing_tier_defaults: { light: 'max', standard: 'max', heavy: 'max' }, default: 'max' });
|
||
|
||
const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs');
|
||
const result = captureOutput(() =>
|
||
cmdEffortSync(tmpDir, false, { dryRun: false, configDir: tmpDir, runtime: 'claude' })
|
||
);
|
||
|
||
assert.equal(result.synced, 1, 'should inject effort into agent missing the key');
|
||
assert.equal(result.changes[0].from, null);
|
||
assert.equal(result.changes[0].to, 'max');
|
||
assert.ok(fs.readFileSync(agentPath, 'utf8').includes('effort: max'), 'effort must be injected');
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('non-claude runtime exits cleanly with informative reason field', () => {
|
||
const tmpDir = makeTmpDir('effort-sync-gemini-');
|
||
|
||
const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs');
|
||
const result = captureOutput(() =>
|
||
cmdEffortSync(tmpDir, false, { dryRun: true, runtime: 'gemini' })
|
||
);
|
||
|
||
assert.ok(result.reason, 'should include a reason message for unsupported runtime');
|
||
assert.equal(result.synced, 0);
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('home-default effort config gap: applies home-level effort when project config has no effort section', () => {
|
||
// The key #488 scenario: user changed ~/.gsd/defaults.json effort settings
|
||
// after install, but the project .planning/config.json has no effort section.
|
||
// cmdEffortSync must pick up the home config (via readGsdEffectiveEffortConfig),
|
||
// not fall back to 'high' (which loadConfig would return).
|
||
//
|
||
// readGsdEffectiveEffortConfig calls os.homedir() directly, and os.homedir()
|
||
// is live (respects process.env.HOME). We redirect HOME to an isolated
|
||
// tmpHome so the test is hermetic and can assert the real outcome.
|
||
const tmpHome = makeTmpDir('effort-sync-homecfg-');
|
||
const tmpDir = makeTmpDir('effort-sync-project-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
const agentPath = path.join(agentsDir, 'gsd-planner.md');
|
||
fs.writeFileSync(agentPath, AGENT_WITH_EFFORT); // current: effort: medium
|
||
|
||
// Project has .planning/config.json with NO effort section
|
||
const planningDir = path.join(tmpDir, '.planning');
|
||
fs.mkdirSync(planningDir, { recursive: true });
|
||
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ model_profile: 'balanced' }));
|
||
|
||
// Home defaults set the heavy tier effort to low. (#3531: a bare home
|
||
// effort.default would no longer reach gsd-planner — the merged tier
|
||
// ladder answers for tiered agents — so the home fixture pins the tier,
|
||
// which is what this test's claim actually exercises: home-level effort
|
||
// applies when the project config has no effort section.)
|
||
const gsdDir = path.join(tmpHome, '.gsd');
|
||
fs.mkdirSync(gsdDir, { recursive: true });
|
||
fs.writeFileSync(path.join(gsdDir, 'defaults.json'), JSON.stringify({ effort: { routing_tier_defaults: { heavy: 'low' } } }));
|
||
|
||
// Isolate HOME (and USERPROFILE for Windows parity) so
|
||
// readGsdEffectiveEffortConfig reads our fixture, not the
|
||
// developer's real ~/.gsd/defaults.json.
|
||
const origHome = process.env.HOME;
|
||
const origUserProfile = process.env.USERPROFILE;
|
||
process.env.HOME = tmpHome;
|
||
process.env.USERPROFILE = tmpHome;
|
||
|
||
const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs');
|
||
let result;
|
||
try {
|
||
result = captureOutput(() =>
|
||
cmdEffortSync(tmpDir, false, { dryRun: false, configDir: tmpDir, runtime: 'claude' })
|
||
);
|
||
} finally {
|
||
if (origHome === undefined) {
|
||
delete process.env.HOME;
|
||
} else {
|
||
process.env.HOME = origHome;
|
||
}
|
||
if (origUserProfile === undefined) {
|
||
delete process.env.USERPROFILE;
|
||
} else {
|
||
process.env.USERPROFILE = origUserProfile;
|
||
}
|
||
}
|
||
|
||
// With home heavy-tier effort 'low' and the agent currently at 'medium',
|
||
// cmdEffortSync must sync exactly 1 agent and set it to 'low'.
|
||
assert.equal(result.synced, 1, 'should sync 1 agent whose effort differs from home default');
|
||
assert.equal(result.changes[0].agent, 'gsd-planner');
|
||
assert.equal(result.changes[0].from, 'medium');
|
||
assert.equal(result.changes[0].to, 'low', 'effort must be updated to the home-default value');
|
||
assert.ok(
|
||
fs.readFileSync(agentPath, 'utf8').includes('effort: low'),
|
||
'agent file must be rewritten with the home-default effort value'
|
||
);
|
||
|
||
cleanup(tmpHome);
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('CLI dispatcher: positional args after effort sync are rejected', () => {
|
||
const result = runCli(['effort', 'sync', 'unexpected-arg']);
|
||
assert.notEqual(result.status, 0, 'should exit non-zero on unexpected positional arg');
|
||
assert.ok(
|
||
result.stderr.includes('positional') || result.stderr.includes('unexpected-arg'),
|
||
`stderr should mention the bad arg; got: ${result.stderr}`
|
||
);
|
||
});
|
||
|
||
test('CLI dispatcher: effort sync --apply routes through gsd-tools correctly', () => {
|
||
const tmpDir = makeTmpDir('effort-sync-cli-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
const agentPath = path.join(agentsDir, 'gsd-planner.md');
|
||
fs.writeFileSync(agentPath, AGENT_WITH_EFFORT);
|
||
writePlanningConfig(tmpDir, { agent_overrides: { 'gsd-planner': 'xhigh' } });
|
||
|
||
const result = runCli(
|
||
['--cwd', tmpDir, 'effort', 'sync', '--apply', '--config-dir', tmpDir],
|
||
);
|
||
|
||
assert.equal(result.status, 0, `CLI exited non-zero: ${result.stderr}`);
|
||
// gsd-tools may print a startup banner before the JSON payload — parse from the first `{`.
|
||
const jsonStart = result.stdout.indexOf('{');
|
||
const output = JSON.parse(result.stdout.slice(jsonStart));
|
||
assert.equal(output.synced, 1);
|
||
assert.ok(
|
||
fs.readFileSync(agentPath, 'utf8').includes('effort: xhigh'),
|
||
'CLI --apply must write the updated effort value'
|
||
);
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
});
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// #3243 (ADR-2313 D7) — the Codex `.toml` branch of `cmdEffortSync`.
|
||
// Spec: .gsd/phase/feat-3243-codex-toml-sync/{40-design,50-test-matrix}.md
|
||
// Row numbers below (# B<N>) map 1:1 to 50-test-matrix.md's "B — the sync"
|
||
// table. B1/B2 (the claude/opencode rows) are the EXISTING tests directly
|
||
// above this block ('feat-488: effort sync command' + the non-claude-runtime
|
||
// test) and are asserted UNCHANGED — this describe adds only new coverage.
|
||
//
|
||
// Every `.toml` fixture below is hand-authored against the real
|
||
// `generateCodexAgentToml` shape (CONTRIBUTING's fixture-provenance rule,
|
||
// #2371), not produced by calling that writer.
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
describe('#3243 (ADR-2313 D7): Codex .toml effort sync', () => {
|
||
const { PARSE_REASON } = require('../gsd-core/bin/lib/codex-agent-toml.cjs');
|
||
const FIXTURES_DIR = path.join(__dirname, 'fixtures', 'adversarial', 'toml');
|
||
|
||
function writeCodexAgentToml(agentsDir, agentName, content) {
|
||
fs.mkdirSync(agentsDir, { recursive: true });
|
||
fs.writeFileSync(path.join(agentsDir, `${agentName}.toml`), content);
|
||
}
|
||
|
||
function syncCodex(tmpDir, dryRun) {
|
||
const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs');
|
||
return captureOutput(() =>
|
||
cmdEffortSync(tmpDir, false, { dryRun, configDir: tmpDir, runtime: 'codex' })
|
||
);
|
||
}
|
||
|
||
test('B3: model = "sonnet" — the model line stripped; one structured change', () => {
|
||
const tmpDir = makeTmpDir('codex-sync-b3-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
writeCodexAgentToml(
|
||
agentsDir,
|
||
'gsd-planner',
|
||
'name = "gsd-planner"\nmodel = "sonnet"\ndeveloper_instructions = \'\'\'\nPlan.\n\'\'\'\n',
|
||
);
|
||
|
||
const result = syncCodex(tmpDir, false);
|
||
|
||
assert.equal(result.synced, 1);
|
||
assert.equal(result.skipped, 0);
|
||
assert.equal(result.changes.length, 1);
|
||
assert.equal(result.changes[0].agent, 'gsd-planner');
|
||
assert.equal(result.changes[0].field, 'model');
|
||
assert.equal(result.changes[0].from, 'sonnet');
|
||
assert.equal(result.changes[0].to, null);
|
||
const updated = fs.readFileSync(path.join(agentsDir, 'gsd-planner.toml'), 'utf8');
|
||
assert.ok(!updated.includes('model = "sonnet"'), 'the stale model line must be gone');
|
||
assert.ok(updated.includes('name = "gsd-planner"'), 'other lines must survive');
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('B4 (negative proof): model = "gpt-5.6-sol" (legal pin) — untouched, reported skipped not synced', () => {
|
||
const tmpDir = makeTmpDir('codex-sync-b4-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
const content = 'name = "gsd-gpt-agent"\nmodel = "gpt-5.6-sol"\ndeveloper_instructions = \'\'\'\nWork.\n\'\'\'\n';
|
||
writeCodexAgentToml(agentsDir, 'gsd-gpt-agent', content);
|
||
|
||
const result = syncCodex(tmpDir, false);
|
||
|
||
assert.equal(result.synced, 0, 'a legal pin must never be reported synced');
|
||
assert.equal(result.skipped, 1);
|
||
assert.equal(result.changes.length, 0);
|
||
assert.equal(fs.readFileSync(path.join(agentsDir, 'gsd-gpt-agent.toml'), 'utf8'), content);
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('B5 (negative proof): legal pin plus its coupled effort — both untouched', () => {
|
||
const tmpDir = makeTmpDir('codex-sync-b5-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
const content = 'name = "gsd-pinned-agent"\nmodel = "gpt-5-codex"\nmodel_reasoning_effort = "high"\n' +
|
||
"developer_instructions = '''\nWork.\n'''\n";
|
||
writeCodexAgentToml(agentsDir, 'gsd-pinned-agent', content);
|
||
|
||
const result = syncCodex(tmpDir, false);
|
||
|
||
assert.equal(result.synced, 0);
|
||
assert.equal(result.skipped, 1);
|
||
assert.equal(fs.readFileSync(path.join(agentsDir, 'gsd-pinned-agent.toml'), 'utf8'), content);
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('B6: orphaned model_reasoning_effort, no model — the effort stripped', () => {
|
||
const tmpDir = makeTmpDir('codex-sync-b6-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
writeCodexAgentToml(
|
||
agentsDir,
|
||
'gsd-orphan-agent',
|
||
'name = "gsd-orphan-agent"\nmodel_reasoning_effort = "high"\ndeveloper_instructions = \'\'\'\nWork.\n\'\'\'\n',
|
||
);
|
||
|
||
const result = syncCodex(tmpDir, false);
|
||
|
||
assert.equal(result.synced, 1);
|
||
assert.equal(result.changes.length, 1);
|
||
assert.equal(result.changes[0].field, 'model_reasoning_effort');
|
||
assert.equal(result.changes[0].from, 'high');
|
||
const updated = fs.readFileSync(path.join(agentsDir, 'gsd-orphan-agent.toml'), 'utf8');
|
||
assert.ok(!updated.includes('model_reasoning_effort'), 'the orphaned effort must be gone');
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('B7: stale model plus its effort — both stripped', () => {
|
||
const tmpDir = makeTmpDir('codex-sync-b7-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
writeCodexAgentToml(
|
||
agentsDir,
|
||
'gsd-stale-agent',
|
||
'name = "gsd-stale-agent"\nmodel = "opus"\nmodel_reasoning_effort = "medium"\n' +
|
||
"developer_instructions = '''\nWork.\n'''\n",
|
||
);
|
||
|
||
const result = syncCodex(tmpDir, false);
|
||
|
||
assert.equal(result.synced, 1);
|
||
assert.equal(result.changes.length, 2, 'both the model and the coupled effort must be reported');
|
||
const fields = result.changes.map(c => c.field).sort();
|
||
assert.deepEqual(fields, ['model', 'model_reasoning_effort']);
|
||
const updated = fs.readFileSync(path.join(agentsDir, 'gsd-stale-agent.toml'), 'utf8');
|
||
assert.ok(!updated.includes('model = "opus"'));
|
||
assert.ok(!updated.includes('model_reasoning_effort'));
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('B8: posture-clean .toml — synced:0, no write (mtime unchanged)', () => {
|
||
const tmpDir = makeTmpDir('codex-sync-b8-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
writeCodexAgentToml(
|
||
agentsDir,
|
||
'gsd-clean',
|
||
'name = "gsd-clean"\ndeveloper_instructions = \'\'\'\nWork.\n\'\'\'\n',
|
||
);
|
||
const filePath = path.join(agentsDir, 'gsd-clean.toml');
|
||
const mtimeBefore = fs.statSync(filePath).mtimeMs;
|
||
|
||
const result = syncCodex(tmpDir, false);
|
||
|
||
assert.equal(result.synced, 0);
|
||
assert.equal(fs.statSync(filePath).mtimeMs, mtimeBefore, 'a posture-clean file must never be written');
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('B9 (boundary): dry-run is the default — changes reported, file byte-identical after', () => {
|
||
const tmpDir = makeTmpDir('codex-sync-b9-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
const content = 'name = "gsd-planner"\nmodel = "sonnet"\ndeveloper_instructions = \'\'\'\nPlan.\n\'\'\'\n';
|
||
writeCodexAgentToml(agentsDir, 'gsd-planner', content);
|
||
|
||
const { cmdEffortSync } = require('../gsd-core/bin/lib/commands.cjs');
|
||
const result = captureOutput(() =>
|
||
cmdEffortSync(tmpDir, false, { configDir: tmpDir, runtime: 'codex' }) // no dryRun key — must default true
|
||
);
|
||
|
||
assert.equal(result.dry_run, true);
|
||
assert.equal(result.synced, 1, 'the pending strip must still be reported');
|
||
assert.equal(fs.readFileSync(path.join(agentsDir, 'gsd-planner.toml'), 'utf8'), content, 'dry-run must not write');
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('B10: --no-dry-run writes; reports identically to the dry run', () => {
|
||
const content = 'name = "gsd-planner"\nmodel = "sonnet"\ndeveloper_instructions = \'\'\'\nPlan.\n\'\'\'\n';
|
||
|
||
const dryTmpDir = makeTmpDir('codex-sync-b10-dry-');
|
||
writeCodexAgentToml(makeAgentsDir(dryTmpDir), 'gsd-planner', content);
|
||
const dryResult = syncCodex(dryTmpDir, true);
|
||
|
||
const applyTmpDir = makeTmpDir('codex-sync-b10-apply-');
|
||
const applyAgentsDir = makeAgentsDir(applyTmpDir);
|
||
writeCodexAgentToml(applyAgentsDir, 'gsd-planner', content);
|
||
const applyResult = syncCodex(applyTmpDir, false);
|
||
|
||
assert.equal(dryResult.synced, applyResult.synced);
|
||
assert.deepEqual(dryResult.changes, applyResult.changes, 'the report must match the dry run exactly');
|
||
assert.equal(dryResult.dry_run, true);
|
||
assert.equal(applyResult.dry_run, false);
|
||
assert.ok(!fs.readFileSync(path.join(applyAgentsDir, 'gsd-planner.toml'), 'utf8').includes('model = "sonnet"'));
|
||
|
||
cleanup(dryTmpDir);
|
||
cleanup(applyTmpDir);
|
||
});
|
||
|
||
test('B11 (negative proof): unterminated block — skipped and reported, file byte-identical after', () => {
|
||
const tmpDir = makeTmpDir('codex-sync-b11-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
const content = 'name = "gsd-broken"\nmodel = "sonnet"\ndeveloper_instructions = \'\'\'\nThis block never closes.\n';
|
||
writeCodexAgentToml(agentsDir, 'gsd-broken', content);
|
||
|
||
const result = syncCodex(tmpDir, false);
|
||
|
||
assert.equal(result.synced, 0, 'an unparseable document must never be synced');
|
||
assert.equal(result.skipped, 1);
|
||
assert.equal(result.refused.length, 1);
|
||
assert.equal(result.refused[0].agent, 'gsd-broken');
|
||
assert.equal(result.refused[0].reason, PARSE_REASON.UNTERMINATED_BLOCK);
|
||
assert.equal(
|
||
fs.readFileSync(path.join(agentsDir, 'gsd-broken.toml'), 'utf8'),
|
||
content,
|
||
'a refused file must never be partially rewritten',
|
||
);
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('B12 (negative proof): symlinked .toml — skipped, target byte-identical after', (t) => {
|
||
const tmpDir = makeTmpDir('codex-sync-b12-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
const targetPath = path.join(tmpDir, 'outside-target.toml');
|
||
const targetContent = 'model = "sonnet"\n';
|
||
fs.writeFileSync(targetPath, targetContent);
|
||
const symlinkPath = path.join(agentsDir, 'gsd-linked.toml');
|
||
try {
|
||
fs.symlinkSync(targetPath, symlinkPath, 'file');
|
||
} catch (error) {
|
||
if (error && ['EPERM', 'EACCES', 'ENOTSUP'].includes(error.code)) {
|
||
t.skip('symlink creation is not available on this platform');
|
||
cleanup(tmpDir);
|
||
return;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
const result = syncCodex(tmpDir, false);
|
||
|
||
assert.equal(result.synced, 0);
|
||
assert.ok(
|
||
!result.changes.some(c => c.agent === 'gsd-linked'),
|
||
'a symlinked agent must never be reported as synced',
|
||
);
|
||
assert.equal(fs.readFileSync(targetPath, 'utf8'), targetContent, 'the symlink target must never be written through');
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('B13: agents dir absent — reports not-found, as the claude path does', () => {
|
||
const tmpDir = makeTmpDir('codex-sync-b13-');
|
||
// agentsDir intentionally not created
|
||
|
||
const result = syncCodex(tmpDir, false);
|
||
|
||
assert.equal(result.synced, 0);
|
||
assert.equal(result.reason, 'agents directory not found');
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('B14 (hostile, headline data-loss case): model = inside developer_instructions — file byte-identical after a non-dry-run sync', () => {
|
||
const tmpDir = makeTmpDir('codex-sync-b14-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
const fixtureContent = fs.readFileSync(path.join(FIXTURES_DIR, 'model-in-developer-instructions.toml'), 'utf8');
|
||
writeCodexAgentToml(agentsDir, 'gsd-planner', fixtureContent);
|
||
|
||
const result = syncCodex(tmpDir, false);
|
||
|
||
assert.equal(result.synced, 0, 'the prose model= inside the block must never be treated as a pin');
|
||
assert.equal(
|
||
fs.readFileSync(path.join(agentsDir, 'gsd-planner.toml'), 'utf8'),
|
||
fixtureContent,
|
||
'the agent prompt must survive a non-dry-run sync untouched',
|
||
);
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('B15 (cross-platform): CRLF file, stale pin — pin stripped, remaining line endings still CRLF', () => {
|
||
const tmpDir = makeTmpDir('codex-sync-b15-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
const lfContent = 'name = "gsd-scribe"\nmodel = "sonnet"\ndeveloper_instructions = \'\'\'\nWrite a changelog entry.\n\'\'\'\n';
|
||
const crlfContent = lfContent.replace(/\n/g, '\r\n');
|
||
writeCodexAgentToml(agentsDir, 'gsd-scribe', crlfContent);
|
||
|
||
const result = syncCodex(tmpDir, false);
|
||
|
||
assert.equal(result.synced, 1);
|
||
const updated = fs.readFileSync(path.join(agentsDir, 'gsd-scribe.toml'), 'utf8');
|
||
assert.ok(!updated.includes('model = "sonnet"'));
|
||
assert.equal(
|
||
updated,
|
||
'name = "gsd-scribe"\r\ndeveloper_instructions = \'\'\'\r\nWrite a changelog entry.\r\n\'\'\'\r\n',
|
||
'every remaining line ending must still be CRLF',
|
||
);
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('B16 (cross-platform): BOM file, stale pin — pin stripped, BOM preserved', () => {
|
||
const tmpDir = makeTmpDir('codex-sync-b16-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
const content = String.fromCharCode(0xfeff) +
|
||
'name = "gsd-archivist"\nmodel = "sonnet"\ndeveloper_instructions = \'\'\'\nArchive.\n\'\'\'\n';
|
||
writeCodexAgentToml(agentsDir, 'gsd-archivist', content);
|
||
|
||
const result = syncCodex(tmpDir, false);
|
||
|
||
assert.equal(result.synced, 1);
|
||
const updatedRaw = fs.readFileSync(path.join(agentsDir, 'gsd-archivist.toml'));
|
||
assert.equal(updatedRaw[0], 0xef, 'BOM byte 1 (EF) must survive');
|
||
assert.equal(updatedRaw[1], 0xbb, 'BOM byte 2 (BB) must survive');
|
||
assert.equal(updatedRaw[2], 0xbf, 'BOM byte 3 (BF) must survive');
|
||
assert.ok(!updatedRaw.toString('utf8').includes('model = "sonnet"'));
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('B17 (boundary): file with no trailing newline — preserved', () => {
|
||
const tmpDir = makeTmpDir('codex-sync-b17-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
const content = 'name = "gsd-bare"\nmodel = "sonnet"'; // deliberately no trailing \n
|
||
writeCodexAgentToml(agentsDir, 'gsd-bare', content);
|
||
|
||
const result = syncCodex(tmpDir, false);
|
||
|
||
assert.equal(result.synced, 1);
|
||
const updated = fs.readFileSync(path.join(agentsDir, 'gsd-bare.toml'), 'utf8');
|
||
assert.equal(updated, 'name = "gsd-bare"', 'the file must still have no trailing newline');
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('B18 (negative proof): hand-added approval_policy survives a strip of the stale pin untouched', () => {
|
||
const tmpDir = makeTmpDir('codex-sync-b18-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
writeCodexAgentToml(
|
||
agentsDir,
|
||
'gsd-custom-agent',
|
||
'name = "gsd-custom-agent"\nmodel = "sonnet"\napproval_policy = "on-request"\n' +
|
||
"developer_instructions = '''\nFollow policy.\n'''\n",
|
||
);
|
||
|
||
const result = syncCodex(tmpDir, false);
|
||
|
||
assert.equal(result.synced, 1);
|
||
const updated = fs.readFileSync(path.join(agentsDir, 'gsd-custom-agent.toml'), 'utf8');
|
||
assert.ok(updated.includes('approval_policy = "on-request"'), 'the hand-added key must survive verbatim');
|
||
assert.ok(!updated.includes('model = "sonnet"'));
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('B19 (negative proof): interleaved comments preserved verbatim', () => {
|
||
const tmpDir = makeTmpDir('codex-sync-b19-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
writeCodexAgentToml(
|
||
agentsDir,
|
||
'gsd-commented',
|
||
'# top comment\nname = "gsd-commented"\n# a note about the model below\nmodel = "sonnet"\n# trailing comment\n' +
|
||
"developer_instructions = '''\nWork.\n'''\n",
|
||
);
|
||
|
||
const result = syncCodex(tmpDir, false);
|
||
|
||
assert.equal(result.synced, 1);
|
||
const updated = fs.readFileSync(path.join(agentsDir, 'gsd-commented.toml'), 'utf8');
|
||
assert.ok(updated.includes('# top comment'));
|
||
assert.ok(updated.includes('# a note about the model below'));
|
||
assert.ok(updated.includes('# trailing comment'));
|
||
assert.ok(!updated.includes('model = "sonnet"'));
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('B20 (filesystem failure, atomic-write proof): a mid-write failure is reported; the remaining agents still get processed; the target is left byte-identical, never partially rewritten', (t) => {
|
||
const tmpDir = makeTmpDir('codex-sync-b20-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
const alphaOriginal = 'name = "gsd-alpha"\nmodel = "sonnet"\ndeveloper_instructions = \'\'\'\nWork.\n\'\'\'\n';
|
||
writeCodexAgentToml(agentsDir, 'gsd-alpha', alphaOriginal);
|
||
writeCodexAgentToml(agentsDir, 'gsd-bravo', 'name = "gsd-bravo"\nmodel = "opus"\ndeveloper_instructions = \'\'\'\nWork.\n\'\'\'\n');
|
||
const failingPath = path.join(agentsDir, 'gsd-alpha.toml');
|
||
|
||
// Unlike the old version of this test — which mocked fs.writeFileSync to
|
||
// throw BEFORE any bytes ever reached disk, proving nothing about a
|
||
// mid-write failure — this injects the failure at the point a NON-ATOMIC
|
||
// implementation (`fs.writeFileSync(filePath, ...)` straight into the
|
||
// target, in place) would already have truncated the real file: 'w'-mode
|
||
// open+truncate happens before any content is written, so a crash between
|
||
// open and completion leaves a partial file. The mock actually performs a
|
||
// REAL (truncated) write to whatever path it's called with — including a
|
||
// hypothetical direct write to `failingPath` itself — before throwing, so
|
||
// an in-place implementation's target would end up holding these 4 bytes,
|
||
// not the original content. An atomic tmp+rename implementation instead
|
||
// sends this call to a SIBLING tmp path (never `failingPath` itself), so
|
||
// `failingPath` is never opened for write in the first place and survives
|
||
// untouched.
|
||
const realWriteFileSync = fs.writeFileSync;
|
||
t.mock.method(fs, 'writeFileSync', (target, data, ...args) => {
|
||
if (typeof target === 'string' && target.startsWith(failingPath)) {
|
||
realWriteFileSync.call(fs, target, String(data).slice(0, 4));
|
||
throw Object.assign(new Error('injected ENOSPC (mid-write)'), { code: 'ENOSPC' });
|
||
}
|
||
return realWriteFileSync.call(fs, target, data, ...args);
|
||
});
|
||
|
||
const result = syncCodex(tmpDir, false);
|
||
|
||
assert.equal(result.synced, 1, 'only the non-failing agent must be reported synced');
|
||
assert.equal(result.write_failures.length, 1);
|
||
assert.equal(result.write_failures[0].agent, 'gsd-alpha');
|
||
assert.ok(
|
||
!result.changes.some(c => c.agent === 'gsd-alpha'),
|
||
'a failed write must never be reported as a completed change',
|
||
);
|
||
assert.ok(
|
||
fs.readFileSync(path.join(agentsDir, 'gsd-bravo.toml'), 'utf8').indexOf('model = "opus"') === -1,
|
||
'the sibling agent must still be synced despite the other write failing',
|
||
);
|
||
// The load-bearing assertion (ADR-2313 "never partially rewritten"): the
|
||
// target must be BYTE-IDENTICAL to its pre-sync content, not merely
|
||
// "contains model = sonnet somewhere" — a truncated-to-4-bytes file would
|
||
// pass a substring check but fail this equality. Against the pre-fix
|
||
// in-place `fs.writeFileSync(filePath, renderCodexAgentToml(doc))`, this
|
||
// assertion FAILS: that call's target IS `failingPath`, so the mock's
|
||
// real truncated write lands directly on the file, leaving it as the
|
||
// 4-byte slice `'name'` instead of `alphaOriginal`.
|
||
assert.equal(
|
||
fs.readFileSync(failingPath, 'utf8'),
|
||
alphaOriginal,
|
||
'a mid-write failure must leave the original file byte-identical, never partially rewritten',
|
||
);
|
||
// The atomic write path cleans up its sibling tmp file on failure — no
|
||
// stray `.tmp.<pid>` left behind in the agents directory.
|
||
const leftovers = fs.readdirSync(agentsDir).filter(f => f !== 'gsd-alpha.toml' && f !== 'gsd-bravo.toml');
|
||
assert.deepEqual(leftovers, [], 'a failed write must not leave a stray tmp file behind');
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('B21 (independence): several agents, mixed states — per-agent results, deterministic order', () => {
|
||
const tmpDir = makeTmpDir('codex-sync-b21-');
|
||
const agentsDir = makeAgentsDir(tmpDir);
|
||
writeCodexAgentToml(agentsDir, 'gsd-alpha', 'name = "gsd-alpha"\ndeveloper_instructions = \'\'\'\nClean.\n\'\'\'\n');
|
||
writeCodexAgentToml(agentsDir, 'gsd-bravo', 'name = "gsd-bravo"\nmodel = "opus"\ndeveloper_instructions = \'\'\'\nWork.\n\'\'\'\n');
|
||
writeCodexAgentToml(agentsDir, 'gsd-charlie', 'name = "gsd-charlie"\nmodel_reasoning_effort = "medium"\ndeveloper_instructions = \'\'\'\nWork.\n\'\'\'\n');
|
||
|
||
const result = syncCodex(tmpDir, false);
|
||
|
||
assert.equal(result.synced, 2, 'gsd-bravo and gsd-charlie must both sync; gsd-alpha is clean');
|
||
assert.equal(result.skipped, 1);
|
||
assert.deepEqual(
|
||
result.changes.map(c => c.agent),
|
||
['gsd-bravo', 'gsd-charlie'],
|
||
'agents must be processed in deterministic (sorted) order',
|
||
);
|
||
|
||
cleanup(tmpDir);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
describe('query commit --files scoping (#2269)', () => {
|
||
const REPO_ROOT = path.join(__dirname, '..');
|
||
|
||
test('secure-phase.md passes --files to its query commit call', () => {
|
||
const content = fs.readFileSync(
|
||
path.join(REPO_ROOT, 'gsd-core', 'workflows', 'secure-phase.md'), 'utf-8'
|
||
);
|
||
const idx = content.indexOf('add/update security threat verification');
|
||
assert.notEqual(idx, -1, 'must contain the security commit message');
|
||
assert.match(content.slice(idx, idx + 200), /--files/);
|
||
assert.match(content.slice(idx, idx + 200), /SECURITY\.md/);
|
||
});
|
||
|
||
test('validate-phase.md passes --files to its query commit call', () => {
|
||
const content = fs.readFileSync(
|
||
path.join(REPO_ROOT, 'gsd-core', 'workflows', 'validate-phase.md'), 'utf-8'
|
||
);
|
||
const idx = content.indexOf('add/update validation strategy');
|
||
assert.notEqual(idx, -1, 'must contain the validation commit message');
|
||
assert.match(content.slice(idx, idx + 200), /--files/);
|
||
assert.match(content.slice(idx, idx + 200), /VALIDATION\.md/);
|
||
});
|
||
|
||
test('next.md passes --files to its deferral query commit call', () => {
|
||
const content = fs.readFileSync(
|
||
path.join(REPO_ROOT, 'gsd-core', 'workflows', 'next.md'), 'utf-8'
|
||
);
|
||
const idx = content.indexOf('defer incomplete Phase');
|
||
assert.notEqual(idx, -1, 'must contain the deferral commit message');
|
||
assert.match(content.slice(idx, idx + 200), /--files/);
|
||
assert.match(content.slice(idx, idx + 200), /ROADMAP\.md/);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// #3776: `query commit --files` must report an empty diff as `nothing_to_commit`
|
||
// even when a pre-commit hook would reject.
|
||
//
|
||
// `stagedPaths` records paths whose `git add` exited 0 — "did staging succeed",
|
||
// not "is there anything to commit". Staging an already-committed, unmodified
|
||
// file succeeds and contributes nothing, so the old `stagedPaths.length === 0`
|
||
// guard was reachable only when EVERY named path was missing from disk. The
|
||
// ordinary empty-diff case fell through to `git commit`, where the sole rescue
|
||
// was a string match on git's "nothing to commit" output — and git runs the
|
||
// pre-commit hook BEFORE deciding there is nothing to commit, so a rejecting
|
||
// hook pre-empted the match and the caller was handed `commit_failed` carrying
|
||
// a gate message about a commit that had nothing to gate.
|
||
//
|
||
// The residual sibling of #2608/#2693, which covered `git add` FAILING; this
|
||
// covers `git add` succeeding and contributing nothing.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
describe('#3776: query commit --files reports an empty diff as nothing_to_commit', () => {
|
||
const { createTempGitProject } = require('./helpers.cjs');
|
||
// runGit (never gitOrThrow) for the conflicting merge below — that merge is
|
||
// MEANT to exit non-zero, and the throwing wrapper would fail the fixture.
|
||
const { runGit } = require('./helpers/process-seam.cjs');
|
||
let tmpDir;
|
||
|
||
const REJECTING_HOOK = '#!/bin/sh\necho "gate: BACKLOG.md is stale" >&2\nexit 1\n';
|
||
const PASSING_HOOK = '#!/bin/sh\nexit 0\n';
|
||
|
||
// Writes .git/hooks/pre-commit. Every arm below drives the real hook, not a
|
||
// stub of it: the defect lives in git's own hook-before-empty-diff ordering,
|
||
// so a faked rejection would not exercise the mechanism under test.
|
||
function installHook(body) {
|
||
const hookPath = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
|
||
fs.writeFileSync(hookPath, body);
|
||
fs.chmodSync(hookPath, 0o755);
|
||
}
|
||
|
||
// A tracked, committed, unmodified file — `git add` on it succeeds and
|
||
// contributes no diff. This is the exact shape the guard used to miss.
|
||
function commitFixtureFile(name = 'doc.md', body = 'hello\n') {
|
||
const rel = path.posix.join('.planning', name);
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', name), body);
|
||
gitOrThrow(['add', '--', rel], { cwd: tmpDir });
|
||
gitOrThrow(['commit', '-m', 'fixture: ' + name], { cwd: tmpDir });
|
||
return rel;
|
||
}
|
||
|
||
// The command emits its JSON payload on either stream depending on outcome;
|
||
// read whichever carries it rather than assuming success.
|
||
function commitFiles(rel, extra = '') {
|
||
const result = runGsdTools('commit "m"' + extra + ' --files ' + rel, tmpDir);
|
||
const payload = (result.output && result.output.trim()) ? result.output : result.error;
|
||
return JSON.parse(payload);
|
||
}
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempGitProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
// AC1 — the defect. Pre-fix this returned commit_failed + the hook's message.
|
||
test('AC1: empty diff + rejecting pre-commit hook reports nothing_to_commit, not the hook rejection', () => {
|
||
const rel = commitFixtureFile();
|
||
installHook(REJECTING_HOOK);
|
||
|
||
const output = commitFiles(rel);
|
||
assert.strictEqual(output.committed, false);
|
||
assert.strictEqual(output.reason, 'nothing_to_commit',
|
||
'an empty-diff --files call must not be reported as a failed commit');
|
||
assert.ok(!output.error,
|
||
'no hook message may be surfaced for a call that had nothing to gate');
|
||
});
|
||
|
||
// AC2 — the two controls that isolate the hook as the only variable.
|
||
test('AC2: empty diff + no hook still reports nothing_to_commit', () => {
|
||
const rel = commitFixtureFile();
|
||
|
||
const output = commitFiles(rel);
|
||
assert.strictEqual(output.committed, false);
|
||
assert.strictEqual(output.reason, 'nothing_to_commit');
|
||
});
|
||
|
||
test('AC2: empty diff + passing hook still reports nothing_to_commit', () => {
|
||
const rel = commitFixtureFile();
|
||
installHook(PASSING_HOOK);
|
||
|
||
const output = commitFiles(rel);
|
||
assert.strictEqual(output.committed, false);
|
||
assert.strictEqual(output.reason, 'nothing_to_commit');
|
||
});
|
||
|
||
// AC3 — the all-missing short-circuit must not regress.
|
||
test('AC3: every named path missing from disk still reports nothing_to_commit', () => {
|
||
const rel = commitFixtureFile();
|
||
fs.unlinkSync(path.join(tmpDir, rel));
|
||
installHook(REJECTING_HOOK);
|
||
|
||
const output = commitFiles(rel);
|
||
assert.strictEqual(output.committed, false);
|
||
assert.strictEqual(output.reason, 'nothing_to_commit');
|
||
});
|
||
|
||
// AC3, sharp edge: the `stagedPaths.length === 0` short-circuit is
|
||
// load-bearing, not defensive noise. Without it an all-missing call spreads
|
||
// an empty array into the pathspec, and a pathspec-less `git diff HEAD`
|
||
// tests the WHOLE tree — so unrelated work would suppress the guard and turn
|
||
// this arm into a commit of somebody else's changes.
|
||
test('AC3: all named paths missing does not consult unrelated staged work', () => {
|
||
const rel = commitFixtureFile();
|
||
fs.unlinkSync(path.join(tmpDir, rel));
|
||
const unrelated = path.posix.join('.planning', 'unrelated.md');
|
||
fs.writeFileSync(path.join(tmpDir, unrelated), 'staged by the caller\n');
|
||
gitOrThrow(['add', '--', unrelated], { cwd: tmpDir });
|
||
installHook(REJECTING_HOOK);
|
||
|
||
const output = commitFiles(rel);
|
||
assert.strictEqual(output.committed, false);
|
||
assert.strictEqual(output.reason, 'nothing_to_commit');
|
||
|
||
const staged = gitOrThrow(['diff', '--cached', '--name-only'], { cwd: tmpDir });
|
||
assert.match(staged, /unrelated\.md/,
|
||
"the caller's own staged work must be left in the index, not swept into a commit");
|
||
});
|
||
|
||
// AC4 — a genuine rejection must still be reported. The goal is to stop
|
||
// reporting a rejection for a call that never had anything to gate, not to
|
||
// stop reporting rejections.
|
||
test('AC4: a real diff rejected by the hook still reports commit_failed with the hook message', () => {
|
||
const rel = commitFixtureFile();
|
||
fs.writeFileSync(path.join(tmpDir, rel), 'hello\nmodified\n');
|
||
installHook(REJECTING_HOOK);
|
||
|
||
const output = commitFiles(rel);
|
||
assert.strictEqual(output.committed, false);
|
||
assert.strictEqual(output.reason, 'commit_failed');
|
||
assert.match(String(output.error), /BACKLOG\.md is stale/,
|
||
"the hook's own message must still reach the caller");
|
||
});
|
||
|
||
test('AC4: a real diff with no hook still commits', () => {
|
||
const rel = commitFixtureFile();
|
||
fs.writeFileSync(path.join(tmpDir, rel), 'hello\nmodified\n');
|
||
|
||
const output = commitFiles(rel);
|
||
assert.strictEqual(output.committed, true);
|
||
assert.strictEqual(output.reason, 'committed');
|
||
assert.ok(output.hash, 'a successful commit must carry its hash');
|
||
});
|
||
|
||
// AC5 — amending has a different empty-diff meaning; the guard stays exempt.
|
||
test('AC5: --amend remains exempt from the empty-diff guard', () => {
|
||
const rel = commitFixtureFile();
|
||
installHook(REJECTING_HOOK);
|
||
|
||
const output = commitFiles(rel, ' --amend');
|
||
assert.strictEqual(output.committed, false);
|
||
assert.strictEqual(output.reason, 'commit_failed',
|
||
'--amend must still reach git, where the hook governs the rewrite');
|
||
});
|
||
|
||
// Beyond the brief's ACs: during a merge git refuses a partial commit, so the
|
||
// commit runs WITHOUT the pathspec and the named paths describe nothing about
|
||
// what would land. Deciding "nothing to commit" from them would abandon the
|
||
// merge — which is why the empty-diff probe is gated on !isMergeInProgress.
|
||
// Sets up a conflicted history and leaves the caller mid-sequence. `rel` (the
|
||
// file the commit call names) is never touched by the conflict, so it always
|
||
// contributes no diff of its own — which is what puts these arms on the
|
||
// empty-diff branch under test.
|
||
function conflictedSequence(kind) {
|
||
const shared = path.posix.join('.planning', 'shared.md');
|
||
fs.writeFileSync(path.join(tmpDir, shared), 'base\n');
|
||
gitOrThrow(['add', '--', shared], { cwd: tmpDir });
|
||
gitOrThrow(['commit', '-m', 'shared base'], { cwd: tmpDir });
|
||
const trunk = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim();
|
||
|
||
if (kind === 'revert') {
|
||
fs.writeFileSync(path.join(tmpDir, shared), 'second\n');
|
||
gitOrThrow(['commit', '-am', 'second'], { cwd: tmpDir });
|
||
fs.writeFileSync(path.join(tmpDir, shared), 'third\n');
|
||
gitOrThrow(['commit', '-am', 'third'], { cwd: tmpDir });
|
||
runGit(['revert', '--no-edit', 'HEAD~1'], { cwd: tmpDir });
|
||
} else {
|
||
gitOrThrow(['checkout', '-b', 'side'], { cwd: tmpDir });
|
||
fs.writeFileSync(path.join(tmpDir, shared), 'side\n');
|
||
gitOrThrow(['commit', '-am', 'side edit'], { cwd: tmpDir });
|
||
gitOrThrow(['checkout', trunk], { cwd: tmpDir });
|
||
fs.writeFileSync(path.join(tmpDir, shared), 'trunk\n');
|
||
gitOrThrow(['commit', '-am', 'trunk edit'], { cwd: tmpDir });
|
||
runGit([kind === 'merge' ? 'merge' : 'cherry-pick', 'side'], { cwd: tmpDir });
|
||
}
|
||
fs.writeFileSync(path.join(tmpDir, shared), 'resolved\n');
|
||
gitOrThrow(['add', '--', shared], { cwd: tmpDir });
|
||
}
|
||
|
||
// The one state where `git diff` and `git commit -- <path>` genuinely
|
||
// disagree. `--assume-unchanged` makes `git add` stage nothing and BOTH diff
|
||
// forms (`--cached` and `HEAD`) report no difference, while
|
||
// `git commit -- <path>` reads the working tree directly and records it. The
|
||
// pre-#3776 build therefore COMMITTED this, and the guard must not turn a
|
||
// purely diagnostic fix into a silent drop of content the caller named in
|
||
// `--files`. Asking `git commit --dry-run` preserves the pre-fix outcome
|
||
// exactly, because it is the same decision the real commit makes.
|
||
test('a modified assume-unchanged path is still committed, not swallowed by the guard', () => {
|
||
const rel = commitFixtureFile();
|
||
gitOrThrow(['update-index', '--assume-unchanged', '--', rel], { cwd: tmpDir });
|
||
fs.writeFileSync(path.join(tmpDir, rel), 'hello\nmodified under assume-unchanged\n');
|
||
|
||
const output = commitFiles(rel);
|
||
assert.strictEqual(output.committed, true,
|
||
'git commit -- <path> reads the working tree and records it; the guard must not pre-empt that');
|
||
assert.strictEqual(
|
||
gitOrThrow(['show', 'HEAD:' + rel], { cwd: tmpDir }),
|
||
'hello\nmodified under assume-unchanged\n',
|
||
'and the content it records must be the working-tree content');
|
||
});
|
||
|
||
// THE OTHER DIRECTION, and the reason the check compares CONTENT rather than
|
||
// stopping at the `ls-files -v` tag. An unmodified assume-unchanged path has
|
||
// nothing to record; falling through on the tag alone would hand it to
|
||
// `git commit`, which — with any unrelated modified file present — prints
|
||
// `no changes added to commit`, a string the fallback does not match, and
|
||
// returns `commit_failed`. That is #3776 re-entered from the other side, the
|
||
// same shape `--ignore-submodules=none` would have re-entered it. Pinned so a
|
||
// later simplification to a tag-only test cannot pass.
|
||
test('an UNMODIFIED assume-unchanged path still reports nothing_to_commit, even with unrelated dirt', () => {
|
||
const rel = commitFixtureFile();
|
||
const unrelated = path.posix.join('.planning', 'unrelated.md');
|
||
fs.writeFileSync(path.join(tmpDir, unrelated), 'seed\n');
|
||
gitOrThrow(['add', '--', unrelated], { cwd: tmpDir });
|
||
gitOrThrow(['commit', '-m', 'seed unrelated'], { cwd: tmpDir });
|
||
gitOrThrow(['update-index', '--assume-unchanged', '--', rel], { cwd: tmpDir });
|
||
// Unrelated modified work present — this is what turns git's answer from
|
||
// `nothing to commit` into `no changes added to commit`.
|
||
fs.writeFileSync(path.join(tmpDir, unrelated), 'unrelated edit\n');
|
||
|
||
assert.strictEqual(commitFiles(rel).reason, 'nothing_to_commit',
|
||
'nothing would land for the named path, so the guard must still answer nothing_to_commit');
|
||
});
|
||
|
||
// `--skip-worktree` is NOT a second instance of the above, and the PR body
|
||
// used to group them. `git add` exits 1 under it (the path reads as outside
|
||
// the sparse-checkout definition), so it fails closed as `staging_failed`
|
||
// ABOVE this guard and never reaches the empty-diff decision at all.
|
||
test('a modified skip-worktree path fails closed as staging_failed, never reaching the guard', () => {
|
||
const rel = commitFixtureFile();
|
||
gitOrThrow(['update-index', '--skip-worktree', '--', rel], { cwd: tmpDir });
|
||
fs.writeFileSync(path.join(tmpDir, rel), 'hello\nmodified under skip-worktree\n');
|
||
|
||
const output = commitFiles(rel);
|
||
assert.strictEqual(output.committed, false);
|
||
assert.strictEqual(output.reason, 'staging_failed',
|
||
'git add refuses the path, so the staging-failure block above the guard owns this case');
|
||
});
|
||
|
||
// The same flag with the path ABSENT from disk — the canonical sparse shape —
|
||
// takes a DIFFERENT route, and the distinction is worth pinning because the
|
||
// obvious reading of the arm above ("skip-worktree never reaches the guard")
|
||
// is too strong. A missing path is skipped before `git add` runs at all
|
||
// (#2014), so `stagedPaths` is empty and the guard's own
|
||
// `stagedPaths.length === 0` arm answers it. `nothing_to_commit` is the
|
||
// correct answer there — the file does not exist, so a commit would record
|
||
// nothing — and it is the PRE-FIX answer too, unchanged by this PR.
|
||
test('a skip-worktree path absent from disk reports nothing_to_commit via the missing-path arm', () => {
|
||
const rel = commitFixtureFile();
|
||
gitOrThrow(['update-index', '--skip-worktree', '--', rel], { cwd: tmpDir });
|
||
fs.unlinkSync(path.join(tmpDir, rel));
|
||
|
||
assert.strictEqual(commitFiles(rel).reason, 'nothing_to_commit',
|
||
'a missing path is skipped before git add, so the length === 0 arm owns this — not staging_failed');
|
||
});
|
||
|
||
// THREE ARMS PINNING WHY THE PROBE ASKS GIT RATHER THAN RECONSTRUCTING ITS
|
||
// ANSWER. Each one reds if the dry run is replaced by a
|
||
// `hash-object` vs `HEAD:<path>` blob comparison, and each is a silent drop
|
||
// of content the caller named — the exact class this whole guard is careful
|
||
// about.
|
||
|
||
// A mode-only change leaves the blob identical, so a content comparison sees
|
||
// nothing — while `git commit -- <path>` records the new mode.
|
||
test('a mode-only change to an assume-unchanged path is still committed', (t) => {
|
||
const rel = commitFixtureFile('exec.md');
|
||
// Windows, and any checkout with `core.filemode=false`, cannot represent
|
||
// the bit — `chmodSync` would then be a no-op and this arm would pass while
|
||
// pinning nothing. Assert the precondition and skip loudly instead.
|
||
gitOrThrow(['config', 'core.filemode', 'true'], { cwd: tmpDir });
|
||
gitOrThrow(['update-index', '--assume-unchanged', '--', rel], { cwd: tmpDir });
|
||
fs.chmodSync(path.join(tmpDir, rel), 0o755);
|
||
if (!/^100755 /.test(gitOrThrow(['ls-files', '-s', '--', rel], { cwd: tmpDir }))
|
||
&& (fs.statSync(path.join(tmpDir, rel)).mode & 0o111) === 0) {
|
||
t.skip('filesystem cannot represent the executable bit — nothing to pin here');
|
||
return;
|
||
}
|
||
|
||
assert.strictEqual(commitFiles(rel).committed, true,
|
||
'the mode moved and git would record it, so the guard must not report nothing_to_commit');
|
||
assert.match(
|
||
gitOrThrow(['ls-tree', 'HEAD', '--', rel], { cwd: tmpDir }), /^100755 /,
|
||
'and the recorded mode must actually be the executable one');
|
||
});
|
||
|
||
// A non-ASCII path is rendered QUOTED by `git ls-files -v` under the default
|
||
// `core.quotePath` (`"caf\303\251.md"`), so any probe that parses the path
|
||
// out of that output reads a filename that does not exist and silently
|
||
// concludes there is nothing to commit.
|
||
test('a modified assume-unchanged path with a non-ASCII name is still committed', () => {
|
||
const rel = commitFixtureFile('caf\u00e9.md');
|
||
// PIN the quoting explicitly. This arm's whole point is that a probe
|
||
// parsing the path out of `ls-files -v` reads `"caf\303\251.md"` and finds
|
||
// no such file; under an ambient `core.quotePath=false` the rejected
|
||
// implementation would pass here and the arm would be vacuous.
|
||
gitOrThrow(['config', 'core.quotePath', 'true'], { cwd: tmpDir });
|
||
gitOrThrow(['update-index', '--assume-unchanged', '--', rel], { cwd: tmpDir });
|
||
fs.writeFileSync(path.join(tmpDir, rel), 'modified\n');
|
||
|
||
assert.strictEqual(commitFiles(rel).committed, true,
|
||
'core.quotePath must not be able to hide a real change from the probe');
|
||
assert.strictEqual(gitOrThrow(['show', 'HEAD:' + rel], { cwd: tmpDir }), 'modified\n');
|
||
});
|
||
|
||
// The probe compares the WORKING TREE to HEAD, so an unborn HEAD makes it
|
||
// fatal (rc 128). That must fall through to the commit rather than be read as
|
||
// "nothing to commit" — there is plenty to commit in a repo with no commits.
|
||
test('an unborn HEAD falls through to the commit rather than reporting nothing_to_commit', (t) => {
|
||
const fresh = createTempDir();
|
||
// REGISTERED teardown, not a trailing statement: `fresh` lives outside
|
||
// `tmpDir`, so afterEach does not reach it and any failing assertion below
|
||
// would leak a git repo into the temp root.
|
||
t.after(() => cleanup(fresh));
|
||
fs.mkdirSync(path.join(fresh, '.planning'), { recursive: true });
|
||
fs.writeFileSync(path.join(fresh, '.planning', 'config.json'), '{}\n');
|
||
fs.writeFileSync(path.join(fresh, '.planning', 'doc.md'), 'first content\n');
|
||
gitOrThrow(['init', '-q', '.'], { cwd: fresh });
|
||
gitOrThrow(['config', 'user.email', 't@t'], { cwd: fresh });
|
||
gitOrThrow(['config', 'user.name', 't'], { cwd: fresh });
|
||
|
||
const result = runGsdTools('commit "m" --files .planning/doc.md', fresh);
|
||
const payload = (result.output && result.output.trim()) ? result.output : result.error;
|
||
const output = JSON.parse(payload);
|
||
assert.strictEqual(output.committed, true,
|
||
'the very first commit in a repo must not be swallowed by the empty-diff guard');
|
||
});
|
||
|
||
// git refuses a partial commit during a cherry-pick exactly as it does during
|
||
// a merge, so the guard must stay out of the way there too — this arm pins
|
||
// that the pre-fix outcome is preserved rather than turned into a silent
|
||
// no-op. Driven, not assumed: the three sequencer states disagree.
|
||
test('a cherry-pick in progress keeps its pre-existing outcome', () => {
|
||
const rel = commitFixtureFile();
|
||
conflictedSequence('cherry-pick');
|
||
assert.ok(fs.existsSync(path.join(tmpDir, '.git', 'CHERRY_PICK_HEAD')),
|
||
'fixture must leave a cherry-pick in progress');
|
||
installHook(REJECTING_HOOK);
|
||
|
||
const output = commitFiles(rel);
|
||
assert.strictEqual(output.committed, false);
|
||
assert.strictEqual(output.reason, 'commit_failed',
|
||
'git refuses the partial commit here; that must not become a silent nothing_to_commit');
|
||
assert.match(String(output.error), /partial commit/,
|
||
"git's own refusal must reach the caller");
|
||
});
|
||
|
||
// REVERT_HEAD is deliberately NOT in the refusal set: a revert permits partial
|
||
// commits, so the fix must still apply there. Including it would suppress the
|
||
// fix during a revert and reintroduce the misreport.
|
||
test('a revert in progress still reports nothing_to_commit, not the hook rejection', () => {
|
||
const rel = commitFixtureFile();
|
||
conflictedSequence('revert');
|
||
assert.ok(fs.existsSync(path.join(tmpDir, '.git', 'REVERT_HEAD')),
|
||
'fixture must leave a revert in progress');
|
||
installHook(REJECTING_HOOK);
|
||
|
||
const output = commitFiles(rel);
|
||
assert.strictEqual(output.committed, false);
|
||
assert.strictEqual(output.reason, 'nothing_to_commit',
|
||
'a revert permits partial commits, so the empty-diff guard must still apply');
|
||
});
|
||
|
||
test('a merge in progress is still concluded when the named paths carry no diff', () => {
|
||
const shared = path.posix.join('.planning', 'shared.md');
|
||
fs.writeFileSync(path.join(tmpDir, shared), 'base\n');
|
||
gitOrThrow(['add', '--', shared], { cwd: tmpDir });
|
||
gitOrThrow(['commit', '-m', 'shared base'], { cwd: tmpDir });
|
||
const rel = commitFixtureFile();
|
||
|
||
const trunk = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim();
|
||
gitOrThrow(['checkout', '-b', 'side'], { cwd: tmpDir });
|
||
fs.writeFileSync(path.join(tmpDir, shared), 'side\n');
|
||
gitOrThrow(['commit', '-am', 'side edit'], { cwd: tmpDir });
|
||
gitOrThrow(['checkout', trunk], { cwd: tmpDir });
|
||
fs.writeFileSync(path.join(tmpDir, shared), 'trunk\n');
|
||
gitOrThrow(['commit', '-am', 'trunk edit'], { cwd: tmpDir });
|
||
|
||
// Conflicting merge, then resolve it so the index carries real content.
|
||
runGit(['merge', 'side'], { cwd: tmpDir });
|
||
fs.writeFileSync(path.join(tmpDir, shared), 'resolved\n');
|
||
gitOrThrow(['add', '--', shared], { cwd: tmpDir });
|
||
assert.ok(fs.existsSync(path.join(tmpDir, '.git', 'MERGE_HEAD')),
|
||
'fixture must leave a merge in progress');
|
||
|
||
// `rel` is committed and unmodified — it contributes no diff of its own.
|
||
const output = commitFiles(rel);
|
||
assert.strictEqual(output.committed, true,
|
||
'the merge must still be concluded, not reported as nothing to commit');
|
||
assert.ok(!fs.existsSync(path.join(tmpDir, '.git', 'MERGE_HEAD')),
|
||
'MERGE_HEAD must be gone once the merge commit lands');
|
||
});
|
||
});
|
||
|
||
// #3859: the empty-diff probe must answer the question `git commit -- <paths>`
|
||
// asks. `git diff` is porcelain and honours user configuration the commit does
|
||
// not, so an unpinned probe lets a caller's config decide whether the guard
|
||
// fires — and every arm below was driven against git 2.54 by confirming that
|
||
// `git commit -- <path>` records exactly the change the unpinned probe reports
|
||
// as absent.
|
||
describe('#3859: the empty-diff probe is pinned against diff-only configuration', () => {
|
||
const { createTempGitProject } = require('./helpers.cjs');
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempGitProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
function commitFiles(rel) {
|
||
const result = runGsdTools('commit "m" --files ' + rel, tmpDir);
|
||
const payload = (result.output && result.output.trim()) ? result.output : result.error;
|
||
return JSON.parse(payload);
|
||
}
|
||
|
||
// Sub-repos created by `bumpedSubmodule()` are SIBLINGS of `tmpDir`, so the
|
||
// `afterEach` above does not reach them. Registering them here cleans every
|
||
// caller at once and — unlike a trailing `cleanup(subSrc)` in each test body
|
||
// — survives a failing assertion, which would otherwise leak a git repo into
|
||
// the temp root.
|
||
const strayRepos = [];
|
||
afterEach(() => {
|
||
while (strayRepos.length > 0) cleanup(strayRepos.pop());
|
||
});
|
||
|
||
// A submodule whose recorded gitlink is AHEAD of what the superproject has
|
||
// committed — i.e. `git commit -- <sub>` has something real to record.
|
||
function bumpedSubmodule() {
|
||
const subSrc = path.join(tmpDir, '..', path.basename(tmpDir) + '-sub');
|
||
strayRepos.push(subSrc);
|
||
fs.mkdirSync(subSrc, { recursive: true });
|
||
gitOrThrow(['init', '-q', '.'], { cwd: subSrc });
|
||
gitOrThrow(['config', 'user.email', 't@t'], { cwd: subSrc });
|
||
gitOrThrow(['config', 'user.name', 't'], { cwd: subSrc });
|
||
fs.writeFileSync(path.join(subSrc, 'f.txt'), 'v1\n');
|
||
gitOrThrow(['add', 'f.txt'], { cwd: subSrc });
|
||
gitOrThrow(['commit', '-m', 'v1'], { cwd: subSrc });
|
||
|
||
gitOrThrow(['-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', subSrc, 'sub'], { cwd: tmpDir });
|
||
gitOrThrow(['commit', '-m', 'add submodule'], { cwd: tmpDir });
|
||
|
||
fs.writeFileSync(path.join(subSrc, 'f.txt'), 'v2\n');
|
||
gitOrThrow(['add', 'f.txt'], { cwd: subSrc });
|
||
gitOrThrow(['commit', '-m', 'v2'], { cwd: subSrc });
|
||
gitOrThrow(['-c', 'protocol.file.allow=always', 'submodule', 'update', '--remote', '--', 'sub'], { cwd: tmpDir });
|
||
}
|
||
|
||
// `diff.ignoreSubmodules=all` is local config; `.gitmodules` `ignore = all` is
|
||
// CHECKED IN and so arrives with a clone, needing no local setting at all —
|
||
// which makes it the stronger of the two vectors, and the one a reviewer
|
||
// reading only `diff.ignoreSubmodules` would not reach.
|
||
for (const vector of ['diff.ignoreSubmodules', '.gitmodules ignore']) {
|
||
test(`a submodule bump is not reported as nothing_to_commit under ${vector}=all`, () => {
|
||
bumpedSubmodule();
|
||
if (vector === 'diff.ignoreSubmodules') {
|
||
gitOrThrow(['config', 'diff.ignoreSubmodules', 'all'], { cwd: tmpDir });
|
||
} else {
|
||
gitOrThrow(['config', '-f', '.gitmodules', 'submodule.sub.ignore', 'all'], { cwd: tmpDir });
|
||
gitOrThrow(['add', '--', '.gitmodules'], { cwd: tmpDir });
|
||
gitOrThrow(['commit', '-m', 'gitmodules ignore=all'], { cwd: tmpDir });
|
||
}
|
||
|
||
const before = gitOrThrow(['rev-parse', 'HEAD:sub'], { cwd: tmpDir }).trim();
|
||
const output = commitFiles('sub');
|
||
|
||
assert.notStrictEqual(output.reason, 'nothing_to_commit',
|
||
'the gitlink moved and `git commit -- sub` records it, so the probe must not say there is nothing');
|
||
assert.strictEqual(output.committed, true);
|
||
assert.notStrictEqual(
|
||
gitOrThrow(['rev-parse', 'HEAD:sub'], { cwd: tmpDir }).trim(), before,
|
||
'the recorded gitlink must actually advance');
|
||
});
|
||
}
|
||
|
||
// A submodule path cannot be hashed at all (`fatal: Unable to hash sub`),
|
||
// while `git commit -- sub` advances the recorded gitlink.
|
||
test('an assume-unchanged submodule with an advanced gitlink is still committed', () => {
|
||
bumpedSubmodule();
|
||
const before = gitOrThrow(['rev-parse', 'HEAD:sub'], { cwd: tmpDir }).trim();
|
||
gitOrThrow(['update-index', '--assume-unchanged', '--', 'sub'], { cwd: tmpDir });
|
||
|
||
assert.notStrictEqual(commitFiles('sub').reason, 'nothing_to_commit',
|
||
'the gitlink would advance, so the guard must stand aside');
|
||
assert.notStrictEqual(
|
||
gitOrThrow(['rev-parse', 'HEAD:sub'], { cwd: tmpDir }).trim(), before,
|
||
'and the recorded gitlink must actually advance');
|
||
});
|
||
|
||
// The other direction, and the reason the pin is `=dirty` rather than `=none`.
|
||
// A partial commit of a submodule path records the GITLINK, which moves only
|
||
// when the submodule's HEAD does — so a merely dirty submodule WORKTREE would
|
||
// land nothing. Under `--ignore-submodules=none` the probe reports a
|
||
// difference there and sends an empty call back to `git commit`, which is the
|
||
// #3776 misreport re-entered from the other side. Pinned so a later widening
|
||
// to `=none` cannot pass.
|
||
test('a dirty submodule worktree with an unchanged gitlink still reports nothing_to_commit', () => {
|
||
bumpedSubmodule();
|
||
gitOrThrow(['add', '--', 'sub'], { cwd: tmpDir });
|
||
gitOrThrow(['commit', '-m', 'bump sub'], { cwd: tmpDir });
|
||
fs.appendFileSync(path.join(tmpDir, 'sub', 'f.txt'), 'dirty\n');
|
||
|
||
assert.strictEqual(commitFiles('sub').reason, 'nothing_to_commit',
|
||
'nothing would land, so nothing_to_commit is the correct answer, not a misreport');
|
||
});
|
||
|
||
// No submodule involved. A textconv driver maps two different blobs to the
|
||
// same text, so `git diff --quiet HEAD` reports no difference while
|
||
// `git commit -- <path>` records the new blob.
|
||
test('a change hidden by a textconv driver is not reported as nothing_to_commit', () => {
|
||
const rel = path.posix.join('.planning', 'binaryish.md');
|
||
fs.writeFileSync(path.join(tmpDir, rel), 'A\n');
|
||
fs.writeFileSync(path.join(tmpDir, '.gitattributes'), 'binaryish.md diff=flat\n');
|
||
gitOrThrow(['add', '--', rel, '.gitattributes'], { cwd: tmpDir });
|
||
gitOrThrow(['commit', '-m', 'seed'], { cwd: tmpDir });
|
||
// A textconv that collapses every input to one constant. `#` swallows the
|
||
// filename git appends, so the driver ignores its argument entirely.
|
||
gitOrThrow(['config', 'diff.flat.textconv', 'echo CONSTANT #'], { cwd: tmpDir });
|
||
|
||
fs.writeFileSync(path.join(tmpDir, rel), 'B\n');
|
||
const output = commitFiles(rel);
|
||
|
||
assert.notStrictEqual(output.reason, 'nothing_to_commit',
|
||
'the blob changed and the commit would record it — textconv only changes how the DIFF renders');
|
||
assert.strictEqual(output.committed, true);
|
||
assert.strictEqual(
|
||
gitOrThrow(['show', 'HEAD:' + rel], { cwd: tmpDir }), 'B\n',
|
||
'the new content must actually be recorded');
|
||
});
|
||
|
||
// #3859 follow-up (e935694fc/b3d37b929, widened after a canScope gap found
|
||
// reproducing live against the pinned CI tester image,
|
||
// ghcr.io/open-gsd/gsd-tester-linux:v1.8.0-node24, which runs git 2.39.5):
|
||
// the actual `git commit` call carries a `commitEnv` GIT_CONFIG_* override
|
||
// forcing `diff.ignoreSubmodules=dirty`. This was originally scoped to only
|
||
// fire when `canScope` was true (a pathspec-limited `git commit --
|
||
// <paths>`), on the assumption that only a pathspec-limited commit
|
||
// consults `diff.ignoreSubmodules` when deciding whether a bumped
|
||
// submodule gitlink is a real change to record. That assumption was wrong:
|
||
// on git 2.39.5 a bare WHOLE-INDEX `git commit -m ...` (no pathspec at
|
||
// all, canScope=false) is refused identically when the only staged change
|
||
// is a submodule gitlink under `diff.ignoreSubmodules=all` — git's
|
||
// "nothing to commit" check is a real diff (HEAD vs. index) that honours
|
||
// `diff.ignoreSubmodules` regardless of pathspec. The override is now
|
||
// applied unconditionally (no `canScope` gate) to cover this shape too.
|
||
// `--amend` is the one shape confirmed NOT to hit the refusal at all
|
||
// (reproduced directly: it succeeds identically with or without the
|
||
// override, since amend never runs the empty-diff check a plain `git
|
||
// commit` does) — its test below pins that the override being applied
|
||
// unconditionally is still harmless there.
|
||
test('a whole-index commit (no --files, canScope=false) still records a bumped submodule under diff.ignoreSubmodules=all', () => {
|
||
bumpedSubmodule();
|
||
gitOrThrow(['config', 'diff.ignoreSubmodules', 'all'], { cwd: tmpDir });
|
||
gitOrThrow(['add', '--', 'sub'], { cwd: tmpDir });
|
||
const before = gitOrThrow(['rev-parse', 'HEAD:sub'], { cwd: tmpDir }).trim();
|
||
|
||
const result = runGsdTools('commit "m"', tmpDir);
|
||
const payload = (result.output && result.output.trim()) ? result.output : result.error;
|
||
const output = JSON.parse(payload);
|
||
|
||
assert.strictEqual(output.committed, true,
|
||
'a whole-index commit hits the same git 2.39.5 refusal as a pathspec-limited one, so it needs the ' +
|
||
'GIT_CONFIG_* override applied unconditionally, not gated on canScope, to record the bumped gitlink');
|
||
assert.notStrictEqual(
|
||
gitOrThrow(['rev-parse', 'HEAD:sub'], { cwd: tmpDir }).trim(), before,
|
||
'the recorded gitlink must actually advance');
|
||
});
|
||
|
||
test('an --amend commit (canScope=false) still records a bumped submodule under diff.ignoreSubmodules=all', () => {
|
||
bumpedSubmodule();
|
||
gitOrThrow(['config', 'diff.ignoreSubmodules', 'all'], { cwd: tmpDir });
|
||
gitOrThrow(['add', '--', 'sub'], { cwd: tmpDir });
|
||
const before = gitOrThrow(['rev-parse', 'HEAD:sub'], { cwd: tmpDir }).trim();
|
||
|
||
const result = runGsdTools('commit "m" --amend', tmpDir);
|
||
const payload = (result.output && result.output.trim()) ? result.output : result.error;
|
||
const output = JSON.parse(payload);
|
||
|
||
assert.strictEqual(output.committed, true,
|
||
'--amend never hits the empty-diff refusal, so the now-unconditional GIT_CONFIG_* override must remain ' +
|
||
'a harmless no-op here');
|
||
assert.notStrictEqual(
|
||
gitOrThrow(['rev-parse', 'HEAD:sub'], { cwd: tmpDir }).trim(), before,
|
||
'the recorded gitlink must actually advance');
|
||
});
|
||
});
|
||
|
||
// #3859 follow-up: `cmdCommitToSubrepo` and `cmdPrSubrepo` carry the identical
|
||
// structurally-shaped `canScope*`-branched `git commit` call as `cmdCommit`
|
||
// above (see `COMMIT_TIMEOUT_MS`'s "three commit sites" comment in
|
||
// src/commands.cts) and were missing the same `diff.ignoreSubmodules=dirty`
|
||
// GIT_CONFIG_* override, applied unconditionally for the same reason.
|
||
describe('#3859 follow-up: commit-to-subrepo and pr-subrepo also need the diff.ignoreSubmodules override', () => {
|
||
const { createTempGitProject } = require('./helpers.cjs');
|
||
let rootDir;
|
||
let nestedSubmoduleSrc;
|
||
|
||
afterEach(() => {
|
||
if (rootDir) cleanup(rootDir);
|
||
if (nestedSubmoduleSrc) cleanup(nestedSubmoduleSrc);
|
||
rootDir = undefined;
|
||
nestedSubmoduleSrc = undefined;
|
||
});
|
||
|
||
// A submodule nested inside `repoDir` whose recorded gitlink is AHEAD of
|
||
// what `repoDir` has committed — same shape as `bumpedSubmodule()` above,
|
||
// scoped to an arbitrary sub-repo directory instead of the project root.
|
||
function bumpedSubmoduleIn(repoDir) {
|
||
const subSrc = path.join(repoDir, '..', path.basename(repoDir) + '-nested-sub');
|
||
nestedSubmoduleSrc = subSrc;
|
||
fs.mkdirSync(subSrc, { recursive: true });
|
||
gitOrThrow(['init', '-q', '.'], { cwd: subSrc });
|
||
gitOrThrow(['config', 'user.email', 't@t'], { cwd: subSrc });
|
||
gitOrThrow(['config', 'user.name', 't'], { cwd: subSrc });
|
||
fs.writeFileSync(path.join(subSrc, 'f.txt'), 'v1\n');
|
||
gitOrThrow(['add', 'f.txt'], { cwd: subSrc });
|
||
gitOrThrow(['commit', '-m', 'v1'], { cwd: subSrc });
|
||
|
||
gitOrThrow(['-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', subSrc, 'nested'], { cwd: repoDir });
|
||
gitOrThrow(['commit', '-m', 'add nested submodule'], { cwd: repoDir });
|
||
|
||
fs.writeFileSync(path.join(subSrc, 'f.txt'), 'v2\n');
|
||
gitOrThrow(['add', 'f.txt'], { cwd: subSrc });
|
||
gitOrThrow(['commit', '-m', 'v2'], { cwd: subSrc });
|
||
gitOrThrow(['-c', 'protocol.file.allow=always', 'submodule', 'update', '--remote', '--', 'nested'], { cwd: repoDir });
|
||
}
|
||
|
||
test('commit-to-subrepo records a bumped nested submodule under diff.ignoreSubmodules=all', () => {
|
||
rootDir = createTempGitProject();
|
||
fs.writeFileSync(
|
||
path.join(rootDir, '.planning', 'config.json'),
|
||
JSON.stringify({ planning: { sub_repos: ['backend'] } }, null, 2),
|
||
);
|
||
const subDir = path.join(rootDir, 'backend');
|
||
fs.mkdirSync(subDir, { recursive: true });
|
||
gitOrThrow(['init', '-q', '.'], { cwd: subDir });
|
||
gitOrThrow(['config', 'user.email', 't@t'], { cwd: subDir });
|
||
gitOrThrow(['config', 'user.name', 't'], { cwd: subDir });
|
||
fs.writeFileSync(path.join(subDir, 'seed.js'), '// seed\n');
|
||
gitOrThrow(['add', 'seed.js'], { cwd: subDir });
|
||
gitOrThrow(['commit', '-m', 'seed'], { cwd: subDir });
|
||
|
||
bumpedSubmoduleIn(subDir);
|
||
gitOrThrow(['config', 'diff.ignoreSubmodules', 'all'], { cwd: subDir });
|
||
const before = gitOrThrow(['rev-parse', 'HEAD:nested'], { cwd: subDir }).trim();
|
||
|
||
const res = runGsdTools(
|
||
['commit-to-subrepo', 'chore: bump nested submodule', '--files', 'backend/nested'],
|
||
rootDir,
|
||
);
|
||
assert.ok(res.success, `commit-to-subrepo failed: ${res.error}`);
|
||
const result = JSON.parse(res.output);
|
||
|
||
assert.strictEqual(result.repos.backend.committed, true,
|
||
`the gitlink moved and \`git commit -- nested\` records it on git 2.39.5 only with the ` +
|
||
`GIT_CONFIG_* override applied, got ${JSON.stringify(result.repos.backend)}`);
|
||
assert.notStrictEqual(result.repos.backend.reason, 'error');
|
||
assert.notStrictEqual(
|
||
gitOrThrow(['rev-parse', 'HEAD:nested'], { cwd: subDir }).trim(), before,
|
||
'the recorded gitlink must actually advance');
|
||
});
|
||
|
||
test('pr-subrepo records a bumped nested submodule under diff.ignoreSubmodules=all', () => {
|
||
rootDir = createTempGitProject();
|
||
fs.writeFileSync(
|
||
path.join(rootDir, '.planning', 'config.json'),
|
||
JSON.stringify({ planning: { sub_repos: ['backend'] } }, null, 2),
|
||
);
|
||
const subDir = path.join(rootDir, 'backend');
|
||
const bareDir = path.join(rootDir, '_bare-backend.git');
|
||
fs.mkdirSync(subDir, { recursive: true });
|
||
gitOrThrow(['init', '-q', '.'], { cwd: subDir });
|
||
gitOrThrow(['config', 'user.email', 't@t'], { cwd: subDir });
|
||
gitOrThrow(['config', 'user.name', 't'], { cwd: subDir });
|
||
fs.writeFileSync(path.join(subDir, 'seed.js'), '// seed\n');
|
||
gitOrThrow(['add', 'seed.js'], { cwd: subDir });
|
||
gitOrThrow(['commit', '-m', 'seed'], { cwd: subDir });
|
||
fs.mkdirSync(bareDir, { recursive: true });
|
||
gitOrThrow(['init', '--bare', '-q'], { cwd: bareDir });
|
||
gitOrThrow(['remote', 'add', 'origin', bareDir], { cwd: subDir });
|
||
const branch = gitOrThrow(['branch', '--show-current'], { cwd: subDir }).trim();
|
||
gitOrThrow(['push', 'origin', branch], { cwd: subDir });
|
||
|
||
bumpedSubmoduleIn(subDir);
|
||
gitOrThrow(['config', 'diff.ignoreSubmodules', 'all'], { cwd: subDir });
|
||
const before = gitOrThrow(['rev-parse', 'HEAD:nested'], { cwd: subDir }).trim();
|
||
|
||
const res = runGsdTools(
|
||
['query', 'pr-subrepo', 'fix(backend): bump nested submodule',
|
||
'--repo', 'backend', '--branch', 'fix-3859-nested-submodule-pr'],
|
||
rootDir,
|
||
);
|
||
assert.ok(res.success, `pr-subrepo failed: ${res.error}`);
|
||
const result = JSON.parse(res.output);
|
||
|
||
assert.strictEqual(result.committed, true,
|
||
`the gitlink moved and \`git commit -- nested\` records it on git 2.39.5 only with the ` +
|
||
`GIT_CONFIG_* override applied, got ${JSON.stringify(result)}`);
|
||
assert.notStrictEqual(
|
||
gitOrThrow(['rev-parse', 'HEAD:nested'], { cwd: subDir }).trim(), before,
|
||
'the recorded gitlink must actually advance');
|
||
});
|
||
});
|
||
|
||
describe('#2279: map-codebase date stamp instructions overwrite existing dates', () => {
|
||
const REPO_ROOT = path.join(__dirname, '..');
|
||
|
||
test('codebase-mapper agent says to SET date stamps, overwriting existing values', () => {
|
||
const content = fs.readFileSync(
|
||
path.join(REPO_ROOT, 'agents', 'gsd-codebase-mapper.md'), 'utf-8'
|
||
);
|
||
assert.match(content, /overwriting whatever date is already there/i,
|
||
'must instruct the agent to SET date stamps unconditionally, not just replace [YYYY-MM-DD] placeholders');
|
||
});
|
||
|
||
test('map-codebase workflow spawn prompts say to SET date stamps, not replace placeholders', () => {
|
||
const content = fs.readFileSync(
|
||
path.join(REPO_ROOT, 'gsd-core', 'workflows', 'map-codebase.md'), 'utf-8'
|
||
);
|
||
// eslint-disable-next-line local/no-unbounded-quantifier -- parses maintainer-authored workflow markdown, bounded prose, not adversarial input
|
||
const stampLines = content.match(/Set all date stamps[^\r\n]*/g) || [];
|
||
assert.ok(stampLines.length >= 4,
|
||
`must have ≥4 "Set all date stamps" instructions (4 spawn prompts + 1 sequential); got ${stampLines.length}`);
|
||
});
|
||
|
||
test('map-codebase sequential path says to SET date stamps overwriting existing dates', () => {
|
||
const content = fs.readFileSync(
|
||
path.join(REPO_ROOT, 'gsd-core', 'workflows', 'map-codebase.md'), 'utf-8'
|
||
);
|
||
const idx = content.indexOf('overwriting any existing date');
|
||
assert.notEqual(idx, -1,
|
||
'workflow must instruct agents to overwrite existing dates, not just replace [YYYY-MM-DD] placeholders');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// DEFECT.GENERATIVE-FIX parity guard: HOST_COMMAND_ROUTERS vs TOP_LEVEL_USAGE
|
||
// vs SKIP_ROOT_RESOLUTION (#2928 S9)
|
||
//
|
||
// gsd-tools.cjs's query-command surface is declared across THREE
|
||
// independently hand-maintained sites in the same file with no prior parity
|
||
// gate between them: the dispatch table (HOST_COMMAND_ROUTERS), the
|
||
// `--help` command list (TOP_LEVEL_USAGE), and the project-root-skip list
|
||
// (SKIP_ROOT_RESOLUTION). Nothing previously caught a command being wired
|
||
// into the dispatch table but omitted from the help string (or vice versa)
|
||
// — exactly the generative-fix-divergence shape CLAUDE.md's
|
||
// "Generative Fix Divergence" anti-pattern names ("add a parity assertion
|
||
// test that fails if the shared constants/arrays/parsers diverge").
|
||
//
|
||
// This is a STRUCTURAL comparison against the exported constants, not a
|
||
// source-text/string-match test, so it stays correct across reformatting
|
||
// and is immune to the no-source-grep concern.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
describe('gsd-tools.cjs dispatch/help/skip-list parity (DEFECT.GENERATIVE-FIX, #2928 S9)', () => {
|
||
const { HOST_COMMAND_ROUTERS, TOP_LEVEL_USAGE, skipsRootResolution } = require('../gsd-core/bin/gsd-tools.cjs');
|
||
|
||
// Parse the "Commands: a, b, c\n\nGlobal flags:" line out of the usage
|
||
// string rather than hardcoding a copy of it here — this test must fail
|
||
// when the two sites diverge, not silently pass because it re-embeds its
|
||
// own stale expectation.
|
||
function parseHelpCommandNames(usage) {
|
||
const match = usage.match(/Commands: ([\s\S]*?)\n\nGlobal flags:/);
|
||
assert.ok(match, 'TOP_LEVEL_USAGE must contain a "Commands: ...\\n\\nGlobal flags:" block');
|
||
return match[1]
|
||
.split(',')
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
test('every HOST_COMMAND_ROUTERS entry is listed in the --help command string', () => {
|
||
const helpNames = new Set(parseHelpCommandNames(TOP_LEVEL_USAGE));
|
||
const missing = Object.keys(HOST_COMMAND_ROUTERS).filter((name) => !helpNames.has(name));
|
||
assert.deepEqual(
|
||
missing,
|
||
[],
|
||
`command(s) registered in HOST_COMMAND_ROUTERS but missing from TOP_LEVEL_USAGE's ` +
|
||
`"Commands:" list: ${missing.join(', ')}`,
|
||
);
|
||
});
|
||
|
||
test('context-predicates is registered in all three hand-maintained sites', () => {
|
||
// Concrete regression pin for the command this parity test was added
|
||
// alongside (#2928 S9) — a generic diff-based assertion alone would not
|
||
// fail if ALL THREE sites were missing an entry simultaneously.
|
||
assert.ok(
|
||
Object.prototype.hasOwnProperty.call(HOST_COMMAND_ROUTERS, 'context-predicates'),
|
||
'context-predicates must be registered in HOST_COMMAND_ROUTERS',
|
||
);
|
||
assert.ok(
|
||
parseHelpCommandNames(TOP_LEVEL_USAGE).includes('context-predicates'),
|
||
'context-predicates must be listed in TOP_LEVEL_USAGE',
|
||
);
|
||
assert.ok(
|
||
skipsRootResolution('context-predicates'),
|
||
'context-predicates must be in SKIP_ROOT_RESOLUTION (it is a pure repo-root CONTEXT.md ' +
|
||
'read, like prompt-budget, and must work with no .planning/ directory present)',
|
||
);
|
||
});
|
||
|
||
test('SKIP_ROOT_RESOLUTION is not exported as a mutable live Set (DEFECT.MUTABLE-EXPORTED-SET, #2928)', () => {
|
||
const gsdTools = require('../gsd-core/bin/gsd-tools.cjs');
|
||
assert.equal(
|
||
gsdTools.SKIP_ROOT_RESOLUTION,
|
||
undefined,
|
||
'the live Set must not be exported directly — only the read-only skipsRootResolution() predicate',
|
||
);
|
||
assert.equal(typeof gsdTools.skipsRootResolution, 'function');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// resolveMainWorktreeCwd — #3050: gsd-tools must surface (not silently
|
||
// swallow) a git_timed_out worktree-root resolution, since writing planning
|
||
// artifacts to the wrong tree is the exact fail-open the issue names.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
describe('gsd-tools.cjs resolveMainWorktreeCwd (#3050)', () => {
|
||
const { resolveMainWorktreeCwd } = require('../gsd-core/bin/gsd-tools.cjs');
|
||
|
||
test('emits a WARNING to stderr and still resolves the fallback root on git_timed_out', () => {
|
||
const warnings = [];
|
||
const resolved = resolveMainWorktreeCwd('/repo/wt', {
|
||
existsSync: () => false,
|
||
resolveWorktreeRoot: () => ({ root: '/repo/wt', reason: 'git_timed_out' }),
|
||
writeWarning: (msg) => warnings.push(msg),
|
||
});
|
||
assert.equal(resolved, '/repo/wt');
|
||
assert.equal(warnings.length, 1, 'must emit exactly one warning on git_timed_out');
|
||
assert.match(warnings[0], /git timed out/i);
|
||
assert.match(warnings[0], /wrong tree/i);
|
||
});
|
||
|
||
test('does NOT warn on a benign reason (linked_worktree)', () => {
|
||
const warnings = [];
|
||
const resolved = resolveMainWorktreeCwd('/repo/wt', {
|
||
existsSync: () => false,
|
||
resolveWorktreeRoot: () => ({ root: '/repo', reason: 'linked_worktree' }),
|
||
writeWarning: (msg) => warnings.push(msg),
|
||
});
|
||
assert.equal(resolved, '/repo');
|
||
assert.deepEqual(warnings, [], 'must not warn for a benign, definitive resolution');
|
||
});
|
||
|
||
test('does NOT warn on a benign reason (not_git_repo)', () => {
|
||
const warnings = [];
|
||
const resolved = resolveMainWorktreeCwd('/repo/wt', {
|
||
existsSync: () => false,
|
||
resolveWorktreeRoot: () => ({ root: '/repo/wt', reason: 'not_git_repo' }),
|
||
writeWarning: (msg) => warnings.push(msg),
|
||
});
|
||
assert.equal(resolved, '/repo/wt');
|
||
assert.deepEqual(warnings, []);
|
||
});
|
||
|
||
test('short-circuits (never calls resolveWorktreeRoot) when .planning already exists in cwd', () => {
|
||
const resolved = resolveMainWorktreeCwd('/repo/wt', {
|
||
existsSync: () => true,
|
||
resolveWorktreeRoot: () => { throw new Error('must not be called when .planning exists locally'); },
|
||
writeWarning: () => { throw new Error('must not warn when short-circuited'); },
|
||
});
|
||
assert.equal(resolved, '/repo/wt');
|
||
});
|
||
});
|