fix(#4488): report state update as successful when the value is already correct (#4581)

* fix(#4488): report state update as successful when the value is already correct

`cmdStateUpdate` unconditionally overwrote `updateCore`'s own `updated:true`
signal with `reconcileReportedFields`'s disk-diff result. That diff reports
`[]` -- by design -- whenever `readModifyWriteStateMd`'s #948 no-op guard
fires because the transform's output was byte-identical to the input, which
happens precisely when the requested value already equals what's on disk.
The field genuinely was found and matched; there was simply nothing left to
change. Collapsing that into the same `false`/"not found" response as a
genuine miss produced an actively wrong diagnostic message and a silent
same-day no-op in gsd-ship + gsd-extract-learnings, which both write
`Last Activity` to today's date.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4488): backfill changeset pr number to 4581

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4488): untrack tdd-red-evidence.cjs, completing its ADR-457 gitignore migration

Bundled discovery from this PR's own CI run: tests/lint-compiled-artifact-
sync.test.cjs's full tsc compile (which runs whenever ANY compiled artifact
remains tracked) SIGTERM'd under shard contention. gsd-core/bin/lib/tdd-red-
evidence.cjs (introduced by #3770/PR #4279) was the sole remaining tracked
artifact -- a tenth, later, separate instance of the #2657/#2653
migration-gap defect class this test file's closed nine-item list doesn't
cover. Untracked it and added the .gitignore entry, same fix shape as the
original nine. This eliminates the slow tsc-compile path entirely (verified:
0.1s vs ~7s locally) rather than papering over a timeout. Added a generic
regression test asserting the tracked set is fully empty.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-09-09 18:32:54 -04:00
committed by GitHub
parent 615b74ff45
commit 7fe440a838
6 changed files with 221 additions and 142 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 4581
---
**`state update` no longer reports a same-value write as a missing field** — updating `Last Activity` (or any other body-sourced frontmatter key) to the value it already holds reported `updated: false` with a "not found in STATE.md" message telling the caller to add a line that was already there at the correct value. Any day `gsd-ship` runs before `gsd-extract-learnings`, both write today's date to the same field, so the second call always hit this. (#4488)

1
.gitignore vendored
View File

@@ -314,6 +314,7 @@ build/
/gsd-core/bin/lib/git-base-branch.cjs
/gsd-core/bin/lib/host-runtime-detection.cjs
/gsd-core/bin/lib/task-content-resolution.cjs
/gsd-core/bin/lib/tdd-red-evidence.cjs
__pycache__/
*.pyc
.venv/

View File

@@ -1,133 +0,0 @@
"use strict";
/**
* TDD RED-evidence classification (#3770).
*
* The `type: tdd` executor gate previously accepted ANY nonzero test command as
* RED: syntax errors, zero-test discovery, fixture crashes, parser errors, and
* unrelated assertions all authorized production edits (GREEN). This module
* defines the compact RED evidence the gate now requires — the TARGET test's
* identity plus a matching assertion failure — and classifies a persisted test
* run into exactly one verdict:
*
* RED_EVIDENCE_OK — nonzero exit AND the target test failed as a REAL test
* (distinctly named, TAP-reported failure). The ONLY
* verdict that may advance to GREEN.
* INVALID_RED — everything else, with a machine-readable reason:
* unexpected_green | zero_tests_discovered |
* nonzero_exit_without_test_failure |
* fixture_or_load_failure | no_target_test_failure |
* invalid_record | unreadable_record (router arm).
*
* Everything here is PURE — no fs, no spawn, no clock — so the executor can
* persist the record (command, exit code, failing test, expected, actual) and
* validate it via `gsd_run check tdd-red-evidence <record.json>`.
*
* TAP parsing reuses the proven primitives from prohibition-enforcement
* (`parseNodeTestSummary`, `tapFailedTestNames`) — the same contract the
* prohibition probe's fail-first prover already relies on (#1259).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.classifyRedEvidence = classifyRedEvidence;
exports.buildRedEvidenceRecord = buildRedEvidenceRecord;
const prohibition_enforcement_cjs_1 = require("./prohibition-enforcement.cjs");
/** Basename of a path-like string ('' for non-strings) — separators `/` and `\`. */
function baseOf(p) {
return typeof p === 'string' ? (p.split(/[\\/]/).pop() ?? p) : '';
}
/** Coerce and validate the raw record's scalar fields. Returns null exit_code only when absent/non-numeric. */
function readInput(input) {
const command = typeof input?.command === 'string' ? input.command : '';
const output = typeof input?.output === 'string' ? input.output : '';
const targetTest = typeof input?.targetTest === 'string' ? input.targetTest.trim() : '';
const exitCode = typeof input?.exitCode === 'number' && Number.isFinite(input.exitCode) ? input.exitCode : null;
if (!command || !targetTest || exitCode === null)
return null;
return { command, exitCode, output, targetTest };
}
/**
* Classify a persisted RED-phase test run. Fail-closed: malformed input, an
* unparseable/empty TAP summary, a file-named (load/crash) failure, or a
* failure that is not the target test's are all INVALID_RED — only a nonzero
* exit WITH the distinctly-named target test failing is RED_EVIDENCE_OK.
* Never throws.
*/
function classifyRedEvidence(input) {
const parsed = readInput(input);
if (!parsed) {
return {
verdict: 'INVALID_RED',
reason: 'invalid_record',
evidence: {
command: typeof input?.command === 'string' ? input.command : '',
exit_code: null,
target_test: '',
tests: 0,
pass: 0,
fail: 0,
failing_tests: [],
},
};
}
const { command, exitCode, output, targetTest } = parsed;
const summary = (0, prohibition_enforcement_cjs_1.parseNodeTestSummary)(output);
const failing = (0, prohibition_enforcement_cjs_1.tapFailedTestNames)(output);
const evidence = {
command,
exit_code: exitCode,
target_test: targetTest,
tests: summary.tests,
pass: summary.pass,
fail: summary.fail,
failing_tests: failing,
};
// Existing fail-fast rule, now machine-checked: exit 0 during RED is an
// unexpected GREEN — the feature may already exist or the test is wrong.
if (exitCode === 0) {
return { verdict: 'INVALID_RED', reason: 'unexpected_green', evidence };
}
// Zero-test discovery: the discovery pattern / fixture matched no tests.
// A run that executed nothing cannot prove anything about the behavior.
if (summary.tests === 0) {
return { verdict: 'INVALID_RED', reason: 'zero_tests_discovered', evidence };
}
// Nonzero exit but TAP reports no failing test: harness/setup/parser crash
// whose failure never reached a test assertion (or unparseable output).
if (summary.fail === 0 || failing.length === 0) {
return { verdict: 'INVALID_RED', reason: 'nonzero_exit_without_test_failure', evidence };
}
// Fixture/load failure: every failing entry is named like the target FILE —
// node reports a load-time crash (throw-on-require, syntax error, ENOENT
// fixture) as a file-named `not ok 1 - <file>`, never the target test.
const targetBase = baseOf(input?.targetFile ?? '');
const distinctlyNamed = failing.filter((n) => (targetBase ? baseOf(n) !== targetBase : true));
if (distinctlyNamed.length === 0) {
return { verdict: 'INVALID_RED', reason: 'fixture_or_load_failure', evidence };
}
// Unrelated failure: real tests ran and failed, but none is the target test
// the plan named — an unrelated assertion must not authorize GREEN.
if (!distinctlyNamed.includes(targetTest)) {
return { verdict: 'INVALID_RED', reason: 'no_target_test_failure', evidence };
}
return { verdict: 'RED_EVIDENCE_OK', reason: 'target_test_failed', evidence };
}
/**
* Project a classification into the persisted record shape — command, exit
* code, failing test, expected, actual, verdict, reason — so the evidence
* survives past the terminal and the gate can re-verify it deterministically.
* Pure: JSON-serializable, no timestamps (the record's mtime/commit carries time).
*/
function buildRedEvidenceRecord(input, result) {
const failingTest = result.evidence.failing_tests.find((n) => n === result.evidence.target_test) ??
result.evidence.failing_tests[0] ??
null;
return {
command: result.evidence.command,
exit_code: result.evidence.exit_code,
failing_test: failingTest,
target_test: result.evidence.target_test,
expected: typeof input?.expected === 'string' ? input.expected : null,
actual: typeof input?.actual === 'string' ? input.actual : null,
verdict: result.verdict,
reason: result.reason,
};
}

View File

@@ -808,7 +808,31 @@ function cmdStateUpdate(cwd: string, field: string | undefined, value: string |
// (#3345's direction) — reported separately from `updated` because this
// command's contract is a single-field boolean, not a per-field array.
const reconciled = reconcileReportedFields(statePath, preWriteState, updated ? [field as string] : [], divergedFields);
updated = reconciled.includes(field as string);
// #4488: `updateCore` itself already told us whether it matched the field
// (`updated`, captured above `readModifyWriteStateMd` runs it) — that is a
// real signal, not a guess. `reconcileReportedFields` answers a DIFFERENT
// question ("what changed on disk") and, per its own docstring, reports
// `[]` whenever `preWriteState.fm` is `undefined`. That happens in two
// known cases, both of which mean "no snapshot was ever captured", not
// "nothing happened": (a) `readModifyWriteStateMd`'s #948 no-op guard
// fires because the transform's output was byte-identical to the input —
// the requested value already equals what's on disk, so the field WAS
// found and there was simply nothing left to change; (b)
// `applyPostSyncPreservation`'s `isUnparseableFrontmatter` early return —
// the ORIGINAL frontmatter block was malformed, so preservation never
// runs, yet `readModifyWriteStateMd` still persists the transform's raw
// output via `platformWriteSync`. In neither case did preservation
// discard or rewrite what the transform wrote, so trusting the
// transform's own `updated` signal here is never a false positive.
// Collapsing either case into the same `false` as "field not found" is
// the #4488 bug — `explainUpdateFailure` then reports a message that is
// actively false (it tells the caller to add a line that is already
// there). Every other `false` origin (case-D fallback did not apply, or
// the transform genuinely found nothing) is unaffected: there
// `preWriteState.fm` is defined (a normal sync ran) or `updated` was
// already false before this line.
const noopBecauseAlreadyCorrect = updated && preWriteState.fm === undefined;
updated = reconciled.includes(field as string) || noopBecauseAlreadyCorrect;
const preserved = reconciled.filter((f) => f !== field);
if (updated) {

View File

@@ -186,17 +186,50 @@ describe('fix-2657: compiled .cjs artifacts are gitignored, not tracked (ADR-457
// #4093 CI: this spawn is NOT the PROBE class the shared default below
// describes. The script's "nothing left to check" path still runs a FULL
// `tsc -p tsconfig.build.json` compile to a throwaway outDir whenever any
// compiled artifact remains tracked (ten are, deliberately — ADR-457's
// staged end state), and under CI shard load that compile can exceed the
// 15s probe budget, dying to a SIGTERM with empty piped stdout (observed
// twice on ubuntu shard 1/3). Per helpers/timeouts.cjs's own rule, a call
// site that genuinely differs from its class — "a real `tsc` compile" —
// keeps its own local constant with its own justifying comment; see
// tests/ensure-runtime-build.test.cjs's BUILD_TIMEOUT_MS for the other
// instance. 60s is that same class, sized for the cold-cache CI case.
// compiled artifact remains tracked, and under CI shard load that compile
// can exceed even a generous budget, dying to a SIGTERM with empty piped
// stdout (observed on ubuntu shard 1/3 both pre- and post-#4488). Per
// helpers/timeouts.cjs's own rule, a call site that genuinely differs from
// its class — "a real `tsc` compile" — keeps its own local constant with
// its own justifying comment; see tests/ensure-runtime-build.test.cjs's
// BUILD_TIMEOUT_MS for the other instance. 60s is that same class, sized
// for the cold-cache CI case.
//
// #4488: as of this fix the tracked set is fully empty (see the
// "fully-empty ADR-457 end state" test above) — trackedCompiledArtifacts()
// returning [] means main() short-circuits at its own line ~98 BEFORE ever
// invoking `compileToTemp()`, so this spawn no longer runs a real tsc
// compile at all. The 60s budget above stays as a guard for whenever a
// future module reintroduces a tracked artifact (re-widening the migration
// gap this whole file exists to catch), not because this run needs it now.
const TSC_COMPILE_TIMEOUT_MS = 60000;
const args = [path.join(REPO_ROOT, 'scripts', 'lint-compiled-artifact-sync.cjs')];
const result = run(process.execPath, args, { timeoutMs: TSC_COMPILE_TIMEOUT_MS });
assert.equal(result.status, 0, describeFailure(process.execPath, args, result));
});
test('trackedCompiledArtifacts() reports the fully-empty ADR-457 end state (#4488)', () => {
// #4488 CI: tdd-red-evidence.cjs (introduced by #3770/PR #4279, AFTER the
// original "nine" this file's other tests pin) was never gitignored —
// a tenth, later instance of the exact #2657/#2653 migration-gap defect
// class, incidentally surfaced by a #4488 CI run timing out on the real
// tsc compile this test's sibling above must run while ANY artifact stays
// tracked. Generic (not tied to the closed NINE_ARTIFACTS list above) so
// it also catches any future module that lands compiled-but-tracked.
let pairs;
try {
pairs = trackedCompiledArtifacts();
} catch (err) {
assert.fail(
`trackedCompiledArtifacts() threw instead of returning a result:\n` +
`${err && err.stack ? err.stack : err}`,
);
}
assert.deepEqual(
pairs,
[],
`expected zero tracked compiled artifacts (ADR-457 end state); still tracked: ` +
`${pairs.map((p) => p.artifact).join(', ') || '(none)'}`,
);
});
});

View File

@@ -13797,6 +13797,155 @@ describe('#905: syncStateFrontmatter preserves scalars when body annotations are
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Bug #4488: state update reports a same-value write as "field not found"
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #4488: state update reports updated:true, not a false "not found", when the new value equals the current value', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('same-value update on a template-shaped STATE.md reports updated:true, not "not found"', () => {
// Exact shape from the issue: frontmatter last_activity AND body
// "Last activity:" line already hold the value being "written". The
// transform's own stateReplaceField match succeeds and produces
// byte-identical output, which trips readModifyWriteStateMd's #948
// no-op guard before reconcileReportedFields' preWriteState snapshot is
// ever populated -- reconciliation then (correctly, for the general
// case) reports "[]", and cmdStateUpdate must not read that as "the
// field could not be found".
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, [
'---',
'gsd_state_version: "1.0"',
'current_phase: 1',
'current_phase_name: Test Phase',
'status: planning',
'last_activity: 2026-09-07',
'---',
'',
'# Project State',
'',
'## Current Position',
'',
'Phase: 1 of 1 (Test Phase)',
'Status: In progress',
'Last activity: 2026-09-07',
'',
].join('\n'), 'utf-8');
const result = runGsdTools('state update "Last Activity" "2026-09-07"', tmpDir);
assert.ok(result.success, `state update failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.strictEqual(
json.updated,
true,
`same-value update must report updated:true, not a false negative (got: ${JSON.stringify(json)})`,
);
assert.ok(
!('reason' in json),
`same-value update must not carry a "field not found" reason (got: ${JSON.stringify(json)})`,
);
// The file itself is untouched byte-for-byte (there was nothing to change).
const after = fs.readFileSync(statePath, 'utf-8');
assert.match(after, /^last_activity: 2026-09-07$/m);
assert.match(after, /^Last activity: 2026-09-07$/m);
});
test('changed-value update (control) still reports updated:true and actually rewrites the line', () => {
// Same fixture, different target date -- the pre-existing, always-worked
// path. Pins that the #4488 fix does not turn INTO a false positive for
// a real change.
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, [
'---',
'gsd_state_version: "1.0"',
'last_activity: 2026-09-07',
'---',
'',
'## Current Position',
'',
'Last activity: 2026-09-07',
'',
].join('\n'), 'utf-8');
const result = runGsdTools('state update "Last Activity" "2026-09-08"', tmpDir);
assert.ok(result.success, `state update failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.strictEqual(json.updated, true);
const after = fs.readFileSync(statePath, 'utf-8');
assert.match(after, /^Last activity: 2026-09-08$/m);
});
test('a genuinely absent field still reports updated:false with the case-D diagnostic', () => {
// Control for the other direction: this must NOT become a blanket
// "always true" -- a field with no body source line and no frontmatter
// key at all is still a real failure.
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, [
'---',
'gsd_state_version: "1.0"',
'---',
'',
'## Current Position',
'',
'Status: In progress',
'',
].join('\n'), 'utf-8');
const result = runGsdTools('state update "Last Activity" "2026-09-08"', tmpDir);
assert.ok(result.success, `state update failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.strictEqual(json.updated, false);
assert.match(json.reason, /not found in STATE\.md/);
});
test('#3699 case-D repair with a same-value frontmatter target still reports updated:true', () => {
// Case D fires when the CALLER addresses the FRONTMATTER key directly
// (e.g. "last_activity", per explainUpdateFailure's own "update
// \"last_activity\" directly to repair" instruction) on a document whose
// body has no source line at all. That repair write can ALSO be
// byte-identical to the original (the frontmatter already holds the
// target value) and trip the same #948 no-op guard as the body-label
// path above -- a second origin for the #4488 collapse, covered here so
// it doesn't regress silently.
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, [
'---',
'gsd_state_version: "1.0"',
'current_phase: 1',
'current_phase_name: Test Phase',
'status: planning',
'last_activity: 2026-09-07',
'---',
'',
'## Current Position',
'',
'Status: In progress',
'',
].join('\n'), 'utf-8');
const result = runGsdTools('state update last_activity 2026-09-07', tmpDir);
assert.ok(result.success, `state update failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.strictEqual(
json.updated,
true,
`same-value case-D repair must report updated:true (got: ${JSON.stringify(json)})`,
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Bug #1230 regression suite
// ─────────────────────────────────────────────────────────────────────────────