* feat(#2790): add read-only planning.inspect schema-v1 snapshot query Adds a read-only query emitting a schema-versioned JSON projection of .planning/ so downstream harness UIs can consume planning state without parsing GSD's Markdown a second time. Composed strictly from the ADR-3180 section 7 owners plus parsePlanDocument, parseRequirements and parseUatItems; markdown structure is read through the Markdown Sectionizer and Markdown Table Model seams. It declares its own flat external schema rather than serializing PlanningSnapshot, which is the diagnostic-rule subject and still growing. Extracts plan-document parsing out of cmdPhasePlanIndex into a shared leaf module so phase.plan-index and planning.inspect cannot drift, including the plan-id derivation both surfaces report. Also fixes parseRequirements dropping the separator delimiter used by the shipped requirements template, surfaced while wiring the requirement rows. * fix(#2790): close spec gaps and a raw-text test assertion found in review Review findings from the standards, spec and security passes: - phases[] rows carry goal and dependencies, the two per-phase elements the issue Summary names that had no corresponding field. Goal is bounded to the section's leading prose so the Depends-on line, the Plans checklist and the wave annotations are not duplicated into it. - requirement rows carry their own diagnostic codes, so a consumer no longer has to string-parse the global diagnostics subject to correlate. - roadmap_acceptance.checkbox is looked up through the phase-id key owners. It was compared raw against the on-disk directory name, so it read null for every real-world slugged phase directory and the evidence channel was inert. - the hostile-input test asserts the structured payload instead of matching the raw stdout string. The absence proof over raw stdout is kept deliberately. * fix(#2790): register planning in the runtime usage list and repair fixtures Remote runner reported 9 failures on 9b3f9aa. Two root causes, both fixed: - gsd-tools.cjs registered the planning family in HOST_COMMAND_ROUTERS but never added it to TOP_LEVEL_USAGE's Commands list. Those are two surfaces a parity test guards, and the top-of-file block comment is not the runtime help string. A real wiring gap that every local gate and three review passes missed. - the new suite's fixtures could not produce a resolvable phase set. STATE.md frontmatter omitted the milestone field, which ADR-3180 7.2 rule 1 makes the primary milestone selector, so the phase set scoped unscoped and every percentage was correctly withheld. Separately declarePhase returned a path without creating the directory, so a phase declared but never written to left phases empty. Both reproduced against the built module before fixing. No assertion was weakened. The withholding path is still exercised and still returns null when the roadmap is absent. * chore(#2790): backfill changeset pr number * test(#2790): cover every enumerated matrix row and contain a symlink escape Reverses a silent deferral. An earlier revision left 23 of the 78 enumerated matrix rows unimplemented and 7 more as one-off manual checks, with a paragraph in the artifact and the PR body describing the gap. CLAUDE.md is explicit that such a note is not a fix and is not surfacing. The rows are implemented instead and the manual-evidence bucket is gone: 49 test cases become 88, covering all 78. Writing the symlink row proved a real leak: a *-PLAN.md symlinked outside .planning/ had its content emitted into the payload, confirmed via a direct call and the spawned CLI. readDocument now resolves target and planning root with realpathSync and rejects an escape, returning the ordinary unreadable-document shape. Tested both ways, because a containment check that over-rejects is its own defect: an escaping symlink leaks nothing and degrades that plan alone, while a legitimately relocated .planning/ symlink stays fully readable. The three new modules are registered in the mutation COVERED registry, which had been reporting has_work false and skipping the Stryker gate entirely. Provisional non-binding floors so the shards run and report; raised to the measured value before merge, since the registry forbids calibrating from a local run. * fix(#2790): satisfy the mutation ratchet contract and scope the 1MB test Remote runner reported 16 failures on 8c451ed. Two causes. The COVERED registry has a paired contract the earlier commit violated: every module needs a matching RATCHET_BASELINE entry, and minScore must be between 50 and 100 with minScore === baseline. The provisional floor of 1 was illegal on both counts. All three modules now sit at 50 — the registry's own enforced minimum — with matching baselines. The score cannot be measured locally: the shard runs node --test, which this repo hard-blocks, so CI is the only source. Floors are raised to the measured value once this PR's shards report; a shard below 50 means the tests need strengthening, since the floor cannot go lower. The 1MB test was measuring the test harness rather than the product. The command handles the oversized payload correctly by spilling to a tmpfile and resolving it back, but the resolved stdout then exceeds runGsdTools' maxBuffer and the helper reports ENOBUFS. It now uses --pick so stdout stays one byte while the full 1MB document is still read and parsed end to end. * fix(#2790): wire containment across every document read this command drives An isolated security review of the containment control found the boundary logic sound but not comprehensively wired: two content reads reached the filesystem without it. An escaped phase DIRECTORY could enumerate external filenames into the file fields and diagnostic subjects. Both enumeration sites now containment-check the directory before reading. Worth recording that the leak was already prevented one layer earlier than the review claimed: Dirent#isDirectory() reports false for a directory symlink, so such a directory never becomes a phase row at all. The guard is defense-in-depth for a direct caller and for platforms where a reparse point reports as a directory. A *-VERIFICATION.md symlinked outside the root leaked one frontmatter value verbatim, because readVerificationStatus does its own read and copies an unrecognized status into the payload's next_action. Closed from the consumer side through that function's existing fs injection seam, so src/verification.cts keeps its signature and its other callers are untouched. The reviewer additionally rated a forged status: passed as an integrity bypass. It is not: anyone able to plant the symlink can plant a real VERIFICATION.md saying the same thing. The incremental risk is confidentiality, which is what these fixes close. src/plan-scan.cts is deliberately unchanged: isPlanSuperseded reads symlink-followed content but yields only a derived boolean, no document text. * test(#2790): give the mutation shards an in-process surface Two Stryker shards were CANCELLED at the 15-minute cap, not failed on score. CI log: 640 mutants instrumented, and the dry run reported 'Ran 1 tests in 20 seconds' because the shards pointed at the integration suite, where nearly every case spawns a gsd-tools subprocess and Stryker's command runner treats the whole test-runner invocation as a single test. 640 x 20s cannot finish in 15 minutes; at the kill it was 27/640 with an ETA over an hour. Every other COVERED module points at a property or unit file, and the workflow's own paths filter lists exactly those two patterns. In-process is the intended mutation surface; the shards were pointed at the wrong shape of test. Adds tests/planning-inspect.unit.test.cjs — 39 cases in 10 describes that spawn nothing and call the built modules directly. plan-document and the router need no filesystem at all, one being a pure content-to-object parser and the other taking an injected mock. The three shards now point here. The 91-case integration suite is untouched and still runs in the normal test job. * chore(#2790): ratchet mutation floors to the measured CI scores CI run 32392791843 measured all three shards, which is the only source the registry accepts — local runs count timeouts as kills and inflate badly. planning-command-router 95.65 -> floor 94 plan-document 76.58 -> floor 75 planning-inspect 57.03 -> floor 56 Applied the registry's own rule, floor(score) - 1, and updated RATCHET_BASELINE to match, since the ratchet test enforces equality. planning-inspect sits well below the file's target of 80 and is the obvious ratchet candidate as its tests improve. planning-command-router already exceeds the target. The placeholder comment about floors pending measurement is removed rather than left standing as a false statement. --------- Co-authored-by: sim <sim@local>
779 lines
34 KiB
JavaScript
779 lines
34 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* FAST, IN-PROCESS mutation-testing surface for `planning-inspect.cjs`,
|
|
* `plan-document.cjs`, and `planning-command-router.cjs` (#2790).
|
|
*
|
|
* Root cause this file exists to fix: `tests/planning-inspect.test.cjs` is
|
|
* INTEGRATION-shaped — it spawns a `gsd-tools` child process per case via
|
|
* `runGsdTools`. Stryker's command runner treats a `node --test <file>`
|
|
* invocation as ONE test costing whatever the slowest case costs (measured in
|
|
* CI: ~20s), and re-runs that whole file once per mutant. 640 mutants x 20s
|
|
* cannot finish inside a 15-minute shard cap — CI evidence: two shards were
|
|
* CANCELLED at 4% (27/640) after ~3 elapsed minutes. This file is the
|
|
* dedicated, spawn-free mutation surface `scripts/mutation-matrix.cjs`
|
|
* repoints those three modules' shards at; the integration suite keeps
|
|
* running unmodified in the normal (non-mutation) test job.
|
|
*
|
|
* NEVER spawn a child process here — no `runGsdTools`, `spawnSync`,
|
|
* `execFileSync`, or CLI invocation of any kind. Every case below requires
|
|
* the BUILT `.cjs` artifacts directly and calls their exports in-process.
|
|
* `plan-document.cjs` needs no filesystem at all (pure `(content) -> object`
|
|
* parser); `planning-command-router.cjs` is driven with a recording mock and
|
|
* needs no filesystem; `planning-inspect.cjs` needs small `.planning/`
|
|
* fixtures on disk (cheap disk I/O, not the cost this file exists to avoid)
|
|
* under `os.tmpdir()`.
|
|
*
|
|
* Every fixture shape and every asserted value below was verified by
|
|
* requiring the built libs directly and inspecting the real returned object
|
|
* — never guessed from reading the source alone (CLAUDE.md "verify
|
|
* assertions by executing, not retyping").
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const {
|
|
parsePlanDocument,
|
|
planIdFromFile,
|
|
TASK_KIND,
|
|
} = require('../gsd-core/bin/lib/plan-document.cjs');
|
|
|
|
const {
|
|
routePlanningCommand,
|
|
PLANNING_SUBCOMMANDS,
|
|
} = require('../gsd-core/bin/lib/planning-command-router.cjs');
|
|
|
|
const planningInspectLib = require('../gsd-core/bin/lib/planning-inspect.cjs');
|
|
const {
|
|
buildPlanningInspect,
|
|
INSPECT_DIAGNOSTIC,
|
|
TASK_STATUS,
|
|
PROVENANCE,
|
|
AGREEMENT,
|
|
} = planningInspectLib;
|
|
|
|
// ─── Shared fs fixture helpers (planning-inspect only) ────────────────────────
|
|
|
|
function writeAbs(fullPath, content) {
|
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
fs.writeFileSync(fullPath, content);
|
|
}
|
|
|
|
function writeFile(cwd, relPath, content) {
|
|
writeAbs(path.join(cwd, relPath), content);
|
|
}
|
|
|
|
function mkCwd() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'planning-inspect-unit-'));
|
|
}
|
|
|
|
function frontmatterDoc(fmLines, bodyLines) {
|
|
return ['---', ...fmLines, '---', '', ...bodyLines].join('\n');
|
|
}
|
|
|
|
function phaseDirOf(cwd, token) {
|
|
return path.join(cwd, '.planning', 'phases', token);
|
|
}
|
|
|
|
function diagnosticCodes(payload) {
|
|
return payload.diagnostics.map((d) => d.code);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// plan-document.cjs — pure, in-memory parser
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe('plan-document — objective extraction', () => {
|
|
test('extracts the first line after an <objective> tag', () => {
|
|
const parsed = parsePlanDocument(['<objective>', 'Ship the thing', '</objective>'].join('\n'));
|
|
assert.strictEqual(parsed.objective, 'Ship the thing');
|
|
});
|
|
|
|
test('falls back to frontmatter objective when no <objective> tag is present', () => {
|
|
const parsed = parsePlanDocument(frontmatterDoc(['objective: From frontmatter'], ['no objective tag here']));
|
|
assert.strictEqual(parsed.objective, 'From frontmatter');
|
|
});
|
|
|
|
test('is null when neither the tag nor frontmatter carries an objective', () => {
|
|
const parsed = parsePlanDocument('no objective anywhere');
|
|
assert.strictEqual(parsed.objective, null);
|
|
});
|
|
|
|
test('prefers the <objective> tag over frontmatter when both are present', () => {
|
|
const parsed = parsePlanDocument(frontmatterDoc(['objective: From frontmatter'], ['<objective>', 'From tag', '</objective>']));
|
|
assert.strictEqual(parsed.objective, 'From tag');
|
|
});
|
|
});
|
|
|
|
describe('plan-document — task grammar', () => {
|
|
test('parses one <task> block with name/files/acceptance/done', () => {
|
|
const parsed = parsePlanDocument([
|
|
'<tasks>',
|
|
'<task type="auto">',
|
|
' <name> Task One </name>',
|
|
' <files>a.ts, b.ts</files>',
|
|
' <acceptance_criteria>',
|
|
'- criterion one',
|
|
'* criterion two',
|
|
' </acceptance_criteria>',
|
|
' <done> All done </done>',
|
|
'</task>',
|
|
'</tasks>',
|
|
].join('\n'));
|
|
assert.strictEqual(parsed.tasks.length, 1);
|
|
const [task] = parsed.tasks;
|
|
assert.strictEqual(task.index, 1);
|
|
assert.strictEqual(task.kind, TASK_KIND.AUTO);
|
|
assert.strictEqual(task.type, 'auto');
|
|
assert.strictEqual(task.name, 'Task One');
|
|
assert.deepStrictEqual(task.plannedFiles, ['a.ts', 'b.ts']);
|
|
assert.deepStrictEqual(task.acceptanceCriteria, ['criterion one', 'criterion two']);
|
|
assert.strictEqual(task.done, 'All done');
|
|
assert.strictEqual(parsed.taskCount, parsed.tasks.length);
|
|
});
|
|
|
|
test('splits <files> on newlines as well as commas', () => {
|
|
const parsed = parsePlanDocument([
|
|
'<task type="auto">',
|
|
' <files>',
|
|
'a.ts',
|
|
'b.ts',
|
|
' </files>',
|
|
'</task>',
|
|
].join('\n'));
|
|
assert.deepStrictEqual(parsed.tasks[0].plannedFiles, ['a.ts', 'b.ts']);
|
|
});
|
|
|
|
test('falls back to ## Task N headings when no <task> blocks exist', () => {
|
|
const parsed = parsePlanDocument([
|
|
'## Task 1: Do the thing',
|
|
'some body text',
|
|
'## Task 2: Do another thing',
|
|
].join('\n'));
|
|
assert.strictEqual(parsed.tasks.length, 2);
|
|
assert.strictEqual(parsed.taskCount, 2);
|
|
assert.strictEqual(parsed.tasks[0].name, 'Task 1: Do the thing');
|
|
assert.strictEqual(parsed.tasks[0].type, null);
|
|
assert.deepStrictEqual(parsed.tasks[0].plannedFiles, []);
|
|
assert.strictEqual(parsed.tasks[1].index, 2);
|
|
assert.strictEqual(parsed.tasks[1].name, 'Task 2: Do another thing');
|
|
});
|
|
|
|
test('prefers <task> blocks over ## Task N headings when both are present', () => {
|
|
const parsed = parsePlanDocument([
|
|
'## Task 1: Legacy heading',
|
|
'<task type="auto">',
|
|
' <name>Real task</name>',
|
|
'</task>',
|
|
].join('\n'));
|
|
assert.strictEqual(parsed.tasks.length, 1);
|
|
assert.strictEqual(parsed.tasks[0].name, 'Real task');
|
|
});
|
|
|
|
test('a checkpoint task carries no name/files/acceptance/done, even if present in the tag', () => {
|
|
const parsed = parsePlanDocument([
|
|
'<task type="checkpoint:manual">',
|
|
' <decision>Ship it?</decision>',
|
|
' <name>Should be ignored</name>',
|
|
'</task>',
|
|
].join('\n'));
|
|
const [task] = parsed.tasks;
|
|
assert.strictEqual(task.kind, TASK_KIND.CHECKPOINT);
|
|
assert.strictEqual(task.type, 'checkpoint:manual');
|
|
assert.strictEqual(task.name, null);
|
|
assert.deepStrictEqual(task.plannedFiles, []);
|
|
assert.deepStrictEqual(task.acceptanceCriteria, []);
|
|
assert.strictEqual(task.done, null);
|
|
});
|
|
|
|
test('checkpoint type detection is case-insensitive and prefix-only', () => {
|
|
const parsed = parsePlanDocument('<task type="CHECKPOINT:Manual"></task>');
|
|
assert.strictEqual(parsed.tasks[0].kind, TASK_KIND.CHECKPOINT);
|
|
});
|
|
|
|
test('an unclosed <task> block is bounded by the next opening tag, never swallowing siblings', () => {
|
|
const parsed = parsePlanDocument([
|
|
'<task type="auto">',
|
|
' <name>First (unclosed)</name>',
|
|
'<task type="auto">',
|
|
' <name>Second</name>',
|
|
'</task>',
|
|
].join('\n'));
|
|
assert.strictEqual(parsed.tasks.length, 2);
|
|
assert.strictEqual(parsed.taskCount, 2);
|
|
assert.strictEqual(parsed.tasks[0].name, 'First (unclosed)');
|
|
assert.strictEqual(parsed.tasks[1].name, 'Second');
|
|
});
|
|
|
|
test('an unclosed final <task> block runs to end of document', () => {
|
|
const parsed = parsePlanDocument(['<task type="auto">', ' <name>Only task</name>'].join('\n'));
|
|
assert.strictEqual(parsed.tasks.length, 1);
|
|
assert.strictEqual(parsed.tasks[0].name, 'Only task');
|
|
});
|
|
|
|
test('taskCount always equals tasks.length', () => {
|
|
const noTasks = parsePlanDocument('no tasks here at all');
|
|
assert.strictEqual(noTasks.taskCount, 0);
|
|
assert.deepStrictEqual(noTasks.tasks, []);
|
|
});
|
|
});
|
|
|
|
describe('plan-document — frontmatter scheduling metadata', () => {
|
|
test('invalid wave, string depends_on, autonomous false, agent_hint set, scalar files_modified', () => {
|
|
const parsed = parsePlanDocument(frontmatterDoc([
|
|
'wave: not-a-number',
|
|
'depends_on: 1-01-PLAN.md',
|
|
'autonomous: false',
|
|
'agent_hint: backend-specialist',
|
|
'files_modified: src/single.ts',
|
|
], ['body']));
|
|
assert.strictEqual(parsed.declaredWave, null);
|
|
assert.deepStrictEqual(parsed.dependsOn, ['1-01-PLAN.md']);
|
|
assert.strictEqual(parsed.autonomous, false);
|
|
assert.strictEqual(parsed.agentHint, 'backend-specialist');
|
|
assert.deepStrictEqual(parsed.filesModified, ['src/single.ts']);
|
|
});
|
|
|
|
test('valid wave, array depends_on, empty agent_hint, files-modified (hyphen) array', () => {
|
|
const parsed = parsePlanDocument(frontmatterDoc([
|
|
'wave: 3',
|
|
'depends_on: [1-01-PLAN.md, 1-02-PLAN.md]',
|
|
'agent_hint: ""',
|
|
'files-modified: [a.ts, b.ts]',
|
|
], ['body']));
|
|
assert.strictEqual(parsed.declaredWave, 3);
|
|
assert.deepStrictEqual(parsed.dependsOn, ['1-01-PLAN.md', '1-02-PLAN.md']);
|
|
assert.strictEqual(parsed.agentHint, null);
|
|
assert.deepStrictEqual(parsed.filesModified, ['a.ts', 'b.ts']);
|
|
});
|
|
|
|
test('no frontmatter at all defaults wave/dependsOn/agentHint/filesModified and autonomous true', () => {
|
|
const parsed = parsePlanDocument('plain body, no frontmatter');
|
|
assert.strictEqual(parsed.declaredWave, null);
|
|
assert.deepStrictEqual(parsed.dependsOn, []);
|
|
assert.strictEqual(parsed.autonomous, true);
|
|
assert.strictEqual(parsed.agentHint, null);
|
|
assert.deepStrictEqual(parsed.filesModified, []);
|
|
});
|
|
|
|
test('empty depends_on string is dropped, not turned into a single blank entry', () => {
|
|
const parsed = parsePlanDocument(frontmatterDoc(['depends_on: ""'], ['body']));
|
|
assert.deepStrictEqual(parsed.dependsOn, []);
|
|
});
|
|
|
|
test('autonomous absent defaults to true', () => {
|
|
const parsed = parsePlanDocument(frontmatterDoc(['wave: 1'], ['body']));
|
|
assert.strictEqual(parsed.autonomous, true);
|
|
});
|
|
});
|
|
|
|
describe('plan-document — planIdFromFile / TASK_KIND', () => {
|
|
test('strips the -PLAN.md suffix from a root-form plan file', () => {
|
|
assert.strictEqual(planIdFromFile('1-01-PLAN.md'), '1-01');
|
|
});
|
|
|
|
test('strips a bare PLAN.md to an empty id', () => {
|
|
assert.strictEqual(planIdFromFile('PLAN.md'), '');
|
|
});
|
|
|
|
test('a nested numbered plan file (plans/PLAN-01-foo.md) is left unchanged', () => {
|
|
// Neither the `-PLAN.md` nor bare `PLAN.md` suffix matches this shape —
|
|
// characterised, byte-for-behaviour-preserved limit (see module doc).
|
|
assert.strictEqual(planIdFromFile('plans/PLAN-01-foo.md'), 'plans/PLAN-01-foo.md');
|
|
});
|
|
|
|
test('a nested bare plan file (plans/PLAN.md) strips to its directory prefix', () => {
|
|
assert.strictEqual(planIdFromFile('plans/PLAN.md'), 'plans/');
|
|
});
|
|
|
|
test('TASK_KIND is the frozen two-member vocabulary', () => {
|
|
assert.deepStrictEqual(TASK_KIND, { AUTO: 'auto', CHECKPOINT: 'checkpoint' });
|
|
assert.ok(Object.isFrozen(TASK_KIND));
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// planning-command-router.cjs — pure dispatch, recording mocks, no fs
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe('planning-command-router', () => {
|
|
function mockError() {
|
|
const calls = [];
|
|
const fn = (message, reason) => calls.push({ message, reason });
|
|
fn.calls = calls;
|
|
return fn;
|
|
}
|
|
|
|
function mockInspect() {
|
|
const calls = [];
|
|
return {
|
|
calls,
|
|
cmdPlanningInspect(cwd, raw) {
|
|
calls.push({ cwd, raw });
|
|
},
|
|
};
|
|
}
|
|
|
|
test('PLANNING_SUBCOMMANDS is exactly ["inspect"]', () => {
|
|
assert.deepStrictEqual(PLANNING_SUBCOMMANDS, ['inspect']);
|
|
});
|
|
|
|
test('dispatches "planning inspect" and forwards cwd/raw verbatim', () => {
|
|
const error = mockError();
|
|
const mod = mockInspect();
|
|
routePlanningCommand({ args: ['planning', 'inspect'], cwd: '/some/cwd', raw: true, error, _planningInspect: mod });
|
|
assert.deepStrictEqual(error.calls, []);
|
|
assert.deepStrictEqual(mod.calls, [{ cwd: '/some/cwd', raw: true }]);
|
|
});
|
|
|
|
test('forwards a falsy raw and a different cwd verbatim (not defaulted)', () => {
|
|
const error = mockError();
|
|
const mod = mockInspect();
|
|
routePlanningCommand({ args: ['planning', 'inspect'], cwd: '/other', raw: false, error, _planningInspect: mod });
|
|
assert.deepStrictEqual(mod.calls, [{ cwd: '/other', raw: false }]);
|
|
});
|
|
|
|
test('a missing subcommand yields sdk_unknown_command and never calls the mock', () => {
|
|
const error = mockError();
|
|
const mod = mockInspect();
|
|
routePlanningCommand({ args: ['planning'], cwd: '/x', raw: false, error, _planningInspect: mod });
|
|
assert.strictEqual(error.calls.length, 1);
|
|
assert.strictEqual(error.calls[0].reason, 'sdk_unknown_command');
|
|
assert.strictEqual(error.calls[0].message, 'Unknown planning subcommand. Available: inspect');
|
|
assert.deepStrictEqual(mod.calls, []);
|
|
});
|
|
|
|
test('an unknown subcommand yields sdk_unknown_command and never calls the mock', () => {
|
|
const error = mockError();
|
|
const mod = mockInspect();
|
|
routePlanningCommand({ args: ['planning', 'bogus'], cwd: '/x', raw: false, error, _planningInspect: mod });
|
|
assert.strictEqual(error.calls.length, 1);
|
|
assert.strictEqual(error.calls[0].reason, 'sdk_unknown_command');
|
|
assert.deepStrictEqual(mod.calls, []);
|
|
});
|
|
|
|
test('a stray positional argument is a usage error naming the offender, never dispatched', () => {
|
|
const error = mockError();
|
|
const mod = mockInspect();
|
|
routePlanningCommand({ args: ['planning', 'inspect', 'extra'], cwd: '/x', raw: false, error, _planningInspect: mod });
|
|
assert.strictEqual(error.calls.length, 1);
|
|
assert.strictEqual(error.calls[0].reason, 'usage');
|
|
assert.strictEqual(
|
|
error.calls[0].message,
|
|
'planning inspect takes no arguments; got positional argument: extra. Usage: gsd-tools query planning inspect',
|
|
);
|
|
assert.deepStrictEqual(mod.calls, []);
|
|
});
|
|
|
|
test('an unknown flag is a usage error naming it as a flag, never dispatched', () => {
|
|
const error = mockError();
|
|
const mod = mockInspect();
|
|
routePlanningCommand({ args: ['planning', 'inspect', '--nope'], cwd: '/x', raw: false, error, _planningInspect: mod });
|
|
assert.strictEqual(error.calls.length, 1);
|
|
assert.strictEqual(error.calls[0].reason, 'usage');
|
|
assert.strictEqual(
|
|
error.calls[0].message,
|
|
'planning inspect takes no arguments; got flag: --nope. Usage: gsd-tools query planning inspect',
|
|
);
|
|
assert.deepStrictEqual(mod.calls, []);
|
|
});
|
|
|
|
test('defaults to the real planning-inspect module when no mock is injected', (t) => {
|
|
// No fixtures — buildPlanningInspect degrades gracefully on an absent
|
|
// .planning/ dir, so this proves the `mod ?? planningInspect` fallback
|
|
// wiring without spawning anything.
|
|
const cwd = mkCwd();
|
|
t.after(() => cleanup(cwd));
|
|
const error = mockError();
|
|
routePlanningCommand({ args: ['planning', 'inspect'], cwd, raw: true, error });
|
|
assert.deepStrictEqual(error.calls, []);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// planning-inspect.cjs — small on-disk fixtures, in-process buildPlanningInspect
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe('planning-inspect — planning root absent', () => {
|
|
test('degrades every section to a non-answer with PLANNING_ROOT_ABSENT', (t) => {
|
|
const cwd = mkCwd();
|
|
t.after(() => cleanup(cwd));
|
|
|
|
const result = buildPlanningInspect(cwd);
|
|
|
|
assert.strictEqual(result.schema_version, 1);
|
|
assert.strictEqual(result.generated_from.planning_root, null);
|
|
assert.deepStrictEqual(result.phases, []);
|
|
assert.deepStrictEqual(result.orphan_phase_dirs, []);
|
|
assert.deepStrictEqual(result.requirements, []);
|
|
assert.deepStrictEqual(result.progress.accepted_phases, { completed: 0, total: 0, percent: null, scope: 'unreadable' });
|
|
assert.deepStrictEqual(result.progress.completed_plans, { completed: 0, total: 0, percent: null, scope: 'unreadable' });
|
|
assert.strictEqual(result.milestone.scope, 'unreadable');
|
|
assert.deepStrictEqual(diagnosticCodes(result), [
|
|
INSPECT_DIAGNOSTIC.PLANNING_ROOT_ABSENT,
|
|
INSPECT_DIAGNOSTIC.ROADMAP_UNSCOPED,
|
|
INSPECT_DIAGNOSTIC.REQUIREMENTS_ABSENT,
|
|
INSPECT_DIAGNOSTIC.PERCENT_WITHHELD,
|
|
INSPECT_DIAGNOSTIC.PERCENT_WITHHELD,
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('planning-inspect — healthy two-phase project', () => {
|
|
function buildHealthy(cwd) {
|
|
writeFile(cwd, '.planning/STATE.md', frontmatterDoc(
|
|
["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'],
|
|
['## Current Position', '', 'Plan: 1-01-PLAN.md', ''],
|
|
));
|
|
writeFile(cwd, '.planning/ROADMAP.md', [
|
|
'## v1.0 Current 🚧', '', '## Phases', '',
|
|
'- [x] **Phase 1: Foo** - stub',
|
|
'- [ ] **Phase 2: Bar** - stub',
|
|
'',
|
|
'### Phase 1: Foo', '', 'Ship the foo module end to end.', '',
|
|
'**Depends on:** Phase 0', '',
|
|
'### Phase 2: Bar', '', 'Ship the bar module.', '',
|
|
].join('\n'));
|
|
writeFile(cwd, '.planning/REQUIREMENTS.md', [
|
|
'# Requirements: Test', '', '## v1 Requirements', '',
|
|
'- [x] **AUTH-01**: User can sign up',
|
|
'- [ ] **AUTH-02**: User can log in',
|
|
'',
|
|
'## Traceability', '',
|
|
'| Requirement | Phase | Status |',
|
|
'|-------------|-------|--------|',
|
|
'| AUTH-01 | Phase 1 | Complete |',
|
|
'| AUTH-02 | Phase 2 | Pending |',
|
|
'',
|
|
].join('\n'));
|
|
for (const [token, name] of [['1', 'foo'], ['2', 'bar']]) {
|
|
const phaseDir = phaseDirOf(cwd, `0${token}-${name}`);
|
|
writeAbs(path.join(phaseDir, `${token}-01-PLAN.md`), frontmatterDoc(['wave: 1'], [
|
|
'<objective>', `Ship ${name}`, '</objective>', '',
|
|
'<tasks>', '',
|
|
'<task type="auto">',
|
|
` <name>Task 1: Build ${name}</name>`,
|
|
` <files>src/${name}.ts</files>`,
|
|
' <done>Done</done>',
|
|
'</task>', '',
|
|
'</tasks>',
|
|
]));
|
|
writeAbs(path.join(phaseDir, `${token}-01-SUMMARY.md`), frontmatterDoc(['status: complete'], [
|
|
'# Summary', '', '## Files Created/Modified', `- \`src/${name}.ts\` - ${name}`,
|
|
]));
|
|
writeAbs(path.join(phaseDir, `${token}-VERIFICATION.md`), ['---', 'status: passed', '---', ''].join('\n'));
|
|
}
|
|
}
|
|
|
|
test('reports exact scope, percent, requirement, plan-metadata and phase-goal values', (t) => {
|
|
const cwd = mkCwd();
|
|
t.after(() => cleanup(cwd));
|
|
buildHealthy(cwd);
|
|
|
|
const result = buildPlanningInspect(cwd);
|
|
|
|
assert.strictEqual(result.phases.length, 2);
|
|
assert.strictEqual(result.milestone.version, 'v1.0');
|
|
|
|
const [foo, bar] = result.phases;
|
|
assert.strictEqual(foo.dir, '01-foo');
|
|
assert.strictEqual(foo.phase_id, '01');
|
|
assert.strictEqual(foo.complete, true);
|
|
assert.strictEqual(foo.scope, 'complete');
|
|
assert.deepStrictEqual(foo.goal, { value: 'Ship the foo module end to end.', scope: 'complete' });
|
|
assert.deepStrictEqual(foo.dependencies, { value: ['0'], scope: 'complete' });
|
|
assert.deepStrictEqual(foo.verification, { status: 'passed', next_action: 'Verification passed — continue.' });
|
|
assert.deepStrictEqual(foo.roadmap_acceptance, { checkbox: true, authoritative: false });
|
|
|
|
assert.strictEqual(bar.dir, '02-bar');
|
|
assert.deepStrictEqual(bar.dependencies, { value: [], scope: 'complete' });
|
|
assert.deepStrictEqual(bar.roadmap_acceptance, { checkbox: false, authoritative: false });
|
|
|
|
const [plan] = foo.plans;
|
|
assert.strictEqual(plan.id, '1-01');
|
|
assert.strictEqual(plan.wave, 1);
|
|
assert.deepStrictEqual(plan.dependsOn, []);
|
|
assert.strictEqual(plan.hasSummary, true);
|
|
assert.deepStrictEqual(plan.changedFiles, ['src/foo.ts']);
|
|
|
|
// The SUMMARY carries only `## Files Created/Modified` (plan-level), with
|
|
// no `## Deviations from Plan` block naming a task — so provenance is
|
|
// PLAN_SCOPED, not TASK_SCOPED, and status/agreement are UNKNOWN.
|
|
const [task] = plan.tasks;
|
|
assert.strictEqual(task.provenance, PROVENANCE.PLAN_SCOPED);
|
|
assert.strictEqual(task.agreement, AGREEMENT.UNKNOWN);
|
|
assert.strictEqual(task.status, TASK_STATUS.UNKNOWN);
|
|
assert.strictEqual(task.changedFiles, null);
|
|
|
|
assert.deepStrictEqual(result.requirements.map((r) => [r.id, r.complete, r.mappedPhases]), [
|
|
['AUTH-01', true, ['1']],
|
|
['AUTH-02', false, ['2']],
|
|
]);
|
|
|
|
assert.deepStrictEqual(result.progress.accepted_phases, { completed: 2, total: 2, percent: 100, scope: 'complete' });
|
|
assert.deepStrictEqual(result.progress.completed_plans, { completed: 2, total: 2, percent: 100, scope: 'complete' });
|
|
assert.strictEqual(diagnosticCodes(result).includes(INSPECT_DIAGNOSTIC.PERCENT_WITHHELD), false);
|
|
});
|
|
});
|
|
|
|
describe('planning-inspect — task provenance/agreement variety, checkpoint, orphan dirs, requirement diagnostics', () => {
|
|
function build(cwd) {
|
|
writeFile(cwd, '.planning/STATE.md', frontmatterDoc(["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'], []));
|
|
writeFile(cwd, '.planning/ROADMAP.md', [
|
|
'## v1.0 Current 🚧', '', '## Phases', '',
|
|
'- [x] **Phase 1: Foo** - stub', '',
|
|
'### Phase 1: Foo', '', 'Ship the foo module.', '',
|
|
].join('\n'));
|
|
writeFile(cwd, '.planning/REQUIREMENTS.md', [
|
|
'# Requirements: Test', '', '## v1 Requirements', '',
|
|
'- [x] **AUTH-01**: User can sign up',
|
|
'- [x] **AUTH-01**: Duplicate row',
|
|
'- [ ] **AUTH-02**: Unmapped requirement',
|
|
'- [ ] **AUTH-03**: Maps to missing phase',
|
|
'', '## Traceability', '',
|
|
'| Requirement | Phase | Status |',
|
|
'|-------------|-------|--------|',
|
|
'| AUTH-01 | Phase 1 | Complete |',
|
|
'| AUTH-03 | Phase 9 | Pending |',
|
|
'',
|
|
].join('\n'));
|
|
const p1 = phaseDirOf(cwd, '01-foo');
|
|
writeAbs(path.join(p1, '1-01-PLAN.md'), frontmatterDoc(['wave: 1'], [
|
|
'<objective>', 'Ship foo', '</objective>', '',
|
|
'<tasks>', '',
|
|
'<task type="auto">',
|
|
' <name>Task 1: Agreed task</name>',
|
|
' <files>src/a.ts</files>',
|
|
' <done>Done</done>',
|
|
'</task>', '',
|
|
'<task type="auto">',
|
|
' <name>Task 2: Conflicting task</name>',
|
|
' <files>src/b.ts</files>',
|
|
' <done>Done</done>',
|
|
'</task>', '',
|
|
'<task type="checkpoint:manual">',
|
|
' <decision>Ship it?</decision>',
|
|
'</task>', '',
|
|
'</tasks>',
|
|
]));
|
|
writeAbs(path.join(p1, '1-01-SUMMARY.md'), frontmatterDoc(['status: complete'], [
|
|
'# Summary', '', '## Files Created/Modified', '- `src/a.ts` - a', '',
|
|
'## Deviations from Plan', '',
|
|
'**Found during:** Task 1',
|
|
'**Files modified:** `src/a.ts`', '',
|
|
'**Found during:** Task 2',
|
|
'**Files modified:** `src/other.ts`',
|
|
]));
|
|
writeAbs(path.join(p1, '1-VERIFICATION.md'), ['---', 'status: passed', '---', ''].join('\n'));
|
|
fs.mkdirSync(phaseDirOf(cwd, '99-orphan'), { recursive: true });
|
|
}
|
|
|
|
test('agreed vs conflicting task provenance, checkpoint shape, orphan dir, and every requirement diagnostic code', (t) => {
|
|
const cwd = mkCwd();
|
|
t.after(() => cleanup(cwd));
|
|
build(cwd);
|
|
|
|
const result = buildPlanningInspect(cwd);
|
|
|
|
assert.deepStrictEqual(result.orphan_phase_dirs, ['99-orphan']);
|
|
assert.strictEqual(result.phases.length, 1);
|
|
|
|
const [agreedTask, conflictingTask, checkpointTask] = result.phases[0].plans[0].tasks;
|
|
assert.strictEqual(agreedTask.provenance, PROVENANCE.TASK_SCOPED);
|
|
assert.strictEqual(agreedTask.agreement, AGREEMENT.AGREED);
|
|
assert.strictEqual(agreedTask.status, TASK_STATUS.DONE);
|
|
assert.deepStrictEqual(agreedTask.changedFiles, ['src/a.ts']);
|
|
|
|
assert.strictEqual(conflictingTask.provenance, PROVENANCE.TASK_SCOPED);
|
|
assert.strictEqual(conflictingTask.agreement, AGREEMENT.CONFLICTING);
|
|
assert.deepStrictEqual(conflictingTask.changedFiles, ['src/other.ts']);
|
|
assert.deepStrictEqual(conflictingTask.plannedFiles, ['src/b.ts']);
|
|
|
|
assert.strictEqual(checkpointTask.kind, TASK_KIND.CHECKPOINT);
|
|
assert.strictEqual(checkpointTask.provenance, PROVENANCE.PLAN_SCOPED);
|
|
assert.strictEqual(checkpointTask.agreement, AGREEMENT.UNKNOWN);
|
|
|
|
assert.deepStrictEqual(
|
|
result.requirements.map((r) => ({ id: r.id, complete: r.complete, mappedPhases: r.mappedPhases, diagnostics: r.diagnostics })),
|
|
[
|
|
{ id: 'AUTH-01', complete: true, mappedPhases: ['1'], diagnostics: [INSPECT_DIAGNOSTIC.REQUIREMENT_DUPLICATE] },
|
|
{ id: 'AUTH-02', complete: false, mappedPhases: [], diagnostics: [INSPECT_DIAGNOSTIC.REQUIREMENT_UNMAPPED] },
|
|
{ id: 'AUTH-03', complete: false, mappedPhases: ['9'], diagnostics: [INSPECT_DIAGNOSTIC.REQUIREMENT_PHASE_UNKNOWN] },
|
|
],
|
|
);
|
|
|
|
const codes = diagnosticCodes(result);
|
|
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.ORPHAN_PHASE_DIR));
|
|
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.TASK_CHANGED_FILES_CONFLICTING));
|
|
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.TASK_SHAPE_CHECKPOINT));
|
|
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.TASK_CHANGED_FILES_PLAN_SCOPED));
|
|
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.REQUIREMENT_DUPLICATE));
|
|
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.REQUIREMENT_UNMAPPED));
|
|
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.REQUIREMENT_PHASE_UNKNOWN));
|
|
assert.strictEqual(codes.includes(INSPECT_DIAGNOSTIC.PERCENT_WITHHELD), false);
|
|
});
|
|
});
|
|
|
|
describe('planning-inspect — percent withheld, unreadable plan/summary, completion-unknown requirement', () => {
|
|
function build(cwd) {
|
|
writeFile(cwd, '.planning/STATE.md', frontmatterDoc(["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'], []));
|
|
writeFile(cwd, '.planning/ROADMAP.md', [
|
|
'## v1.0 Current 🚧', '', '## Phases', '',
|
|
'- [x] **Phase 1: Foo** - stub',
|
|
'- [ ] **Phase 2: Bar** - stub',
|
|
'',
|
|
'### Phase 1: Foo', '', 'Ship the foo module.', '',
|
|
// Deliberately NO section for Phase 2 -> ROADMAP_UNSCOPED for it.
|
|
].join('\n'));
|
|
writeFile(cwd, '.planning/REQUIREMENTS.md', [
|
|
'# Requirements: Test', '', '## v1 Requirements', '',
|
|
'- [x] **AUTH-01**: User can sign up',
|
|
'',
|
|
'## Other', '',
|
|
'| AUTH-05 | some description |',
|
|
'',
|
|
'## Traceability', '',
|
|
'| Requirement | Phase | Status |',
|
|
'|-------------|-------|--------|',
|
|
'| AUTH-01 | Phase 1 | Complete |',
|
|
'| AUTH-05 | Phase 1 | Pending |',
|
|
'',
|
|
].join('\n'));
|
|
const p1 = phaseDirOf(cwd, '01-foo');
|
|
writeAbs(path.join(p1, '1-01-PLAN.md'), frontmatterDoc(['wave: 1'], [
|
|
'<objective>', 'Ship foo', '</objective>', '',
|
|
'<tasks>', '', '<task type="auto">', ' <name>Task 1</name>', ' <files>src/a.ts</files>', ' <done>Done</done>', '</task>', '', '</tasks>',
|
|
]));
|
|
// Directory-in-file-position (cross-platform, no chmod): readDocument sees
|
|
// !stat.isFile() and reports unreadable, never a permissions hack.
|
|
fs.mkdirSync(path.join(p1, '1-01-SUMMARY.md'), { recursive: true });
|
|
const p2 = phaseDirOf(cwd, '02-bar');
|
|
fs.mkdirSync(p2, { recursive: true });
|
|
fs.mkdirSync(path.join(p2, '2-01-PLAN.md'), { recursive: true });
|
|
}
|
|
|
|
test('withholds percent when a windowed phase has no ROADMAP section, and reports unreadable plan/summary + completion-unknown', (t) => {
|
|
const cwd = mkCwd();
|
|
t.after(() => cleanup(cwd));
|
|
build(cwd);
|
|
|
|
const result = buildPlanningInspect(cwd);
|
|
|
|
const [foo, bar] = result.phases;
|
|
assert.deepStrictEqual(foo.goal, { value: 'Ship the foo module.', scope: 'complete' });
|
|
assert.deepStrictEqual(bar.goal, { value: null, scope: 'unscoped' });
|
|
assert.deepStrictEqual(bar.dependencies, { value: [], scope: 'unscoped' });
|
|
assert.strictEqual(bar.scope, 'unscoped');
|
|
assert.strictEqual(bar.plans[0].scope, 'unreadable');
|
|
assert.strictEqual(bar.plans[0].tasks.length, 0);
|
|
|
|
assert.deepStrictEqual(result.progress.accepted_phases, { completed: 0, total: 2, percent: null, scope: 'unscoped' });
|
|
assert.strictEqual(result.progress.completed_plans.percent, null);
|
|
|
|
const auth05 = result.requirements.find((r) => r.id === 'AUTH-05');
|
|
assert.strictEqual(auth05.complete, 'unknown');
|
|
assert.deepStrictEqual(auth05.mappedPhases, ['1']);
|
|
assert.deepStrictEqual(auth05.diagnostics, [INSPECT_DIAGNOSTIC.REQUIREMENT_COMPLETION_UNKNOWN]);
|
|
|
|
const codes = diagnosticCodes(result);
|
|
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.SUMMARY_UNREADABLE));
|
|
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.PLAN_UNREADABLE));
|
|
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.ROADMAP_UNSCOPED));
|
|
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.PHASE_SCOPE_DEGRADED));
|
|
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.REQUIREMENT_COMPLETION_UNKNOWN));
|
|
assert.strictEqual(codes.filter((c) => c === INSPECT_DIAGNOSTIC.PERCENT_WITHHELD).length, 2);
|
|
});
|
|
});
|
|
|
|
describe('planning-inspect — UAT unreadable, UAT items, requirements unreadable, containment escape', () => {
|
|
test('a directory-in-file-position UAT document is UAT_UNREADABLE and degrades phase scope', (t) => {
|
|
const cwd = mkCwd();
|
|
t.after(() => cleanup(cwd));
|
|
writeFile(cwd, '.planning/STATE.md', frontmatterDoc(["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'], []));
|
|
writeFile(cwd, '.planning/ROADMAP.md', [
|
|
'## v1.0 Current 🚧', '', '## Phases', '', '- [ ] **Phase 1: Foo** - stub', '',
|
|
'### Phase 1: Foo', '', 'Ship the foo module.', '',
|
|
].join('\n'));
|
|
const p1 = phaseDirOf(cwd, '01-foo');
|
|
fs.mkdirSync(path.join(p1, '1-UAT.md'), { recursive: true });
|
|
|
|
const result = buildPlanningInspect(cwd);
|
|
assert.deepStrictEqual(result.phases[0].uat, { unresolved: [], scope: 'truncated' });
|
|
assert.strictEqual(result.phases[0].scope, 'truncated');
|
|
const codes = diagnosticCodes(result);
|
|
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.UAT_UNREADABLE));
|
|
assert.ok(codes.includes(INSPECT_DIAGNOSTIC.PHASE_SCOPE_DEGRADED));
|
|
});
|
|
|
|
test('a pending UAT test item is surfaced verbatim in phases[].uat.unresolved', (t) => {
|
|
const cwd = mkCwd();
|
|
t.after(() => cleanup(cwd));
|
|
writeFile(cwd, '.planning/STATE.md', frontmatterDoc(["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'], []));
|
|
writeFile(cwd, '.planning/ROADMAP.md', [
|
|
'## v1.0 Current 🚧', '', '## Phases', '', '- [ ] **Phase 1: Foo** - stub', '',
|
|
'### Phase 1: Foo', '', 'Ship the foo module.', '',
|
|
].join('\n'));
|
|
const p1 = phaseDirOf(cwd, '01-foo');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
writeAbs(path.join(p1, '1-UAT.md'), [
|
|
'# UAT: Phase 1', '',
|
|
'## Current Test', '[testing complete]', '',
|
|
'## Tests', '',
|
|
'### 1. Sign up flow',
|
|
'expected: user can sign up',
|
|
'result: pending',
|
|
'',
|
|
].join('\n'));
|
|
|
|
const result = buildPlanningInspect(cwd);
|
|
assert.deepStrictEqual(result.phases[0].uat, {
|
|
scope: 'complete',
|
|
unresolved: [{ test: 1, name: 'Sign up flow', expected: 'user can sign up', result: 'pending', category: 'pending' }],
|
|
});
|
|
});
|
|
|
|
test('REQUIREMENTS.md as a directory-in-file-position is unreadable, not absent, and yields zero rows', (t) => {
|
|
const cwd = mkCwd();
|
|
t.after(() => cleanup(cwd));
|
|
writeFile(cwd, '.planning/STATE.md', frontmatterDoc(["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'], []));
|
|
writeFile(cwd, '.planning/ROADMAP.md', ['## v1.0 Current 🚧', '', '## Phases', ''].join('\n'));
|
|
fs.mkdirSync(path.join(cwd, '.planning/REQUIREMENTS.md'), { recursive: true });
|
|
|
|
const result = buildPlanningInspect(cwd);
|
|
assert.deepStrictEqual(result.requirements, []);
|
|
assert.ok(diagnosticCodes(result).includes(INSPECT_DIAGNOSTIC.REQUIREMENTS_UNREADABLE));
|
|
assert.strictEqual(diagnosticCodes(result).includes(INSPECT_DIAGNOSTIC.REQUIREMENTS_ABSENT), false);
|
|
});
|
|
|
|
test('a plan file symlinked outside the planning root degrades to unreadable, never leaking the escaped content', (t) => {
|
|
const cwd = mkCwd();
|
|
t.after(() => cleanup(cwd));
|
|
writeFile(cwd, '.planning/STATE.md', frontmatterDoc(["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'], []));
|
|
writeFile(cwd, '.planning/ROADMAP.md', [
|
|
'## v1.0 Current 🚧', '', '## Phases', '', '- [ ] **Phase 1: Foo** - stub', '',
|
|
'### Phase 1: Foo', '', 'Ship the foo module.', '',
|
|
].join('\n'));
|
|
const p1 = phaseDirOf(cwd, '01-foo');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
const outside = path.join(os.tmpdir(), `planning-inspect-unit-outside-${process.pid}.md`);
|
|
fs.writeFileSync(outside, 'SECRET CONTENT');
|
|
t.after(() => cleanup(outside));
|
|
fs.symlinkSync(outside, path.join(p1, '1-01-PLAN.md'));
|
|
|
|
const result = buildPlanningInspect(cwd);
|
|
const [plan] = result.phases[0].plans;
|
|
assert.strictEqual(plan.scope, 'unreadable');
|
|
assert.strictEqual(plan.objective, null);
|
|
assert.deepStrictEqual(plan.tasks, []);
|
|
const json = JSON.stringify(result);
|
|
assert.strictEqual(json.includes('SECRET CONTENT'), false);
|
|
assert.ok(diagnosticCodes(result).includes(INSPECT_DIAGNOSTIC.PLAN_UNREADABLE));
|
|
});
|
|
});
|