* wip(#3217): rule-4 scope withholding — parked, two open findings Implemented but NOT shippable. An isolated review found buildStateFrontmatter still hardcodes SCOPE.COMPLETE, so state json reports percent 0 where roadmap analyze, stats and query progress all correctly report null on the same disk state - rule 4 reintroduced at a site this phase claims to close. Also: roadmap analyze emits scope complete beside progress_percent null with nothing explaining it. Parked to build Phase 4 (#3186) first, which is unblocked. Findings recorded in .gsd/phase/refactor-3217-completion-ratio-scoping/60-review.json. * fix(#3217): withhold the sync percentage on a non-complete scope The parked blocker is fixed - buildStateFrontmatter no longer hardcodes SCOPE.COMPLETE, and the prose Progress fallback is gated too, which was a second leak found while tracing the first. roadmap analyze exposes progress_scope so a consumer can tell WHY a percentage is absent from the JSON alone. Then a residual gap was reproduced rather than assumed. cmdStateSync carried the same hardcode behind a written reason claiming it did not reproduce. It did: on a TRUNCATED window and on UNSCOPED row 4, state sync wrote Progress 0 percent to 100 percent while state json, roadmap analyze, stats and query progress all withheld - and it persisted a self-contradictory file, body claiming 100 percent while its own frontmatter correctly omitted percent. The excuse was also wrong. syncRoadmapRaw is already parsed in that function and is exactly what produces a real scope, so there was a scope to pass. Threaded through listMilestonePhaseDirs; a non-complete scope now skips the write with a reason in changes. milestoneBounded stays as the orthogonal 1761 guard for row 5. Second time this epic a does-not-reproduce claim was too generous. Recorded in ADR Amendment 8 as a correction rather than a quiet rewrite. Verified on the remote runner. * test(#3217): give the withholding fixtures a resolvable scope 40 matrix failures, all fixture drift - no code regression. My own hypothesis that this was over-withholding was wrong and is recorded as such: the worry case, a plain ROADMAP with Phase entries and no version heading, resolves to complete exactly as ADR 7.1 says it should. The real causes were two fixture shapes. Most had no ROADMAP.md at all, which is unreadable via a pre-existing graceful path, and asserted a numeric percent. The five vscode, pi-extension, mcp-server and shell-projection failures were that shape - bare temp dirs using progress json as a reachability proxy while asserting typeof percent is number, which under rule 4 is now null. The rest had a version token in a title or heading with no STATE.md milestone pointer to resolve it, which is classification row 4, versioned but unresolved, so withholding is correct per the contract. Verified on the remote runner. * test(#3217): make the LM-tools reachability tests dispatch against their fixture The gsd_progress reachability test was never testing its fixture. invoke() resolves cwd from vscode.workspace.workspaceFolders by design (the real LanguageModelToolInvocationOptions has no cwd field, per the 2103 fix in extension.js), the mock had no workspace at all, and the test passed a cwd option nothing reads - so it dispatched against the repo working directory. Writing a ROADMAP into the temp dir had no effect. Rule 4 only made it visible. Fixed by mocking workspaceFolders. The two siblings in the same file carried the identical dead cwd and were dispatching against the repo too; they were not failing only because their assertions did not touch scope-dependent output. Both now use their own fixture with assertions unchanged - the no-planning fallback paths already satisfy them honestly. Re-scanned the other five reachability files: no further instances. They thread cwd into parameters that genuinely read it, not through an options shape that ignores it. Verified on the remote runner. * chore(#3217): backfill changeset PR number pr:0 placeholder replaced with the real number now that #3318 exists. * ci(#3217): give the coverage merge enough heap for the merged shards The coverage gate OOMed at exit 134. c8 report merges three shard artifacts, roughly 358MB of V8 dumps in coverage/tmp, and died holding their per-file position maps at the ~4GB default heap. Verified as this branch's delta rather than pre-existing: the same job succeeded on next at 14:18, after phases 4 and 5 merged. Both coverage-gate steps get the bump because both re-slice the same merged data. 8192 doubles what failed and leaves headroom on a 16GB ubuntu runner, matching the idiom the shard step already uses at 6144. This is a memory bound, not a change to what is measured. No threshold was touched. The test file was checked for gratuitous subprocess spawning and is already reasonable at 43 spawns, each a distinct fixture-by-surface pairing. Verified on the remote runner. --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -523,9 +523,12 @@ describe('progress command', () => {
|
||||
});
|
||||
|
||||
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 v1.0 MVP\n`
|
||||
`# Roadmap MVP\n`
|
||||
);
|
||||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
@@ -545,9 +548,10 @@ describe('progress command', () => {
|
||||
});
|
||||
|
||||
test('renders bar format', () => {
|
||||
// #3217: no version token — see 'renders JSON progress' above.
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap v1.0\n`
|
||||
`# Roadmap\n`
|
||||
);
|
||||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-test');
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
@@ -576,9 +580,10 @@ describe('progress command', () => {
|
||||
});
|
||||
|
||||
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 v1.0 MVP\n`
|
||||
`# Roadmap MVP\n`
|
||||
);
|
||||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
@@ -2005,6 +2010,12 @@ describe('stats command', () => {
|
||||
|
||||
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(() => {
|
||||
@@ -2114,6 +2125,11 @@ describe('stats command', () => {
|
||||
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
|
||||
@@ -2122,7 +2138,7 @@ describe('stats command', () => {
|
||||
- [x] **Phase 15: Proof Generation**
|
||||
- [ ] **Phase 16: Multi-Claim Verification & UX**
|
||||
|
||||
## Milestone v1.0 Growth
|
||||
## Milestone Growth
|
||||
|
||||
### Phase 14: Auth Hardening
|
||||
**Goal:** Improve auth checks
|
||||
|
||||
826
tests/completion-ratio-scope-withholding.test.cjs
Normal file
826
tests/completion-ratio-scope-withholding.test.cjs
Normal file
@@ -0,0 +1,826 @@
|
||||
/**
|
||||
* Tests for Phase 7 (#3217, ADR-3180 §7.6 rules 3-4): a derivation whose
|
||||
* scope is not COMPLETE renders no percentage at all.
|
||||
*
|
||||
* Design: .gsd/phase/refactor-3217-completion-ratio-scoping/40-design.md
|
||||
* Test matrix: .gsd/phase/refactor-3217-completion-ratio-scoping/50-test-matrix.md
|
||||
*
|
||||
* Section A asserts at each CONSUMER's observable output (ADR Decision 4c),
|
||||
* never at a helper's return value — a percentage post-filtered after the
|
||||
* fact is indistinguishable from one never withheld. All CLI assertions use
|
||||
* `--raw` (typed JSON), never rendered prose (CONTRIBUTING.md: no source-grep,
|
||||
* no prose matching).
|
||||
*
|
||||
* Fixture note on constructing each SCOPE without fs mocking (a subprocess
|
||||
* CLI test cannot `mock.method` inside the child process): COMPLETE and
|
||||
* TRUNCATED and UNSCOPED are constructed via ROADMAP/STATE content shape
|
||||
* alone. UNREADABLE is constructed by making `.planning/phases` a REGULAR
|
||||
* FILE instead of a directory — `listMilestonePhaseDirs`'s own
|
||||
* `fs.readdirSync(phasesDir, ...)` then throws ENOTDIR and its catch block
|
||||
* returns `scope: SCOPE.UNREADABLE` (src/phase-locator.cts). This is
|
||||
* deterministic and cross-platform, unlike a `chmod`-based trick (repo rule:
|
||||
* IO failure via `mock.method`, never `chmod 0o000` — this sidesteps both by
|
||||
* not needing IO-failure injection into the CLI's own process at all).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { createTempDir, cleanup, runGsdTools } = require('./helpers.cjs');
|
||||
const drift = require('../scripts/lint-completion-ratio-drift.cjs');
|
||||
const { clampPercent } = require('../gsd-core/bin/lib/phase-lifecycle.cjs');
|
||||
const { computeProgressPercent } = require('../gsd-core/bin/lib/state-document.cjs');
|
||||
const { SCOPE } = require('../gsd-core/bin/lib/planning-scope.cjs');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
|
||||
// ─── Fixture helpers (mirrors tests/completion-ratio-single-owner.test.cjs) ─
|
||||
|
||||
function planningDirOf(cwd) {
|
||||
return path.join(cwd, '.planning');
|
||||
}
|
||||
|
||||
function writeRoadmap(cwd, content) {
|
||||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||||
fs.writeFileSync(path.join(planningDirOf(cwd), 'ROADMAP.md'), content);
|
||||
}
|
||||
|
||||
function writeState(cwd, fields) {
|
||||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||||
const lines = ['---'];
|
||||
for (const [k, v] of Object.entries(fields)) lines.push(`${k}: ${v}`);
|
||||
lines.push('---', '');
|
||||
fs.writeFileSync(path.join(planningDirOf(cwd), 'STATE.md'), lines.join('\n'));
|
||||
}
|
||||
|
||||
function writeFile(cwd, relPath, content) {
|
||||
const full = path.join(cwd, relPath);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
}
|
||||
|
||||
// Makes `.planning/phases` an UNREADABLE-as-a-directory node: a regular file
|
||||
// where callers expect a directory. `fs.readdirSync` on it throws ENOTDIR,
|
||||
// which `listMilestonePhaseDirs` (src/phase-locator.cts) catches and reports
|
||||
// as `scope: SCOPE.UNREADABLE` — the real production catch path, not a mock.
|
||||
function makePhasesDirUnreadable(cwd) {
|
||||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||||
fs.writeFileSync(path.join(planningDirOf(cwd), 'phases'), 'not a directory\n');
|
||||
}
|
||||
|
||||
// COMPLETE-scope fixture: v1.0 is the current (and only) milestone, one
|
||||
// Complete phase and one Planned phase.
|
||||
function buildCompleteFixture(cwd) {
|
||||
writeState(cwd, { milestone: 'v1.0' });
|
||||
writeRoadmap(cwd, [
|
||||
'## v1.0 Current 🚧',
|
||||
'',
|
||||
'### Phase 1: Foo',
|
||||
'',
|
||||
'### Phase 2: Bar',
|
||||
].join('\n'));
|
||||
writeFile(cwd, '.planning/phases/01-foo/01-01-PLAN.md', '# Plan\n');
|
||||
writeFile(cwd, '.planning/phases/01-foo/01-01-SUMMARY.md', '# Summary\n');
|
||||
writeFile(cwd, '.planning/phases/02-bar/02-01-PLAN.md', '# Plan\n');
|
||||
}
|
||||
|
||||
// TRUNCATED-scope fixture: STATE asserts v2.0, whose ROADMAP heading is
|
||||
// found but whose OWN window has no Phase entries, while the document (under
|
||||
// v1.0) does — classifyMilestoneWindow row 8 (src/roadmap-parser.cts).
|
||||
function buildTruncatedFixture(cwd) {
|
||||
writeState(cwd, { milestone: 'v2.0' });
|
||||
writeRoadmap(cwd, [
|
||||
'## v1.0 Planned',
|
||||
'',
|
||||
'### Phase 1: Foo',
|
||||
'',
|
||||
'## v2.0 Current 🚧',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
// UNSCOPED-scope fixture: STATE asserts a version with no matching ROADMAP
|
||||
// heading at all (classifyMilestoneWindow row 5).
|
||||
function buildUnscopedFixture(cwd) {
|
||||
writeState(cwd, { milestone: 'v9.9' });
|
||||
writeRoadmap(cwd, [
|
||||
'## v1.0 Current 🚧',
|
||||
'',
|
||||
'### Phase 1: Foo',
|
||||
].join('\n'));
|
||||
writeFile(cwd, '.planning/phases/01-foo/01-01-PLAN.md', '# Plan\n');
|
||||
}
|
||||
|
||||
// UNREADABLE-scope fixture: a valid milestone window, but the phases
|
||||
// directory itself cannot be enumerated.
|
||||
function buildUnreadableFixture(cwd) {
|
||||
writeState(cwd, { milestone: 'v1.0' });
|
||||
writeRoadmap(cwd, [
|
||||
'## v1.0 Current 🚧',
|
||||
'',
|
||||
'### Phase 1: Foo',
|
||||
].join('\n'));
|
||||
makePhasesDirUnreadable(cwd);
|
||||
}
|
||||
|
||||
function analyzeRaw(cwd) {
|
||||
const result = runGsdTools(['roadmap', 'analyze', '--cwd', cwd, '--raw'], cwd);
|
||||
assert.strictEqual(result.success, true, result.error);
|
||||
return JSON.parse(result.output);
|
||||
}
|
||||
|
||||
function queryProgressRaw(cwd) {
|
||||
const result = runGsdTools(['query', 'progress', '--cwd', cwd, '--raw'], cwd);
|
||||
assert.strictEqual(result.success, true, result.error);
|
||||
return JSON.parse(result.output);
|
||||
}
|
||||
|
||||
function statsRaw(cwd) {
|
||||
const result = runGsdTools(['stats', '--cwd', cwd, '--raw'], cwd);
|
||||
assert.strictEqual(result.success, true, result.error);
|
||||
return JSON.parse(result.output);
|
||||
}
|
||||
|
||||
function stateUpdateProgressRaw(cwd) {
|
||||
const result = runGsdTools(['state', 'update-progress', '--cwd', cwd, '--raw'], cwd);
|
||||
assert.strictEqual(result.success, true, result.error);
|
||||
return result.output;
|
||||
}
|
||||
|
||||
function stateJsonRaw(cwd) {
|
||||
const result = runGsdTools(['state', 'json', '--cwd', cwd, '--raw'], cwd);
|
||||
assert.strictEqual(result.success, true, result.error);
|
||||
return JSON.parse(result.output);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// A. Rule 4 at each consumer's OBSERVABLE output (ADR Decision 4c)
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('A. rule 4 — a non-COMPLETE scope renders no percentage, at the CLI surface', () => {
|
||||
test('A1: roadmap analyze --json, COMPLETE scope -> numeric progress_percent', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-a1-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildCompleteFixture(cwd);
|
||||
const analyzed = analyzeRaw(cwd);
|
||||
assert.strictEqual(analyzed.scope, SCOPE.COMPLETE);
|
||||
assert.strictEqual(typeof analyzed.progress_percent, 'number');
|
||||
assert.strictEqual(analyzed.progress_percent, 50);
|
||||
// Finding 2: `progress_scope` is the field that governs `progress_percent`
|
||||
// specifically — it must be exposed (not merely equal to `scope` by
|
||||
// coincidence on this fixture) so a consumer never has to infer it.
|
||||
assert.strictEqual(analyzed.progress_scope, SCOPE.COMPLETE);
|
||||
});
|
||||
|
||||
test('A2: roadmap analyze --json, TRUNCATED scope -> progress_percent: null (never 0, never 100)', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-a2-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildTruncatedFixture(cwd);
|
||||
const analyzed = analyzeRaw(cwd);
|
||||
assert.strictEqual(analyzed.scope, SCOPE.TRUNCATED);
|
||||
assert.strictEqual(analyzed.progress_percent, null);
|
||||
assert.notStrictEqual(analyzed.progress_percent, 0);
|
||||
assert.notStrictEqual(analyzed.progress_percent, 100);
|
||||
});
|
||||
|
||||
test('A3: roadmap analyze --json, UNSCOPED scope -> progress_percent: null', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-a3-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildUnscopedFixture(cwd);
|
||||
const analyzed = analyzeRaw(cwd);
|
||||
assert.strictEqual(analyzed.scope, SCOPE.UNSCOPED);
|
||||
assert.strictEqual(analyzed.progress_percent, null);
|
||||
});
|
||||
|
||||
test('A4: roadmap analyze --json, UNREADABLE phases dir -> progress_percent: null', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-a4-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildUnreadableFixture(cwd);
|
||||
const analyzed = analyzeRaw(cwd);
|
||||
assert.strictEqual(analyzed.progress_percent, null);
|
||||
// Finding 2's exact repro shape: the top-level `scope` (heading-windowing
|
||||
// identity) is COMPLETE — the ROADMAP heading resolves fine — while
|
||||
// `progress_scope` (the listMilestonePhaseDirs scope that actually gates
|
||||
// `progress_percent`) is UNREADABLE. Without `progress_scope` a consumer
|
||||
// sees `scope: "complete"` next to `progress_percent: null` with nothing
|
||||
// in the JSON explaining why.
|
||||
assert.strictEqual(analyzed.scope, SCOPE.COMPLETE);
|
||||
assert.strictEqual(analyzed.progress_scope, SCOPE.UNREADABLE);
|
||||
});
|
||||
|
||||
test('A5: query progress, non-COMPLETE scope -> percent: null, no number rendered', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-a5-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildTruncatedFixture(cwd);
|
||||
const rendered = queryProgressRaw(cwd);
|
||||
assert.strictEqual(rendered.phase_scope, SCOPE.TRUNCATED);
|
||||
assert.strictEqual(rendered.percent, null);
|
||||
});
|
||||
|
||||
test('A6: stats, non-COMPLETE scope -> percent: null and plan_percent: null', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-a6-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildTruncatedFixture(cwd);
|
||||
const stats = statsRaw(cwd);
|
||||
assert.strictEqual(stats.phase_scope, SCOPE.TRUNCATED);
|
||||
assert.strictEqual(stats.percent, null);
|
||||
assert.strictEqual(stats.plan_percent, null);
|
||||
});
|
||||
|
||||
test('A7: state update-progress, non-COMPLETE scope -> STATE.md Progress line untouched, no percentage written', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-a7-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildUnscopedFixture(cwd);
|
||||
const statePath = path.join(planningDirOf(cwd), 'STATE.md');
|
||||
writeFile(cwd, '.planning/STATE.md', fs.readFileSync(statePath, 'utf-8') + '\n**Progress:** [░░░░░░░░░░] 0%\n');
|
||||
const before = fs.readFileSync(statePath, 'utf-8');
|
||||
|
||||
const output = stateUpdateProgressRaw(cwd);
|
||||
assert.strictEqual(output, 'false');
|
||||
|
||||
const after = fs.readFileSync(statePath, 'utf-8');
|
||||
assert.strictEqual(after, before, 'STATE.md must not be modified when scope is not COMPLETE');
|
||||
});
|
||||
|
||||
// A8 (workstream inventory, non-COMPLETE scope -> no percentage in the
|
||||
// projection): NOT covered. `buildWorkstreamInventory`
|
||||
// (src/workstream-inventory-builder.cts) is a pure projection whose caller
|
||||
// (workstream-inventory.cts) does not thread a real `SCOPE` value into its
|
||||
// inputs, and its own `milestoneScoped` is a pre-ADR-3180 bespoke boolean
|
||||
// that cannot distinguish TRUNCATED from UNSCOPED from UNREADABLE. Doing
|
||||
// this honestly requires either widening `WorkstreamInventory.progress_percent`
|
||||
// from `number` to `number | null` (an explicitly out-of-scope
|
||||
// return-type re-architecture per this phase's design doc, "Known limits")
|
||||
// or reusing the bespoke boolean as a `Scope` stand-in (exactly the
|
||||
// textual-proxy-for-a-data-flow-property this phase's own guard section
|
||||
// rejects). See the written-reason comment at
|
||||
// src/workstream-inventory-builder.cts's `progress_percent` field. A bare
|
||||
// `return` here is this repo's documented PASS-not-skip shape for a row
|
||||
// this phase deliberately leaves un-migrated, rather than silently
|
||||
// omitting the row.
|
||||
test('A8: workstream inventory — NOT migrated this phase (written reason above; see src/workstream-inventory-builder.cts)', () => {
|
||||
return;
|
||||
});
|
||||
|
||||
test('A9: cross-surface — one non-COMPLETE fixture, none of roadmap analyze / query progress / stats renders a number', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-a9-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildTruncatedFixture(cwd);
|
||||
|
||||
const analyzed = analyzeRaw(cwd);
|
||||
const progress = queryProgressRaw(cwd);
|
||||
const stats = statsRaw(cwd);
|
||||
|
||||
assert.strictEqual(analyzed.scope, SCOPE.TRUNCATED);
|
||||
assert.strictEqual(progress.phase_scope, SCOPE.TRUNCATED);
|
||||
assert.strictEqual(stats.phase_scope, SCOPE.TRUNCATED);
|
||||
|
||||
assert.strictEqual(analyzed.progress_percent, null);
|
||||
assert.strictEqual(progress.percent, null);
|
||||
assert.strictEqual(stats.percent, null);
|
||||
assert.strictEqual(stats.plan_percent, null);
|
||||
});
|
||||
|
||||
test('A10: state json --raw, UNREADABLE phases dir -> progress.percent is ABSENT (not 0, not null-valued)', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-a10-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildUnreadableFixture(cwd);
|
||||
|
||||
const built = stateJsonRaw(cwd);
|
||||
// buildStateFrontmatter's established convention (unchanged by this
|
||||
// phase): a null percent is OMITTED from `progress`, never emitted as
|
||||
// `percent: 0` or `percent: null`. Before finding 1's fix this hardcoded
|
||||
// SCOPE.COMPLETE and, because the ROADMAP heading gave a real
|
||||
// totalPhases=1 with disk-scanned completedPhases=0 (the phases dir
|
||||
// being unreadable collapses to an empty scanned set), rendered an
|
||||
// EARNED-LOOKING but untrustworthy `percent: 0`.
|
||||
assert.ok(built.progress === undefined || !('percent' in built.progress));
|
||||
});
|
||||
|
||||
test('A11 (finding-1 repro): one genuinely UNREADABLE-phases-dir fixture — state json / roadmap analyze / stats / query progress / state update-progress ALL withhold, none renders a number', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-a11-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildUnreadableFixture(cwd);
|
||||
const statePath = path.join(planningDirOf(cwd), 'STATE.md');
|
||||
const before = fs.readFileSync(statePath, 'utf-8');
|
||||
|
||||
const built = stateJsonRaw(cwd);
|
||||
const analyzed = analyzeRaw(cwd);
|
||||
const stats = statsRaw(cwd);
|
||||
const progress = queryProgressRaw(cwd);
|
||||
const updateOutput = stateUpdateProgressRaw(cwd);
|
||||
|
||||
assert.ok(built.progress === undefined || !('percent' in built.progress), 'state json must not render a percent');
|
||||
assert.strictEqual(analyzed.progress_percent, null, 'roadmap analyze must withhold');
|
||||
assert.strictEqual(analyzed.progress_scope, SCOPE.UNREADABLE, 'roadmap analyze must expose WHY via progress_scope');
|
||||
assert.strictEqual(stats.percent, null, 'stats must withhold');
|
||||
assert.strictEqual(stats.phase_scope, SCOPE.UNREADABLE);
|
||||
assert.strictEqual(progress.percent, null, 'query progress must withhold');
|
||||
assert.strictEqual(progress.phase_scope, SCOPE.UNREADABLE);
|
||||
assert.strictEqual(updateOutput, 'false', 'state update-progress must not write');
|
||||
|
||||
const after = fs.readFileSync(statePath, 'utf-8');
|
||||
assert.strictEqual(after, before, 'state update-progress must leave STATE.md untouched');
|
||||
});
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// B. The negative space — a real 0 must survive (over-withholding guard)
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('B. negative space — a REAL 0 under COMPLETE must still render', () => {
|
||||
test('B1: COMPLETE scope, zero phases in a freshly-declared milestone -> 0, rendered (not withheld)', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-b1-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeState(cwd, { milestone: 'v1.0' });
|
||||
writeRoadmap(cwd, ['## v1.0 Current 🚧', ''].join('\n'));
|
||||
fs.mkdirSync(path.join(planningDirOf(cwd), 'phases'), { recursive: true });
|
||||
|
||||
const analyzed = analyzeRaw(cwd);
|
||||
assert.strictEqual(analyzed.scope, SCOPE.COMPLETE);
|
||||
assert.strictEqual(analyzed.progress_percent, 0);
|
||||
|
||||
const progress = queryProgressRaw(cwd);
|
||||
assert.strictEqual(progress.phase_scope, SCOPE.COMPLETE);
|
||||
assert.strictEqual(progress.percent, 0);
|
||||
});
|
||||
|
||||
test('B2: COMPLETE scope, denominator 0 (plans exist in no phase) -> 0 (rule 2, unchanged)', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-b2-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeState(cwd, { milestone: 'v1.0' });
|
||||
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n'));
|
||||
// Phase directory exists but has no PLAN files at all -> denominator 0.
|
||||
fs.mkdirSync(path.join(planningDirOf(cwd), 'phases', '01-foo'), { recursive: true });
|
||||
|
||||
const analyzed = analyzeRaw(cwd);
|
||||
assert.strictEqual(analyzed.scope, SCOPE.COMPLETE);
|
||||
assert.strictEqual(analyzed.total_plans, 0);
|
||||
assert.strictEqual(analyzed.progress_percent, 0);
|
||||
});
|
||||
|
||||
test('B3: COMPLETE scope, all phases complete -> 100', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-b3-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeState(cwd, { milestone: 'v1.0' });
|
||||
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n'));
|
||||
writeFile(cwd, '.planning/phases/01-foo/01-01-PLAN.md', '# Plan\n');
|
||||
writeFile(cwd, '.planning/phases/01-foo/01-01-SUMMARY.md', '# Summary\n');
|
||||
|
||||
const analyzed = analyzeRaw(cwd);
|
||||
assert.strictEqual(analyzed.scope, SCOPE.COMPLETE);
|
||||
assert.strictEqual(analyzed.progress_percent, 100);
|
||||
});
|
||||
|
||||
test('B4: a free-form legacy ROADMAP is COMPLETE for windowing (§7.1) — its percentage must NOT be lost', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-b4-');
|
||||
t.after(() => cleanup(cwd));
|
||||
// No STATE.md, no versioned milestone headings at all — the classic
|
||||
// free-form legacy shape (classifyMilestoneWindow row 3).
|
||||
writeRoadmap(cwd, ['# ROADMAP', '', '### Phase 1: Foo'].join('\n'));
|
||||
writeFile(cwd, '.planning/phases/01-foo/01-01-PLAN.md', '# Plan\n');
|
||||
|
||||
const analyzed = analyzeRaw(cwd);
|
||||
// Identity scope is UNSCOPED for a free-form roadmap, but WINDOWING scope
|
||||
// (what gates progress_percent here) must be COMPLETE per §7.1.
|
||||
assert.strictEqual(analyzed.scope, SCOPE.COMPLETE);
|
||||
assert.strictEqual(typeof analyzed.progress_percent, 'number');
|
||||
assert.strictEqual(analyzed.progress_percent, 0);
|
||||
});
|
||||
|
||||
test('B5: boundary on the denominator under COMPLETE: 0, 1, >1', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-b5-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeState(cwd, { milestone: 'v1.0' });
|
||||
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n'));
|
||||
|
||||
// Denominator 0: no PLAN files.
|
||||
fs.mkdirSync(path.join(planningDirOf(cwd), 'phases', '01-foo'), { recursive: true });
|
||||
assert.strictEqual(analyzeRaw(cwd).progress_percent, 0);
|
||||
|
||||
// Denominator 1, completed: 1/1 -> 100.
|
||||
writeFile(cwd, '.planning/phases/01-foo/01-01-PLAN.md', '# Plan\n');
|
||||
writeFile(cwd, '.planning/phases/01-foo/01-01-SUMMARY.md', '# Summary\n');
|
||||
assert.strictEqual(analyzeRaw(cwd).progress_percent, 100);
|
||||
|
||||
// Denominator >1: add a second, incomplete plan -> 1/2 -> 50.
|
||||
writeFile(cwd, '.planning/phases/01-foo/01-02-PLAN.md', '# Plan\n');
|
||||
assert.strictEqual(analyzeRaw(cwd).progress_percent, 50);
|
||||
});
|
||||
|
||||
test('B6: null vs 0 are distinguishable by a typed consumer', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-b6-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeState(cwd, { milestone: 'v1.0' });
|
||||
writeRoadmap(cwd, ['## v1.0 Current 🚧', ''].join('\n'));
|
||||
fs.mkdirSync(path.join(planningDirOf(cwd), 'phases'), { recursive: true });
|
||||
const zeroCase = analyzeRaw(cwd);
|
||||
assert.strictEqual(zeroCase.progress_percent, 0);
|
||||
assert.notStrictEqual(zeroCase.progress_percent, null);
|
||||
|
||||
cleanup(cwd);
|
||||
const cwd2 = createTempDir('gsd-3217-b6b-');
|
||||
t.after(() => cleanup(cwd2));
|
||||
buildTruncatedFixture(cwd2);
|
||||
const nullCase = analyzeRaw(cwd2);
|
||||
assert.strictEqual(nullCase.progress_percent, null);
|
||||
assert.notStrictEqual(nullCase.progress_percent, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// C. Rule 3 at cmdRoadmapAnalyze — the site Phase 3 did not reach
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('C. rule 3 at roadmap analyze — progress_percent routed through listMilestonePhaseDirs', () => {
|
||||
// One fixture exercising C1 and C3 together: a backlog/sentinel dir
|
||||
// (999.1-backlog, fully "complete") and a phase outside the current
|
||||
// milestone window (01-foo, under v1.0, fully "complete") must both be
|
||||
// excluded from progress_percent's numerator AND denominator — only
|
||||
// 02-bar (under the current v2.0 window) may count.
|
||||
function buildScopedFixture(cwd) {
|
||||
writeState(cwd, { milestone: 'v2.0' });
|
||||
writeRoadmap(cwd, [
|
||||
'## v1.0 Old',
|
||||
'',
|
||||
'### Phase 1: Foo',
|
||||
'',
|
||||
'## v2.0 Current 🚧',
|
||||
'',
|
||||
'### Phase 2: Bar',
|
||||
].join('\n'));
|
||||
// Out-of-window, fully complete — must not inflate the scoped total.
|
||||
writeFile(cwd, '.planning/phases/01-foo/01-01-PLAN.md', '# Plan\n');
|
||||
writeFile(cwd, '.planning/phases/01-foo/01-01-SUMMARY.md', '# Summary\n');
|
||||
// In-window, incomplete.
|
||||
writeFile(cwd, '.planning/phases/02-bar/02-01-PLAN.md', '# Plan\n');
|
||||
// Backlog/sentinel, fully complete — must never count as a milestone phase.
|
||||
writeFile(cwd, '.planning/phases/999.1-backlog/999.1-01-PLAN.md', '# Plan\n');
|
||||
writeFile(cwd, '.planning/phases/999.1-backlog/999.1-01-SUMMARY.md', '# Summary\n');
|
||||
}
|
||||
|
||||
test('C1 + C3: numerator/denominator are the listMilestonePhaseDirs-scoped set only — no inflation from backlog or out-of-window dirs', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-c1c3-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildScopedFixture(cwd);
|
||||
|
||||
const analyzed = analyzeRaw(cwd);
|
||||
assert.strictEqual(analyzed.scope, SCOPE.COMPLETE);
|
||||
// Only 02-bar's 1 plan / 0 summaries — not 01-foo's or the backlog's.
|
||||
assert.strictEqual(analyzed.total_plans, 1);
|
||||
assert.strictEqual(analyzed.total_summaries, 0);
|
||||
assert.strictEqual(analyzed.progress_percent, 0);
|
||||
assert.notStrictEqual(analyzed.progress_percent, 100, 'the two complete-but-excluded dirs must not leak in');
|
||||
});
|
||||
|
||||
test('C2: roadmap analyze vs stats vs query progress on one fixture report the SAME percentage', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-c2-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildScopedFixture(cwd);
|
||||
|
||||
const analyzed = analyzeRaw(cwd);
|
||||
const progress = queryProgressRaw(cwd);
|
||||
const stats = statsRaw(cwd);
|
||||
|
||||
assert.strictEqual(analyzed.progress_percent, 0);
|
||||
assert.strictEqual(progress.percent, 0);
|
||||
assert.strictEqual(stats.plan_percent, 0);
|
||||
assert.strictEqual(analyzed.progress_percent, progress.percent);
|
||||
assert.strictEqual(progress.percent, stats.plan_percent);
|
||||
});
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// D. computeProgressPercent (state-document.cts:329) — direct unit tests
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('D. computeProgressPercent — scope-required signature', () => {
|
||||
test('D1: no plan data and no phase data -> null (pre-existing behavior, must not regress)', () => {
|
||||
assert.strictEqual(computeProgressPercent(null, null, null, null, SCOPE.COMPLETE), null);
|
||||
});
|
||||
|
||||
test('D2a: plan data only -> min of the two fractions (phase fraction defaults to 1) via clampPercentFromFraction', () => {
|
||||
// completedPlans=1, totalPlans=2 -> planFraction 0.5; no phase data ->
|
||||
// phaseFraction defaults to 1; min(0.5, 1) = 0.5 -> 50.
|
||||
assert.strictEqual(computeProgressPercent(1, 2, null, null, SCOPE.COMPLETE), 50);
|
||||
});
|
||||
|
||||
test('D2b: phase data only -> min of the two fractions (plan fraction defaults to 1)', () => {
|
||||
assert.strictEqual(computeProgressPercent(null, null, 1, 4, SCOPE.COMPLETE), 25);
|
||||
});
|
||||
|
||||
test('D2c: both plan and phase data -> the MIN fraction wins', () => {
|
||||
// planFraction = 1/1 = 1.0; phaseFraction = 1/4 = 0.25 -> min is 0.25 -> 25.
|
||||
assert.strictEqual(computeProgressPercent(1, 1, 1, 4, SCOPE.COMPLETE), 25);
|
||||
});
|
||||
|
||||
test('D3: scope not COMPLETE -> null, even with otherwise-valid plan/phase data', () => {
|
||||
assert.strictEqual(computeProgressPercent(1, 1, 1, 1, SCOPE.TRUNCATED), null);
|
||||
assert.strictEqual(computeProgressPercent(1, 1, 1, 1, SCOPE.UNSCOPED), null);
|
||||
assert.strictEqual(computeProgressPercent(1, 1, 1, 1, SCOPE.UNREADABLE), null);
|
||||
});
|
||||
|
||||
test('D4: completedPlans > totalPlans (over-count) -> clamped at 100, never above', () => {
|
||||
assert.strictEqual(computeProgressPercent(7, 5, null, null, SCOPE.COMPLETE), 100);
|
||||
});
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// E. Tier-2 regression surface
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('E. Tier-2 regression surface', () => {
|
||||
test('E1: a consumer parsing progress_percent as always-numeric must now handle null — pins the new nullable contract', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-e1-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildTruncatedFixture(cwd);
|
||||
const analyzed = analyzeRaw(cwd);
|
||||
// Deliberate Tier-2 break, pinned: an always-numeric parse of
|
||||
// `progress_percent` (e.g. `Number(progress_percent).toFixed(0)`) would
|
||||
// have silently coerced this to "NaN" or "0" pre-#3217. It is `null`.
|
||||
assert.strictEqual(analyzed.progress_percent, null);
|
||||
assert.throws(() => {
|
||||
if (typeof analyzed.progress_percent !== 'number') throw new TypeError('progress_percent is not always numeric');
|
||||
}, TypeError);
|
||||
});
|
||||
|
||||
test('E2: existing power-proof / consumer-identity fixture (tests/completion-ratio-single-owner.test.cjs) still resolves COMPLETE and matches clampPercent(owner totals)', (t) => {
|
||||
// Reuses that file's own fixture shape (a clean single-milestone project)
|
||||
// to confirm this phase's routing change does not alter the COMPLETE
|
||||
// case those pre-existing tests assert on.
|
||||
const cwd = createTempDir('gsd-3217-e2-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildCompleteFixture(cwd);
|
||||
const analyzed = analyzeRaw(cwd);
|
||||
assert.strictEqual(analyzed.scope, SCOPE.COMPLETE);
|
||||
assert.strictEqual(analyzed.progress_percent, clampPercent(analyzed.total_summaries, analyzed.total_plans));
|
||||
});
|
||||
|
||||
test('E3: scripts/lint-completion-ratio-drift.cjs on the real tree — still an earned 0 for rules 1-2', () => {
|
||||
const violations = drift.scanRepo(REPO_ROOT);
|
||||
assert.deepStrictEqual(violations, []);
|
||||
});
|
||||
|
||||
test('E4: a deliberate arithmetic re-derivation fixture is still flagged — the existing guard keeps working, unmodified by this phase', () => {
|
||||
const line = 'const p = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0;';
|
||||
const out = drift.findCompletionRatioDrift(line, 'src/somewhere.cts');
|
||||
assert.strictEqual(out.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// F/G/H. `state sync` (cmdStateSync) — BLOCKER fix (post-review, #3217)
|
||||
//
|
||||
// cmdStateSync's own disk scan (`entries`, ~state.cts:3113) is UNFILTERED by
|
||||
// the real milestone window — it only drops retired phase numbers (#1514).
|
||||
// A phase directory outside the real window (or present while the window
|
||||
// itself is TRUNCATED/UNSCOPED) still counted toward totalDiskPlans /
|
||||
// totalDiskSummaries / diskCompletedPhases, and the percent gate hardcoded
|
||||
// SCOPE.COMPLETE, so a non-COMPLETE window could still compute and WRITE a
|
||||
// fabricated percentage into STATE.md's body. Fixed by threading the real
|
||||
// `listMilestonePhaseDirs` scope through the same gate `computeProgressPercent`
|
||||
// already enforces for every other consumer.
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// UNSCOPED-row-4 fixture: no milestone asserted in STATE.md at all
|
||||
// (`versionResolved` false) while the ROADMAP carries versioned milestone
|
||||
// headings (`hasVersionedMilestones` true) — classifyMilestoneWindow row 4,
|
||||
// distinct from the row-5 fixture above (which asserts an unmatched version).
|
||||
// A completed phase directory is included so the pre-fix hardcoded-COMPLETE
|
||||
// disk scan has real numbers to fabricate a percentage from.
|
||||
function buildUnscopedRow4Fixture(cwd) {
|
||||
writeState(cwd, {});
|
||||
writeRoadmap(cwd, [
|
||||
'## v1.0 Shipped',
|
||||
'',
|
||||
'### Phase 1: Foo',
|
||||
].join('\n'));
|
||||
writeFile(cwd, '.planning/phases/01-foo/01-01-PLAN.md', '# Plan\n');
|
||||
writeFile(cwd, '.planning/phases/01-foo/01-01-SUMMARY.md', '# Summary\n');
|
||||
}
|
||||
|
||||
// TRUNCATED fixture (mirrors buildTruncatedFixture above) but WITH a
|
||||
// completed phase directory on disk — the empty-window/empty-phases-dir
|
||||
// shape above never exercises cmdStateSync's fabrication bug because
|
||||
// cmdStateSync returns early ({ changes: [] }) when `.planning/phases`
|
||||
// itself does not exist. This variant makes the phases dir real so the
|
||||
// disk-scan gate is actually reached.
|
||||
function buildTruncatedFixtureWithPhaseDir(cwd) {
|
||||
writeState(cwd, { milestone: 'v2.0' });
|
||||
writeRoadmap(cwd, [
|
||||
'## v1.0 Planned',
|
||||
'',
|
||||
'### Phase 1: Foo',
|
||||
'',
|
||||
'## v2.0 Current 🚧',
|
||||
].join('\n'));
|
||||
writeFile(cwd, '.planning/phases/01-foo/01-01-PLAN.md', '# Plan\n');
|
||||
writeFile(cwd, '.planning/phases/01-foo/01-01-SUMMARY.md', '# Summary\n');
|
||||
}
|
||||
|
||||
// A COMPLETE-scope fixture whose disk-derived percent is a genuine 0 (empty
|
||||
// milestone window, empty phases dir) — the over-withholding guard (rule 2
|
||||
// negative space) for the WRITE path specifically.
|
||||
function buildSyncCompleteZeroFixture(cwd) {
|
||||
writeState(cwd, { milestone: 'v1.0' });
|
||||
writeRoadmap(cwd, ['## v1.0 Current 🚧', ''].join('\n'));
|
||||
fs.mkdirSync(path.join(planningDirOf(cwd), 'phases'), { recursive: true });
|
||||
}
|
||||
|
||||
// Appends a syncable body (STATE.md's frontmatter is already written by
|
||||
// writeState/buildXFixture above) carrying a `Progress:` line that
|
||||
// `state-transition.cts`'s syncCore can locate and, if warranted, replace —
|
||||
// `stateExtractField`'s plain-line pattern (`^Progress:`). `initialPercent`
|
||||
// lets a test start from a value distinguishable from both 0 and any
|
||||
// fabricated disk-derived number.
|
||||
function appendSyncableBody(cwd, initialPercent) {
|
||||
const statePath = path.join(planningDirOf(cwd), 'STATE.md');
|
||||
const existing = fs.readFileSync(statePath, 'utf-8');
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
existing + [
|
||||
'',
|
||||
'# GSD State',
|
||||
'',
|
||||
'## Configuration',
|
||||
'Total Plans in Phase: 1',
|
||||
`Progress: [░░░░░░░░░░] ${initialPercent}`,
|
||||
'Last Activity: 2020-01-01',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
function stateSyncRaw(cwd) {
|
||||
const result = runGsdTools(['state', 'sync', '--cwd', cwd, '--raw'], cwd);
|
||||
assert.strictEqual(result.success, true, result.error);
|
||||
return JSON.parse(result.output);
|
||||
}
|
||||
|
||||
describe('F. state sync (cmdStateSync) — BLOCKER: withhold on a non-COMPLETE scope, never fabricate', () => {
|
||||
test('F1: TRUNCATED window — no Progress rewrite, a skip entry appears in changes, body carries no fabricated percent', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-f1-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildTruncatedFixtureWithPhaseDir(cwd);
|
||||
appendSyncableBody(cwd, '0%');
|
||||
const statePath = path.join(planningDirOf(cwd), 'STATE.md');
|
||||
|
||||
const out = stateSyncRaw(cwd);
|
||||
assert.strictEqual(out.synced, true);
|
||||
assert.ok(
|
||||
out.changes.some((c) => /^Progress: skipped/.test(c) && /#3217/.test(c)),
|
||||
`expected a #3217 skip entry in changes, got: ${JSON.stringify(out.changes)}`,
|
||||
);
|
||||
assert.ok(
|
||||
!out.changes.some((c) => /^Progress: \[/.test(c)),
|
||||
`must not record a Progress bar rewrite, got: ${JSON.stringify(out.changes)}`,
|
||||
);
|
||||
|
||||
const after = fs.readFileSync(statePath, 'utf-8');
|
||||
assert.ok(after.includes('Progress: [░░░░░░░░░░] 0%'), 'body Progress line must be untouched');
|
||||
assert.ok(!/100%/.test(after), 'must never fabricate 100% (the pre-fix defect: disk scan saw 1/1 complete)');
|
||||
});
|
||||
|
||||
test('F2: UNSCOPED row 4 (no milestone asserted, ROADMAP has versioned headings) — same withholding', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-f2-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildUnscopedRow4Fixture(cwd);
|
||||
appendSyncableBody(cwd, '0%');
|
||||
const statePath = path.join(planningDirOf(cwd), 'STATE.md');
|
||||
|
||||
const out = stateSyncRaw(cwd);
|
||||
assert.ok(
|
||||
out.changes.some((c) => /^Progress: skipped/.test(c) && /#3217/.test(c)),
|
||||
`expected a #3217 skip entry, got: ${JSON.stringify(out.changes)}`,
|
||||
);
|
||||
assert.ok(!out.changes.some((c) => /^Progress: \[/.test(c)));
|
||||
|
||||
const after = fs.readFileSync(statePath, 'utf-8');
|
||||
assert.ok(after.includes('Progress: [░░░░░░░░░░] 0%'), 'body Progress line must be untouched');
|
||||
assert.ok(!/100%/.test(after), 'must never fabricate 100%');
|
||||
});
|
||||
|
||||
test('F3: UNSCOPED row 5 (asserted version with no matching heading) — unchanged: the pre-existing #1761 guard still fires first', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-f3-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildUnscopedFixture(cwd);
|
||||
appendSyncableBody(cwd, '0%');
|
||||
|
||||
const out = stateSyncRaw(cwd);
|
||||
assert.ok(
|
||||
out.changes.some((c) => /^Progress: skipped/.test(c) && /#1761/.test(c)),
|
||||
`row 5 must still hit the pre-existing #1761 guard, got: ${JSON.stringify(out.changes)}`,
|
||||
);
|
||||
// The new #3217 gate is orthogonal and must never fire once #1761 already
|
||||
// skipped — only one skip entry should be recorded.
|
||||
assert.strictEqual(out.changes.filter((c) => /^Progress: skipped/.test(c)).length, 1);
|
||||
assert.ok(!out.changes.some((c) => /#3217/.test(c)));
|
||||
});
|
||||
|
||||
test('F4: COMPLETE scope, genuine 0 — still WRITTEN (over-withholding guard)', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-f4-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildSyncCompleteZeroFixture(cwd);
|
||||
appendSyncableBody(cwd, '100%');
|
||||
const statePath = path.join(planningDirOf(cwd), 'STATE.md');
|
||||
|
||||
const out = stateSyncRaw(cwd);
|
||||
assert.ok(
|
||||
out.changes.some((c) => /^Progress: .* -> \[░{10}\] 0%$/.test(c)),
|
||||
`expected a real 0% write, got: ${JSON.stringify(out.changes)}`,
|
||||
);
|
||||
assert.ok(!out.changes.some((c) => /^Progress: skipped/.test(c)), 'a COMPLETE scope must never skip');
|
||||
|
||||
const after = fs.readFileSync(statePath, 'utf-8');
|
||||
assert.ok(/Progress: \[░{10}\] 0%/.test(after), 'body must carry the real 0%, not the stale 100%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('G. cross-surface — one non-COMPLETE fixture, ALL FIVE surfaces withhold (the assertion that would have caught the defect)', () => {
|
||||
test('G1: state sync / state json / roadmap analyze / stats / query progress all agree on withholding', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-g1-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildTruncatedFixtureWithPhaseDir(cwd);
|
||||
appendSyncableBody(cwd, '0%');
|
||||
const statePath = path.join(planningDirOf(cwd), 'STATE.md');
|
||||
|
||||
const syncOut = stateSyncRaw(cwd);
|
||||
const jsonOut = stateJsonRaw(cwd);
|
||||
const analyzed = analyzeRaw(cwd);
|
||||
const stats = statsRaw(cwd);
|
||||
const progress = queryProgressRaw(cwd);
|
||||
|
||||
assert.ok(
|
||||
syncOut.changes.some((c) => /^Progress: skipped/.test(c)),
|
||||
'state sync must withhold (skip entry present)',
|
||||
);
|
||||
assert.ok(!syncOut.changes.some((c) => /^Progress: \[/.test(c)), 'state sync must not rewrite Progress');
|
||||
assert.ok(jsonOut.progress === undefined || !('percent' in jsonOut.progress), 'state json must withhold');
|
||||
assert.strictEqual(analyzed.progress_percent, null, 'roadmap analyze must withhold');
|
||||
assert.strictEqual(stats.percent, null, 'stats must withhold');
|
||||
assert.strictEqual(progress.percent, null, 'query progress must withhold');
|
||||
|
||||
const afterSync = fs.readFileSync(statePath, 'utf-8');
|
||||
assert.ok(afterSync.includes('Progress: [░░░░░░░░░░] 0%'), 'body Progress line untouched by state sync');
|
||||
assert.ok(!/100%/.test(afterSync), 'state sync must never fabricate 100% on this fixture');
|
||||
});
|
||||
});
|
||||
|
||||
describe('H. self-consistency — after state sync, STATE.md body and frontmatter never contradict each other', () => {
|
||||
test('H1: non-COMPLETE scope — body carries no fabricated percent AND frontmatter progress: carries no percent key', (t) => {
|
||||
const cwd = createTempDir('gsd-3217-h1-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildTruncatedFixtureWithPhaseDir(cwd);
|
||||
appendSyncableBody(cwd, '0%');
|
||||
const statePath = path.join(planningDirOf(cwd), 'STATE.md');
|
||||
|
||||
stateSyncRaw(cwd);
|
||||
const after = fs.readFileSync(statePath, 'utf-8');
|
||||
|
||||
// Body half: the Progress: bar line must still read 0%, never 100%.
|
||||
assert.ok(/Progress: \[░{10}\] 0%/.test(after), 'body Progress line must remain at 0%');
|
||||
|
||||
// Frontmatter half: syncStateFrontmatter (called from writeStateMd on
|
||||
// every write, including this one) regenerates the YAML block via the
|
||||
// already-fixed buildStateFrontmatter — its progress: sub-block must
|
||||
// omit `percent` entirely on this non-COMPLETE fixture. Before this fix
|
||||
// the two halves could disagree within the SAME write: frontmatter
|
||||
// (already scoped) omitted percent while the body (hardcoded
|
||||
// SCOPE.COMPLETE) rendered one.
|
||||
const fmMatch = after.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
assert.ok(fmMatch, 'STATE.md must carry a frontmatter block after a write');
|
||||
assert.ok(!/^\s*percent:/m.test(fmMatch[1]), 'frontmatter progress: block must not carry a percent key');
|
||||
|
||||
// The self-contradiction shape this test guards against: body renders a
|
||||
// percent while frontmatter's own progress: block has none (or vice
|
||||
// versa). Assert directly that no percent digit sequence beyond the
|
||||
// untouched-0% line's own "0%" appears anywhere else in the body.
|
||||
const percentTokens = after.match(/\d+%/g) || [];
|
||||
assert.deepStrictEqual(percentTokens, ['0%'], `no other percentage may appear anywhere in STATE.md, got: ${JSON.stringify(percentTokens)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// The guard — narrow syntactic check considered and DROPPED (documented)
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// The design (`40-design.md`, "The guard") named one candidate narrow,
|
||||
// syntactic structural check: a call to `listMilestonePhaseDirs(...)` whose
|
||||
// `.value` is read while `.scope` is never bound in the same function. This
|
||||
// was prototyped and REJECTED against the real tree: `src/milestone.cts` has
|
||||
// three call sites (`~697`, `~753`, `~881`) that read only `.value` from
|
||||
// `listMilestonePhaseDirs` — legitimately, because they use the directory
|
||||
// list for milestone ARCHIVING, not percentage rendering, and have nothing to
|
||||
// do with rule 3/4 at all. A purely syntactic "was `.scope` bound" check
|
||||
// cannot distinguish "this consumer renders a percentage" from "this
|
||||
// consumer does something else with the directory list" — exactly the
|
||||
// false-positive class the design's own "guard" section predicts for a
|
||||
// textual proxy over a data-flow property. Per this phase's explicit
|
||||
// instruction ("if it produces false positives on the real tree, drop it and
|
||||
// say so"), no such check was added to
|
||||
// scripts/lint-completion-ratio-drift.cjs; the existing arithmetic-detection
|
||||
// guard (rules 1-2) is unchanged, and this phase's real enforcement is the
|
||||
// consumer-identity tests in sections A-C above (ADR Decision 4b/4c).
|
||||
@@ -1052,7 +1052,12 @@ function buildStateWithCuratedProgress(opts) {
|
||||
* Only `numRealizedDirs` phase dirs will have plan/summary files on disk.
|
||||
*/
|
||||
function buildRoadmap(numPhases) {
|
||||
const lines = ['# ROADMAP', '', '## Milestone v1.0', ''];
|
||||
// #3217 (ADR-3180 §7.6 rule 4): no version token in the heading — none of
|
||||
// this section's STATE.md fixtures set a `milestone:` field, so a
|
||||
// `vX.Y`-bearing heading here would window as UNSCOPED (§7.1 row 4:
|
||||
// "has versioned milestones, but no version resolved"), not the free-form
|
||||
// COMPLETE window these tests' phase/plan counting depends on.
|
||||
const lines = ['# ROADMAP', '', '## Milestone', ''];
|
||||
for (let i = 1; i <= numPhases; i++) {
|
||||
lines.push(`### Phase ${i}: phase-${i}`);
|
||||
lines.push('');
|
||||
@@ -1196,6 +1201,11 @@ describe('#3242 Bug A: body-only state.update preserves curated progress frontma
|
||||
});
|
||||
|
||||
test('state.update "Progress" resyncs progress frontmatter from the updated body', () => {
|
||||
// #3217 (ADR-3180 §7.6 rule 4): a free-form ROADMAP.md (no version token)
|
||||
// is COMPLETE scope for windowing (§7.1) — without this, an absent
|
||||
// ROADMAP.md is UNREADABLE and the body-Progress-field resync this test
|
||||
// exercises is withheld.
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||
fs.writeFileSync(statePath, buildStateWithCuratedProgress({
|
||||
completedPlans: 22,
|
||||
|
||||
@@ -69,6 +69,12 @@ test('tools/call gsd_invoke_command: dispatches to the command hub (point 1); un
|
||||
test('tools/call gsd_invoke_command: REGRESSION #2102 — a valid read-only family dispatches for real (not the createHub()-with-no-args UnknownCommand bug)', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
// #3217 (ADR-3180 §7.6 rule 4): a free-form ROADMAP.md (no version
|
||||
// token) is COMPLETE scope for windowing (§7.1) — without this, a
|
||||
// bare temp dir has no ROADMAP.md at all (UNREADABLE) and `percent`
|
||||
// is withheld (null), breaking this reachability proxy.
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
||||
const res = handleMessage(
|
||||
{ jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name: 'gsd_invoke_command', arguments: { family: 'progress', subcommand: 'json' } } },
|
||||
{ cwd: dir },
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const gsdPiExtension = require('../pi/gsd.cjs');
|
||||
const { _internals } = require('../pi/gsd.cjs');
|
||||
@@ -62,6 +64,12 @@ test('REACHABILITY: the /gsd handler dispatches a real family through gsd-tools.
|
||||
gsdPiExtension(pi);
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
// #3217 (ADR-3180 §7.6 rule 4): a free-form ROADMAP.md (no version
|
||||
// token) is COMPLETE scope for windowing (§7.1) — without this, a
|
||||
// bare temp dir has no ROADMAP.md at all (UNREADABLE) and `percent`
|
||||
// is withheld (null), breaking this reachability proxy.
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
||||
const result = await pi._recorded.commands['gsd'].handler('progress json', { cwd: dir });
|
||||
// #2991: handler returns { content: [{ type: 'text', text }] } (Pi's display shape), not a bare string.
|
||||
assert.ok(result && Array.isArray(result.content) && result.content[0].type === 'text',
|
||||
@@ -95,6 +103,11 @@ test('REACHABILITY: the gsd_invoke tool dispatches through the engine and return
|
||||
gsdPiExtension(pi);
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
// #3217 (ADR-3180 §7.6 rule 4): see the /gsd handler reachability test
|
||||
// above — a bare temp dir has no ROADMAP.md (UNREADABLE), withholding
|
||||
// `percent`.
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
||||
const result = await pi._recorded.tools['gsd_invoke'].execute(
|
||||
'call-1',
|
||||
{ family: 'progress', subcommand: 'json' },
|
||||
|
||||
@@ -241,9 +241,13 @@ describe('roadmap analyze command', () => {
|
||||
});
|
||||
|
||||
test('parses phases with goals and disk status', () => {
|
||||
// #3217 (ADR-3180 §7.6 rule 4): no version token — no STATE.md exists to
|
||||
// resolve which milestone "v1.0" names, so a version-bearing title here
|
||||
// would window as UNSCOPED (§7.1 row 4), not the free-form COMPLETE
|
||||
// window this test's disk-status counting depends on.
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap v1.0
|
||||
`# Roadmap
|
||||
|
||||
### Phase 1: Foundation
|
||||
**Goal:** Set up infrastructure
|
||||
|
||||
@@ -134,6 +134,12 @@ describe('dispatchGsdCommand', () => {
|
||||
});
|
||||
|
||||
test('a valid read-only family/subcommand dispatches for real and returns ok:true + non-empty stdout', () => {
|
||||
// #3217 (ADR-3180 §7.6 rule 4): a free-form ROADMAP.md (no version
|
||||
// token) is COMPLETE scope for windowing (§7.1) — without this, a
|
||||
// bare temp dir has no ROADMAP.md at all (UNREADABLE) and `percent`
|
||||
// is withheld (null), breaking this reachability proxy.
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
||||
const result = dispatchGsdCommand({ family: 'progress', subcommand: 'json', cwd: tmpDir });
|
||||
assert.equal(result.ok, true, `expected ok:true, got: ${JSON.stringify(result)}`);
|
||||
assert.equal(typeof result.stdout, 'string');
|
||||
|
||||
@@ -642,6 +642,11 @@ describe('state json command', () => {
|
||||
});
|
||||
|
||||
test('builds frontmatter on-the-fly from body when no frontmatter exists', () => {
|
||||
// #3217 (ADR-3180 §7.6 rule 4): a free-form ROADMAP.md (no version token)
|
||||
// is COMPLETE scope for windowing (§7.1) — without this, an absent
|
||||
// ROADMAP.md is UNREADABLE and the body-Progress-field fallback this
|
||||
// test exercises is withheld.
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State
|
||||
@@ -1665,6 +1670,11 @@ describe('cmdStateUpdateProgress (state update-progress)', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createFixture();
|
||||
// #3217 (ADR-3180 §7.6 rule 4): a free-form ROADMAP.md (no version token)
|
||||
// is COMPLETE scope for windowing (§7.1) — without this, an absent
|
||||
// ROADMAP.md is UNREADABLE and state update-progress withholds
|
||||
// (updated:false) instead of computing a percent.
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -2321,6 +2331,11 @@ describe('progress counters correct after plan execution (#1589)', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createFixture();
|
||||
// #3217 (ADR-3180 §7.6 rule 4): a free-form ROADMAP.md (no version token)
|
||||
// is COMPLETE scope for windowing (§7.1) — without this, an absent
|
||||
// ROADMAP.md is UNREADABLE and the disk-derived percent this describe
|
||||
// exercises is withheld.
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -9953,7 +9968,12 @@ function writeStateFile(tmpDir, overrides = {}) {
|
||||
* filter includes them (avoids needing a milestone header to count phases).
|
||||
*/
|
||||
function writeRoadmap(tmpDir, phaseNums) {
|
||||
const lines = ['## Roadmap v1.0'];
|
||||
// #3217 (ADR-3180 §7.6 rule 4): no version token — none of this helper's
|
||||
// callers write a STATE.md `milestone:` field, so a `vX.Y`-bearing heading
|
||||
// here would window as UNSCOPED (§7.1 row 4: "has versioned milestones,
|
||||
// but no version resolved"), not the free-form COMPLETE window the
|
||||
// percent/count assertions below depend on.
|
||||
const lines = ['## Roadmap'];
|
||||
for (const n of phaseNums) {
|
||||
lines.push('', `### Phase ${n}: Phase ${n}`);
|
||||
}
|
||||
@@ -10474,6 +10494,11 @@ describe('cmdStateSync nested plans/ layout (#3257)', () => {
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent, 'utf-8');
|
||||
// #3217 (ADR-3180 §7.6 rule 4): a free-form ROADMAP.md (no version token)
|
||||
// is COMPLETE scope for windowing (§7.1) — without this, an absent
|
||||
// ROADMAP.md is UNREADABLE and the Progress: field this test asserts on
|
||||
// is withheld ("milestone phase scope is unreadable, not COMPLETE").
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
||||
|
||||
const result = runGsdTools('state sync', tmpDir);
|
||||
assert.ok(result.success, `state sync failed: ${result.error}`);
|
||||
|
||||
@@ -24,6 +24,7 @@ const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const Module = require('node:module');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const extension = require('../vscode/extension.js');
|
||||
const { activate, dispatchGsdCommand, resolveEngineRoot, resolveWorkspaceCwd } = extension;
|
||||
@@ -39,6 +40,12 @@ test('the extension exports activate + dispatchGsdCommand + resolveEngineRoot',
|
||||
test('REACHABILITY: dispatchGsdCommand dispatches a real family/subcommand through gsd-tools.cjs and returns REAL output (keystone wired, not UnknownCommand)', async () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
// #3217 (ADR-3180 §7.6 rule 4): a free-form ROADMAP.md (no version
|
||||
// token) is COMPLETE scope for windowing (§7.1) — without this, a
|
||||
// bare temp dir has no ROADMAP.md at all (UNREADABLE) and `percent`
|
||||
// is withheld (null), breaking this reachability proxy.
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
||||
const result = await dispatchGsdCommand({ family: 'progress', subcommand: 'json', cwd: dir });
|
||||
assert.equal(typeof result, 'string', 'returns a string result');
|
||||
const parsed = JSON.parse(result);
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const pkg = require('../vscode/package.json');
|
||||
const extension = require('../vscode/extension.js');
|
||||
@@ -32,7 +34,15 @@ class FakeToolResult {
|
||||
constructor(parts) { this.parts = parts; }
|
||||
}
|
||||
|
||||
function mockVscodeLm() {
|
||||
// `workspaceCwd`: createLanguageModelTool's invoke() resolves cwd via
|
||||
// resolveWorkspaceCwd(vscode) — vscode.workspace.workspaceFolders[0].uri.fsPath
|
||||
// — NOT via a `cwd` field on the invoke() options object (LanguageModelToolInvocationOptions
|
||||
// has no such field on the real API; see extension.js's #2103 FIX comment on
|
||||
// createLanguageModelTool). A test that wants a real dispatch to run against a
|
||||
// fixture directory must mock workspace.workspaceFolders here, not pass
|
||||
// `{ cwd }` in the options object handed to invoke() (that field is simply
|
||||
// never read).
|
||||
function mockVscodeLm(workspaceCwd) {
|
||||
const registered = [];
|
||||
return {
|
||||
lm: {
|
||||
@@ -41,6 +51,9 @@ function mockVscodeLm() {
|
||||
return { dispose() {} };
|
||||
},
|
||||
},
|
||||
workspace: workspaceCwd
|
||||
? { workspaceFolders: [{ uri: { fsPath: workspaceCwd } }] }
|
||||
: undefined,
|
||||
LanguageModelTextPart: FakeTextPart,
|
||||
LanguageModelToolResult: FakeToolResult,
|
||||
registered,
|
||||
@@ -89,11 +102,20 @@ test('REACHABILITY (desktop): registerLanguageModelTools registers every manifes
|
||||
test('REACHABILITY (desktop): gsd_progress tool.invoke() dispatches through the hub and returns REAL output', async () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
const mock = mockVscodeLm();
|
||||
// #3217 (ADR-3180 §7.6 rule 4): a free-form ROADMAP.md (no version
|
||||
// token) is COMPLETE scope for windowing (§7.1) — without this, a
|
||||
// bare temp dir has no ROADMAP.md at all (UNREADABLE) and `percent`
|
||||
// is withheld (null), breaking this reachability proxy.
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
||||
// The LM tool's invoke() resolves cwd via vscode.workspace.workspaceFolders
|
||||
// (resolveWorkspaceCwd), not via an options.cwd field — pass the fixture
|
||||
// dir through the mock's workspace so dispatch actually runs against it.
|
||||
const mock = mockVscodeLm(dir);
|
||||
extension.registerLanguageModelTools(mock, { subscriptions: [] });
|
||||
const progressTool = mock.registered.find((r) => r.name === 'gsd_progress');
|
||||
assert.ok(progressTool, 'gsd_progress must be registered');
|
||||
const result = await progressTool.impl.invoke({ input: {}, cwd: dir }, {});
|
||||
const result = await progressTool.impl.invoke({ input: {} }, {});
|
||||
assert.ok(result instanceof FakeToolResult, 'invoke must return a LanguageModelToolResult');
|
||||
assert.ok(Array.isArray(result.parts) && result.parts.length === 1);
|
||||
assert.ok(result.parts[0] instanceof FakeTextPart, 'result part must be a LanguageModelTextPart');
|
||||
@@ -107,11 +129,14 @@ test('REACHABILITY (desktop): gsd_progress tool.invoke() dispatches through the
|
||||
test('REACHABILITY (desktop): gsd_plan_phase tool.invoke() forwards the "phase" input through dispatch (real, not UnknownCommand)', async () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
const mock = mockVscodeLm();
|
||||
// The LM tool's invoke() resolves cwd via vscode.workspace.workspaceFolders
|
||||
// (resolveWorkspaceCwd), not via an options.cwd field — pass the fixture
|
||||
// dir through the mock's workspace so dispatch actually runs against it.
|
||||
const mock = mockVscodeLm(dir);
|
||||
extension.registerLanguageModelTools(mock, { subscriptions: [] });
|
||||
const planPhaseTool = mock.registered.find((r) => r.name === 'gsd_plan_phase');
|
||||
assert.ok(planPhaseTool);
|
||||
const result = await planPhaseTool.impl.invoke({ input: { phase: 'nonexistent-phase-8675309' }, cwd: dir }, {});
|
||||
const result = await planPhaseTool.impl.invoke({ input: { phase: 'nonexistent-phase-8675309' } }, {});
|
||||
const parsed = JSON.parse(result.parts[0].text);
|
||||
// Real dispatch reaches gsd-tools.cjs and returns a structured "phase not
|
||||
// found" response (proves the engine was reached) — not the manifest's
|
||||
@@ -126,10 +151,13 @@ test('REACHABILITY (desktop): gsd_plan_phase tool.invoke() forwards the "phase"
|
||||
test('gsd_workstreams tool.invoke() dispatches through the hub and returns REAL output', async () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
const mock = mockVscodeLm();
|
||||
// The LM tool's invoke() resolves cwd via vscode.workspace.workspaceFolders
|
||||
// (resolveWorkspaceCwd), not via an options.cwd field — pass the fixture
|
||||
// dir through the mock's workspace so dispatch actually runs against it.
|
||||
const mock = mockVscodeLm(dir);
|
||||
extension.registerLanguageModelTools(mock, { subscriptions: [] });
|
||||
const wsTool = mock.registered.find((r) => r.name === 'gsd_workstreams');
|
||||
const result = await wsTool.impl.invoke({ input: {}, cwd: dir }, {});
|
||||
const result = await wsTool.impl.invoke({ input: {} }, {});
|
||||
const parsed = JSON.parse(result.parts[0].text);
|
||||
assert.ok('workstreams' in parsed || 'mode' in parsed, 'expected a real workstream list response shape');
|
||||
} finally {
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const extension = require('../vscode/extension.js');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
@@ -74,6 +76,12 @@ test('registerSubagentDispatch: available:false (fail-soft) when vscode.workspac
|
||||
test('REACHABILITY: a background-eligible dispatchAsSubagent call at depth 0 dispatches through the shared hub and returns REAL output', async () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
// #3217 (ADR-3180 §7.6 rule 4): a free-form ROADMAP.md (no version
|
||||
// token) is COMPLETE scope for windowing (§7.1) — without this, a
|
||||
// bare temp dir has no ROADMAP.md at all (UNREADABLE) and `percent`
|
||||
// is withheld (null), breaking this reachability proxy.
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
||||
const result = JSON.parse(await extension.dispatchAsSubagent({
|
||||
family: 'progress', subcommand: 'json', cwd: dir, depth: 0,
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user