Merge remote-tracking branch 'origin/next' into test/3057-wave2-liveness

This commit is contained in:
sim
2026-08-05 20:56:14 -04:00
20 changed files with 356 additions and 31 deletions

View File

@@ -8,7 +8,8 @@ const fs = require('node:fs');
const os = require('node:os');
const cp = require('node:child_process');
const { evaluateLint, LINT_REASON } = require(path.join(__dirname, '..', 'scripts', 'changeset', 'lint.cjs'));
const { evaluateLint, LINT_REASON, DEFAULT_BASE: CHANGESET_DEFAULT_BASE } = require(path.join(__dirname, '..', 'scripts', 'changeset', 'lint.cjs'));
const { DEFAULT_BASE: DOCS_DEFAULT_BASE } = require(path.join(__dirname, '..', 'scripts', 'lint-docs-required.cjs'));
const ROOT = path.join(__dirname, '..');
const LINT_SCRIPT = path.join(ROOT, 'scripts', 'changeset', 'lint.cjs');
@@ -297,3 +298,19 @@ describe('changeset lint: main() end-to-end wiring (#1006)', () => {
assert.ok(!deletedEntry, `deleted fragment must not appear in failures, got: ${JSON.stringify(failures)}`);
});
});
// ─── #2988: local base fallback parity ──────────────────────────────────────
describe('#2988: changeset + docs lints resolve the same local base fallback', () => {
test('both lints default to `next` (the integration branch), not `main`', () => {
assert.strictEqual(CHANGESET_DEFAULT_BASE, 'next',
`changeset lint DEFAULT_BASE must be 'next', got '${CHANGESET_DEFAULT_BASE}'`);
assert.strictEqual(DOCS_DEFAULT_BASE, 'next',
`docs lint DEFAULT_BASE must be 'next', got '${DOCS_DEFAULT_BASE}'`);
});
test('both lints resolve the same base given the same environment (parity)', () => {
assert.strictEqual(CHANGESET_DEFAULT_BASE, DOCS_DEFAULT_BASE,
`the two lints must not diverge on base resolution: changeset='${CHANGESET_DEFAULT_BASE}' docs='${DOCS_DEFAULT_BASE}'`);
});
});

View File

@@ -183,11 +183,18 @@ describe('runMain', () => {
describe('regressions', () => {
/** Spawn a one-shot script that sets json-error mode and calls runMain with a throwing handler. */
function spawnJsonErrorRun({ jsonMode, errorType = 'TypeError', message = 'unexpected boom' } = {}) {
// ExitError lives in the same module as runMain; import it when the test
// wants to exercise the ExitError carve-out path. ExitError takes (code, message).
const isExitError = errorType === 'ExitError';
const destructure = isExitError ? '{ runMain, ExitError }' : '{ runMain }';
const throwExpr = isExitError
? `new ExitError(1, ${JSON.stringify(message)})`
: `new ${errorType}(${JSON.stringify(message)})`;
const script = `
const io = require(${JSON.stringify(IO_PATH)});
const { runMain } = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});
const ${destructure} = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)});
io.setJsonErrorMode(${jsonMode ? 'true' : 'false'});
runMain(() => { throw new ${errorType}(${JSON.stringify(message)}); });
runMain(() => { throw ${throwExpr}; });
setImmediate(() => {});
`;
return spawnSync(process.execPath, ['-e', script], { encoding: 'utf-8' });
@@ -245,5 +252,35 @@ describe('regressions', () => {
`expected "unexpected boom" in stderr, got: ${stderrTrimmed.slice(0, 200)}`
);
});
// #2979: characterization test pinning the two error paths under json-errors
// mode. The structured envelope covers non-ExitError failures; ExitError
// (usage errors) intentionally emits plain text with its own exit code.
// Both halves asserted together so the code cannot drift toward the doc's
// prior overstated claim that EVERY error emits JSON.
test('#2979: ExitError emits plain text (not JSON) even under --json-errors; non-ExitError emits the envelope', () => {
// ExitError path: plain text, own exit code, NOT a JSON object.
const exitResult = spawnJsonErrorRun({
jsonMode: true,
errorType: 'ExitError',
message: 'Usage: gsd-tools <command> [args]',
});
assert.strictEqual(exitResult.status, 1, 'ExitError exits with its code');
const exitStderr = exitResult.stderr.trim();
let exitParsed = null;
try { exitParsed = JSON.parse(exitStderr); } catch { /* expected — plain text */ }
assert.strictEqual(exitParsed, null,
`ExitError must emit plain text, not JSON; got: ${exitStderr.slice(0, 200)}`);
assert.ok(exitStderr.includes('Usage'),
`ExitError plain-text message must reach stderr; got: ${exitStderr.slice(0, 200)}`);
// Non-ExitError path: structured JSON envelope.
const envResult = spawnJsonErrorRun({ jsonMode: true });
assert.strictEqual(envResult.status, 1);
const envParsed = JSON.parse(envResult.stderr.trim());
assert.strictEqual(envParsed.ok, false);
assert.strictEqual(envParsed.reason, 'sdk_fail_fast');
assert.ok(envParsed.message, 'envelope must carry a message');
});
});
});

View File

@@ -0,0 +1,6 @@
{
"version": 1,
"paths": {
"code-review.md": "#2989: the diff-base fallback's git log --grep was changed from an unanchored bare phase number (matching version strings, dates, issue refs) to an anchored '[Pp]hase N\\b' with --extended-regexp, plus a 5-line comment explaining the anchor. Makes the fail-closed branch reachable when no commit genuinely references the phase."
}
}

View File

@@ -89,7 +89,8 @@ describe('W021 — milestone-prefixed phase ID convention', () => {
]);
const result = runGsdTools(['roadmap', 'validate'], tmpDir);
assert.ok(result.success, `roadmap validate should exit 0 even with warnings: ${result.error}`);
// #2978: validate now exits non-zero on any warning (per its documented contract).
assert.strictEqual(result.success, false, `roadmap validate must exit non-zero on W021 warnings: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(Array.isArray(out.warnings), 'output.warnings should be an array');
@@ -219,7 +220,8 @@ describe('W021 — milestone-prefixed phase ID convention', () => {
]);
const result = runGsdTools(['roadmap', 'validate'], tmpDir);
assert.ok(result.success, `roadmap validate failed: ${result.error}`);
// #2978: validate now exits non-zero on any warning (per its documented contract).
assert.strictEqual(result.success, false, `roadmap validate must exit non-zero on W021 warnings: ${result.error}`);
const out = JSON.parse(result.output);
const w021 = (out.warnings || []).filter(w => w.code === 'W021');

View File

@@ -63,23 +63,28 @@ test('REACHABILITY: the /gsd handler dispatches a real family through gsd-tools.
const dir = createTempDir();
try {
const result = await pi._recorded.commands['gsd'].handler('progress json', { cwd: dir });
assert.equal(typeof result, 'string', '/gsd handler returns a string result');
const parsed = JSON.parse(result);
// #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',
`/gsd handler must return Pi's display shape { content: [{ type: 'text', text }] }; got: ${JSON.stringify(result).slice(0, 200)}`);
const parsed = JSON.parse(result.content[0].text);
assert.equal(typeof parsed.percent, 'number', '/gsd dispatch reached gsd-tools.cjs for real (the engine was reached)');
} finally {
cleanup(dir);
}
});
test('REACHABILITY: an unknown family surfaces a clear GSD error string, not a throw', async () => {
test('REACHABILITY: an unknown family surfaces a clear GSD error, not a throw', async () => {
const pi = mockPi();
gsdPiExtension(pi);
const dir = createTempDir();
try {
const result = await pi._recorded.commands['gsd'].handler('no-such-family-8675309', { cwd: dir });
assert.equal(typeof result, 'string');
assert.match(result, /GSD error:/);
assert.match(result, /no-such-family-8675309|Unknown command/);
// #2991: handler returns { content: [{ type: 'text', text }] } (Pi's display shape).
assert.ok(result && Array.isArray(result.content) && result.content[0].type === 'text',
`error result must carry Pi's display shape; got: ${JSON.stringify(result).slice(0, 200)}`);
const text = result.content[0].text;
assert.match(text, /GSD error:/);
assert.match(text, /no-such-family-8675309|Unknown command/);
} finally {
cleanup(dir);
}

View File

@@ -3612,3 +3612,79 @@ describe('bug #1103 — annotate-dependencies preserves newline before Plans: he
});
});
}
// ─────────────────────────────────────────────────────────────────────────────
// bug #2978: roadmap validate returns {"warnings":[]} for every input
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #2978: roadmap validate performs structural validation', () => {
test('empty (zero-byte) ROADMAP.md → non-empty warnings, non-zero exit', () => {
const tmpDir = createTempProject('gsd-2978-empty-');
try {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '');
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.strictEqual(result.success, false, 'empty file must exit non-zero');
const payload = JSON.parse(result.output);
assert.ok(Array.isArray(payload.warnings) && payload.warnings.length > 0,
`empty file must produce non-empty warnings; got: ${JSON.stringify(payload)}`);
} finally { cleanup(tmpDir); }
});
test('garbage/non-roadmap text → non-empty warnings, non-zero exit', () => {
const tmpDir = createTempProject('gsd-2978-garbage-');
try {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'not a roadmap at all');
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.strictEqual(result.success, false, 'garbage must exit non-zero');
const payload = JSON.parse(result.output);
assert.ok(payload.warnings.length > 0, 'garbage must produce warnings');
} finally { cleanup(tmpDir); }
});
test('missing ROADMAP.md → non-empty warnings, non-zero exit', () => {
const tmpDir = createTempProject('gsd-2978-missing-');
try {
// createTempProject creates .planning/phases but no ROADMAP.md — don't write one
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.strictEqual(result.success, false, 'missing file must exit non-zero');
const payload = JSON.parse(result.output);
assert.ok(payload.warnings.length > 0, 'missing file must produce warnings');
} finally { cleanup(tmpDir); }
});
test('truncated frontmatter (unterminated ---) → non-empty warnings', () => {
const tmpDir = createTempProject('gsd-2978-trunc-');
try {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
'---\nmilestone: v1.0\n### Phase 1: Setup\n');
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.strictEqual(result.success, false, 'truncated frontmatter must exit non-zero');
const payload = JSON.parse(result.output);
assert.ok(payload.warnings.length > 0, 'truncated frontmatter must produce warnings');
} finally { cleanup(tmpDir); }
});
test('well-formed roadmap → warnings: [], exit 0 (no false positive)', () => {
const tmpDir = createTempProject('gsd-2978-good-');
try {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## v1.0\n\n### Phase 1: Foundation\n**Goal:** setup\n');
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.ok(result.success, `well-formed roadmap must exit 0; got: ${result.error}`);
const payload = JSON.parse(result.output);
assert.deepStrictEqual(payload.warnings, [], 'well-formed roadmap must have no warnings');
} finally { cleanup(tmpDir); }
});
test('BOM-prefixed well-formed roadmap → warnings: [], exit 0 (not corruption)', () => {
const tmpDir = createTempProject('gsd-2978-bom-');
try {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
'\uFEFF# Roadmap\n\n### Phase 1: Foundation\n**Goal:** setup\n');
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.ok(result.success, `BOM-prefixed roadmap must exit 0; got: ${result.error}`);
const payload = JSON.parse(result.output);
assert.deepStrictEqual(payload.warnings, [], 'BOM is not corruption');
} finally { cleanup(tmpDir); }
});
});

View File

@@ -1340,6 +1340,61 @@ describe('ADR-1769 #1796: applyStatePreservation — table-driven post-sync cons
'total_plans equality → derived value (identity)');
});
test('#2969: deriveProgressKeys=true — completed_plans ratchets UP when disk count exceeds curated (gap-closure plans completed)', () => {
// Gap-closure scenario: a phase had 50 plans all summarized (completed_plans: 50),
// then 4 gap-closure plans were added (total_plans -> 54) and all 4 got SUMMARYs.
// Disk scan now counts 54 summaries. The curated completed_plans (50) must
// ratchet UP to the derived value (54), not stay pinned at 50 — otherwise
// completed_plans < total_plans forever even though every plan is summarized.
const curated = { progress: { total_plans: 54, completed_plans: 50, total_phases: 2, completed_phases: 1, percent: 93 } };
const r = applyStatePreservation({
preFm: curated,
preFmSnapshot: curated,
postFm: { progress: { total_plans: 54, completed_plans: 54, total_phases: 2, completed_phases: 1, percent: 100 } },
resync: false,
deriveProgressKeys: true,
...untouched,
});
assert.equal(r.postFm.progress.total_plans, 54, 'total_plans takes derived value');
assert.equal(r.postFm.progress.completed_plans, 54,
'completed_plans must ratchet UP to derived (54 > curated 50 — gap-closure plans completed) (#2969)');
assert.equal(r.postFm.progress.percent, 100,
'percent must reflect the true completion fraction (54/54) (#2969)');
});
test('#2969 ratchet-down protection: deriveProgressKeys=true keeps curated when disk count < curated', () => {
// The ratchet must only go UP. If the disk count is somehow LOWER than
// curated (e.g. a SUMMARY was deleted), keep the curated value — do not
// derive downward. (#3242 curated-progress protection, scoped to deriveProgressKeys.)
const curated = { progress: { total_plans: 54, completed_plans: 50, percent: 93 } };
const r = applyStatePreservation({
preFm: curated,
preFmSnapshot: curated,
postFm: { progress: { total_plans: 54, completed_plans: 47, percent: 87 } },
resync: false,
deriveProgressKeys: true,
...untouched,
});
assert.equal(r.postFm.progress.completed_plans, 50,
'completed_plans must NOT derive downward (47 < curated 50) — ratchet-up only (#2969/#3242)');
});
test('#2969 body-only write protection: deriveProgressKeys absent keeps wholesale restore', () => {
// state.update/patch (no deriveProgressKeys flag) must keep the full #3242
// wholesale curated restore — completed_plans never moves for a body-only edit.
const curated = { progress: { total_plans: 54, completed_plans: 50, percent: 93 } };
const r = applyStatePreservation({
preFm: curated,
preFmSnapshot: curated,
postFm: { progress: { total_plans: 54, completed_plans: 54, percent: 100 } },
resync: false,
// deriveProgressKeys NOT set — body-only write path
...untouched,
});
assert.equal(r.postFm.progress.completed_plans, 50,
'body-only write must keep curated completed_plans (no deriveProgressKeys) (#2969/#3242)');
});
test('progress: NOT restored when transition re-derives from disk (resync=true) — sync/advancePlan/completePhase path', () => {
const recomputed = { progress: { total_phases: 5, completed_phases: 1, percent: 20 } };
const r = applyStatePreservation({