Merge remote-tracking branch 'origin/next' into test/3057-wave2-liveness
This commit is contained in:
@@ -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}'`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user