* enhance(#2573): stamp STATE.md with its commit and surface a commit-age freshness hint Adds a `state_head` stamp to STATE.md and derives a tri-state commit-age freshness proxy (state_commits_behind / state_commit_stale) through state.cjs's readStateHeadFreshness, surfaced on smart-entry signals and as health W024. The proxy is advisory: classify() deliberately does NOT consume it (ADR-1787 locks the classification/routing boundary — a signal, not a route). Composes with #3099 and #1882 (both merged to next after this branch): the commit-age proxy reads `state_head` while the LAST_ACTIVITY_UNPARSEABLE diagnostic reads `last_activity` — two different fields, not "two staleness signals on one field." A new regression test asserts a STATE.md carrying both an unparseable last_activity AND a valid state_head resolves each independently (diagnostic fires once; freshness reads state_head, commits_behind 0). Rebased onto next (flattened): resolved the add/add conflicts in src/smart-entry.cts (kept both the #2573 freshness import/derivation and the #3099 diagnostic import/call) and tests/smart-entry.unit.test.cjs (kept both describe blocks). Drift-ack for health.md's W024 row is unchanged (12348 B). Tests: smart-entry 62, state/state-transition/health/verify 639, all pass. * chore(#2573): allowlist health-validation test in the prompt-injection scan The scanner's `exec('` code-execution pattern matches the benign `re.exec('<phase-id>')` RegExp method calls in the phase-ID grammar tests (pre-existing: 16 such calls on next, this PR adds none). The file entered the diff-mode scan's changed-file set only because #2573's W024 state_head assertions touch it. Allowlist it alongside the other test files that carry pattern-matching content as data (same DEFECT.PROMPT-INJECTION-SCAN-COLLISION class). Scanner self-test 38/0; diff scan 14 files, 0 findings.
This commit is contained in:
6
tests/emitted-drift-acks/2573-state-head-freshness.json
Normal file
6
tests/emitted-drift-acks/2573-state-head-freshness.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"paths": {
|
||||
"health.md": "#2573 registers W024 (STATE.md written many commits ago — treat its contents as approximate) in the health workflow's <error_codes> table, so the advisory health now emits is documented where every other W-code is listed. The growth is that single table row written inline, not relocated into an eagerly @-imported reference (ADR-1610 Decision 4). 12246 -> 12348 bytes (+102), DEFAULT tier, cap 40960."
|
||||
}
|
||||
}
|
||||
@@ -1816,3 +1816,149 @@ describe('validate consistency — checklist-style roadmap phases must not emit
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── W024 (#2573): STATE.md commit-age freshness advisory ─────────────────────
|
||||
//
|
||||
// Advisory ONLY. It appends to warnings[] and must never change `status` or any
|
||||
// existing count — promoting it to a gate is a separate, disclosed change.
|
||||
//
|
||||
// Goodhart guard (why the threshold is coarse and the wording is a proxy):
|
||||
// `state_head` restamps on EVERY state write, so a low commits-behind count
|
||||
// means "something wrote STATE recently", not "STATE's content is accurate".
|
||||
// The number is a freshness proxy; the message must never assert drift.
|
||||
describe('W024 — STATE.md commit-age freshness advisory (#2573)', () => {
|
||||
const { after } = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { runGsdTools, cleanup } = require('./helpers.cjs');
|
||||
const { runGit } = require('./helpers/process-seam.cjs');
|
||||
const {
|
||||
STATE_HEAD_ADVISORY_COMMITS,
|
||||
} = require('../gsd-core/bin/lib/verify.cjs');
|
||||
|
||||
const dirs = [];
|
||||
const track = (d) => { dirs.push(d); return d; };
|
||||
after(() => { while (dirs.length) cleanup(dirs.pop()); });
|
||||
|
||||
function project({ commitsAhead, stateHead = 'BASE' }) {
|
||||
const base = track(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-h-')));
|
||||
const planningDir = path.join(base, '.planning');
|
||||
fs.mkdirSync(path.join(planningDir, 'phases'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'PROJECT.md'),
|
||||
'# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ model_profile: 'balanced' }));
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'ROADMAP.md'),
|
||||
'# Roadmap\n\n## Milestone v1.0\n\n### Phase 1: One\n**Goal:** g\n',
|
||||
);
|
||||
|
||||
runGit(['init', '-q'], { cwd: base });
|
||||
runGit(['config', 'user.email', 't@t.com'], { cwd: base });
|
||||
runGit(['config', 'user.name', 'T'], { cwd: base });
|
||||
runGit(['config', 'commit.gpgsign', 'false'], { cwd: base });
|
||||
runGit(['add', '-A'], { cwd: base });
|
||||
runGit(['commit', '-q', '-m', 'seed'], { cwd: base });
|
||||
const head = runGit(['rev-parse', 'HEAD'], { cwd: base }).stdout.trim();
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'STATE.md'),
|
||||
[
|
||||
'---',
|
||||
'status: executing',
|
||||
...(stateHead === null ? [] : [`state_head: ${stateHead === 'BASE' ? head : stateHead}`]),
|
||||
'---',
|
||||
'',
|
||||
'# State',
|
||||
'',
|
||||
'**Current Phase:** 1',
|
||||
'**Status:** In progress',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
for (let i = 0; i < commitsAhead; i++) {
|
||||
fs.writeFileSync(path.join(base, `f${i}.txt`), `${i}\n`);
|
||||
runGit(['add', '-A'], { cwd: base });
|
||||
runGit(['commit', '-q', '-m', `c${i}`], { cwd: base });
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function health(dir) {
|
||||
const r = runGsdTools(['validate', 'health', '--json'], dir);
|
||||
assert.strictEqual(r.success, true, `unexpected failure: ${r.error}`);
|
||||
return JSON.parse(r.output);
|
||||
}
|
||||
|
||||
const w024 = (d) => (d.warnings ?? []).filter((w) => w.code === 'W024');
|
||||
|
||||
test('exports a named threshold constant rather than a bare magic number', () => {
|
||||
assert.strictEqual(typeof STATE_HEAD_ADVISORY_COMMITS, 'number');
|
||||
assert.ok(STATE_HEAD_ADVISORY_COMMITS > 0);
|
||||
});
|
||||
|
||||
test('does NOT fire when STATE.md was written at HEAD', () => {
|
||||
const data = health(project({ commitsAhead: 0 }));
|
||||
const w024 = (data.warnings ?? []).filter((w) => w.code === 'W024');
|
||||
assert.strictEqual(w024.length, 0, `expected no W024, got ${JSON.stringify(w024)}`);
|
||||
});
|
||||
|
||||
test('boundary: silent at threshold-1, fires at threshold+1', () => {
|
||||
// The 0/1/20 cases alone leave the actual boundary untested — 1 is a
|
||||
// trivial-fit, not an edge. These two pin the comparison operator.
|
||||
const below = health(project({ commitsAhead: STATE_HEAD_ADVISORY_COMMITS - 1 }));
|
||||
assert.strictEqual((below.warnings ?? []).filter((w) => w.code === 'W024').length, 0,
|
||||
`must stay silent at ${STATE_HEAD_ADVISORY_COMMITS - 1} commits`);
|
||||
const above = health(project({ commitsAhead: STATE_HEAD_ADVISORY_COMMITS + 1 }));
|
||||
assert.strictEqual((above.warnings ?? []).filter((w) => w.code === 'W024').length, 1,
|
||||
`must fire at ${STATE_HEAD_ADVISORY_COMMITS + 1} commits`);
|
||||
});
|
||||
|
||||
test('does NOT fire below the threshold (a healthy project stays quiet)', () => {
|
||||
// Hyrum guard: firing on every ordinary project would change health's
|
||||
// observable "clean" state and make anything gating on clean-health noisy.
|
||||
const data = health(project({ commitsAhead: 1 }));
|
||||
const w024 = (data.warnings ?? []).filter((w) => w.code === 'W024');
|
||||
assert.strictEqual(w024.length, 0, `expected no W024 at 1 commit, got ${JSON.stringify(w024)}`);
|
||||
});
|
||||
|
||||
test('fires at/above the threshold, states the count, and stays a proxy (never asserts drift)', () => {
|
||||
const data = health(project({ commitsAhead: STATE_HEAD_ADVISORY_COMMITS }));
|
||||
const w024 = (data.warnings ?? []).filter((w) => w.code === 'W024');
|
||||
assert.strictEqual(w024.length, 1, `expected exactly one W024, got ${JSON.stringify(data.warnings)}`);
|
||||
const msg = String(w024[0].message);
|
||||
assert.ok(msg.includes(String(STATE_HEAD_ADVISORY_COMMITS)),
|
||||
`W024 must state the commit count, got: ${msg}`);
|
||||
assert.ok(/approximate/i.test(msg),
|
||||
`W024 must frame the signal as approximate (freshness proxy), got: ${msg}`);
|
||||
assert.ok(!/\bdrift(ed)?\b|\bis wrong\b|\bstale content\b/i.test(msg),
|
||||
`W024 must NOT assert drift — it is a proxy, not a measurement. Got: ${msg}`);
|
||||
});
|
||||
|
||||
test('is advisory only — lands in warnings[], never errors[] or the repair set', () => {
|
||||
// NOT "stale.status === fresh.status": these fixtures already carry W006
|
||||
// ("Phase in ROADMAP but no directory"), so both sides are `degraded`
|
||||
// regardless of W024 and that assertion can never fail. Health derives
|
||||
// status from warnings by design (warnings -> degraded), so the real
|
||||
// invariant is that W024 is a WARNING and never escalates.
|
||||
const data = health(project({ commitsAhead: STATE_HEAD_ADVISORY_COMMITS }));
|
||||
assert.strictEqual(w024(data).length, 1, 'precondition: W024 fired');
|
||||
assert.ok(
|
||||
!(data.errors ?? []).some((e) => e.code === 'W024'),
|
||||
`W024 must never appear in errors[]: ${JSON.stringify(data.errors)}`,
|
||||
);
|
||||
assert.notStrictEqual(data.status, 'broken',
|
||||
'an advisory must never break health');
|
||||
assert.ok(!w024(data)[0].repairable,
|
||||
'W024 is diagnostic, not auto-repairable — it must not enter the repair set');
|
||||
});
|
||||
|
||||
test('absent state_head → no W024 (unknown is not a finding)', () => {
|
||||
const data = health(project({ commitsAhead: 5, stateHead: null }));
|
||||
const w024 = (data.warnings ?? []).filter((w) => w.code === 'W024');
|
||||
assert.strictEqual(w024.length, 0, `unknown must stay silent, got ${JSON.stringify(w024)}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -551,6 +551,149 @@ describe('#2427 — roadmap-grounded completion + tightened status regex', () =>
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #2573: STATE.md commit-age freshness signal ─────────────────────────────
|
||||
|
||||
describe('detectSignals — state_head commit-age freshness (#2573)', () => {
|
||||
const { runGit } = require('./helpers/process-seam.cjs');
|
||||
|
||||
const dirs = [];
|
||||
const track = (d) => { dirs.push(d); return d; };
|
||||
afterEach(() => { while (dirs.length) cleanup(dirs.pop()); });
|
||||
|
||||
function gitProject(stateHead) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-se-'));
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
runGit(['init', '-q'], { cwd: dir });
|
||||
runGit(['config', 'user.email', 't@t.com'], { cwd: dir });
|
||||
runGit(['config', 'user.name', 'T'], { cwd: dir });
|
||||
runGit(['config', 'commit.gpgsign', 'false'], { cwd: dir });
|
||||
fs.writeFileSync(path.join(dir, 'seed.txt'), 'seed\n');
|
||||
runGit(['add', '-A'], { cwd: dir });
|
||||
runGit(['commit', '-q', '-m', 'seed'], { cwd: dir });
|
||||
const base = runGit(['rev-parse', 'HEAD'], { cwd: dir }).stdout.trim();
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.planning', 'STATE.md'),
|
||||
[
|
||||
'---',
|
||||
'status: executing',
|
||||
...(stateHead === null ? [] : [`state_head: ${stateHead === 'BASE' ? base : stateHead}`]),
|
||||
'---',
|
||||
'',
|
||||
'# Project State',
|
||||
'',
|
||||
'Phase: 1',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
return { dir: track(dir), base };
|
||||
}
|
||||
|
||||
function advance(dir, n) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
fs.writeFileSync(path.join(dir, `c${i}.txt`), `${i}\n`);
|
||||
runGit(['add', '-A'], { cwd: dir });
|
||||
runGit(['commit', '-q', '-m', `c${i}`], { cwd: dir });
|
||||
}
|
||||
}
|
||||
|
||||
test('state_head at HEAD → commits_behind 0, commit_stale false (known fresh)', () => {
|
||||
const { dir } = gitProject('BASE');
|
||||
const s = detectSignals(dir);
|
||||
assert.strictEqual(s.state_commits_behind, 0);
|
||||
assert.strictEqual(s.state_commit_stale, false);
|
||||
});
|
||||
|
||||
test('state_head N commits back → commits_behind N', () => {
|
||||
const { dir } = gitProject('BASE');
|
||||
advance(dir, 3);
|
||||
const s = detectSignals(dir);
|
||||
assert.strictEqual(s.state_commits_behind, 3,
|
||||
'commits_behind must count commits between state_head and HEAD');
|
||||
assert.strictEqual(s.state_commit_stale, true);
|
||||
});
|
||||
|
||||
test('missing state_head → tri-state null ("we don\'t know"), NOT false', () => {
|
||||
// Mirrors graphify's shipped commit_stale contract (src/graphify.cts:446):
|
||||
// null = unknown, distinct from false = known fresh. Collapsing unknown to
|
||||
// false would assert freshness the engine cannot actually vouch for.
|
||||
const { dir } = gitProject(null);
|
||||
const s = detectSignals(dir);
|
||||
assert.strictEqual(s.state_commits_behind, null);
|
||||
assert.strictEqual(s.state_commit_stale, null);
|
||||
});
|
||||
|
||||
test('malformed / unreachable state_head → null, never throws', () => {
|
||||
for (const bad of ['not-a-sha', 'zzzz', 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef']) {
|
||||
const { dir } = gitProject(bad);
|
||||
let s;
|
||||
assert.doesNotThrow(() => { s = detectSignals(dir); }, `${bad} must not throw`);
|
||||
assert.strictEqual(s.state_commits_behind, null, `${bad} → null`);
|
||||
assert.strictEqual(s.state_commit_stale, null, `${bad} → null`);
|
||||
}
|
||||
});
|
||||
|
||||
test('non-git project → null (no signal), never throws', () => {
|
||||
const dir = track(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-nogit-')));
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.planning', 'STATE.md'),
|
||||
['---', 'status: executing', 'state_head: abc1234', '---', '', '# Project State', ''].join('\n'),
|
||||
);
|
||||
let s;
|
||||
assert.doesNotThrow(() => { s = detectSignals(dir); });
|
||||
assert.strictEqual(s.state_commit_stale, null);
|
||||
});
|
||||
|
||||
// #2573 × #3099 composition: the commit-age freshness signal reads `state_head`
|
||||
// while the LAST_ACTIVITY_UNPARSEABLE diagnostic reads `last_activity` — two
|
||||
// DIFFERENT fields. A STATE.md carrying both an unparseable last_activity AND a
|
||||
// valid state_head must resolve each independently: the diagnostic fires for
|
||||
// last_activity, and the freshness signal still reads state_head. Neither
|
||||
// shadows the other (they are not "two staleness signals on one field").
|
||||
test('unparseable last_activity + valid state_head compose: diagnostic fires AND freshness reads independently (#3099)', () => {
|
||||
const {
|
||||
_resetUnusableInputWarningsForTests,
|
||||
_unusableInputEmissionCountForTests,
|
||||
} = require('../gsd-core/bin/lib/unusable-input.cjs');
|
||||
_resetUnusableInputWarningsForTests();
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-compose-'));
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
runGit(['init', '-q'], { cwd: dir });
|
||||
runGit(['config', 'user.email', 't@t.com'], { cwd: dir });
|
||||
runGit(['config', 'user.name', 'T'], { cwd: dir });
|
||||
runGit(['config', 'commit.gpgsign', 'false'], { cwd: dir });
|
||||
fs.writeFileSync(path.join(dir, 'seed.txt'), 'seed\n');
|
||||
runGit(['add', '-A'], { cwd: dir });
|
||||
runGit(['commit', '-q', '-m', 'seed'], { cwd: dir });
|
||||
const base = runGit(['rev-parse', 'HEAD'], { cwd: dir }).stdout.trim();
|
||||
track(dir);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.planning', 'STATE.md'),
|
||||
[
|
||||
'---',
|
||||
'status: executing',
|
||||
'last_activity: not-a-real-date at all',
|
||||
`state_head: ${base}`,
|
||||
'---',
|
||||
'',
|
||||
'# Project State',
|
||||
'',
|
||||
'Phase: 1',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
const s = detectSignals(dir);
|
||||
// last_activity is unusable → its diagnostic fires (its own field), exactly once.
|
||||
assert.strictEqual(_unusableInputEmissionCountForTests(), 1,
|
||||
'unparseable last_activity must emit exactly one LAST_ACTIVITY_UNPARSEABLE — the freshness path adds none');
|
||||
// state_head still resolves independently → fresh (0 commits behind HEAD).
|
||||
assert.strictEqual(s.state_commits_behind, 0,
|
||||
'state_head freshness must resolve independently of the last_activity diagnostic');
|
||||
assert.strictEqual(s.state_commit_stale, false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #3099: unusable last_activity emits a diagnostic (ADR-1411 amendment:
|
||||
// corrupt is not absent — the fallback stays, the silence is the defect)
|
||||
|
||||
@@ -62,6 +62,18 @@ describe('ADR-1769 substrate: field-classification table', () => {
|
||||
assert.strictEqual(cls && cls.preservation, 'preserve-always');
|
||||
});
|
||||
|
||||
test('state_head is free / derive (ADR-1769 §4 — ambient git read, refreshed every write; #2573)', () => {
|
||||
// `state_head` records the commit STATE.md was written against. It is not
|
||||
// body-derived, disk-derived, or curated — it is an ambient external read
|
||||
// recomputed on every write, exactly like `last_updated` (realClock.nowIso()).
|
||||
// ADR-1769 §4: "Each STATE.md field has a row." The per-transition guard in
|
||||
// transitionCore only checks the keys a transition declares, so an
|
||||
// unregistered field would slip through silently — this test is the check.
|
||||
const cls = getFieldClassification('state_head');
|
||||
assert.strictEqual(cls && cls.source, 'free');
|
||||
assert.strictEqual(cls && cls.preservation, 'derive');
|
||||
});
|
||||
|
||||
test('table covers every frontmatter key emitted by buildStateFrontmatter (codex Phase 1 review)', () => {
|
||||
// Verified against src/state.cts:1633-1653 (buildStateFrontmatter emit block).
|
||||
const requiredFields = [
|
||||
@@ -77,6 +89,7 @@ describe('ADR-1769 substrate: field-classification table', () => {
|
||||
'last_updated',
|
||||
'last_activity',
|
||||
'last_activity_desc',
|
||||
'state_head',
|
||||
'progress',
|
||||
'progress.total_phases',
|
||||
'progress.completed_phases',
|
||||
|
||||
@@ -12075,3 +12075,381 @@ describe('bug #2440 — shouldPreserveExistingProgress does not ratchet total_pl
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── #2573: state_head commit provenance on the write seam ───────────────────
|
||||
|
||||
describe('syncStateFrontmatter — state_head commit provenance (#2573)', () => {
|
||||
const { runGit } = require('./helpers/process-seam.cjs');
|
||||
const { syncStateFrontmatter } = require('../gsd-core/bin/lib/state.cjs');
|
||||
const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs');
|
||||
const { createTempGitProject: mkGit } = require('./helpers.cjs');
|
||||
|
||||
const dirs = [];
|
||||
const track = (d) => { dirs.push(d); return d; };
|
||||
afterEach(() => { while (dirs.length) cleanup(dirs.pop()); });
|
||||
|
||||
const MINIMAL_STATE = [
|
||||
'---',
|
||||
'status: executing',
|
||||
'---',
|
||||
'',
|
||||
'# Session State',
|
||||
'',
|
||||
'Status: executing',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
test('stamps state_head with the full HEAD sha of the project repo', () => {
|
||||
const dir = track(mkGit('gsd-2573-'));
|
||||
const head = runGit(['rev-parse', 'HEAD'], { cwd: dir }).stdout.trim();
|
||||
|
||||
const synced = syncStateFrontmatter(MINIMAL_STATE, dir);
|
||||
const fm = extractFrontmatter(synced);
|
||||
|
||||
assert.strictEqual(fm.state_head, head,
|
||||
'state_head must record the commit STATE.md was written against');
|
||||
});
|
||||
|
||||
test('omits state_head entirely when the project is not a git repo (degrade, never throw)', () => {
|
||||
// trek-e's approval condition 3: degrade to no-signal rather than throwing
|
||||
// when the commit is unresolvable. A non-repo is the canonical case.
|
||||
const dir = track(createTempProject('gsd-2573-nogit-'));
|
||||
|
||||
let synced;
|
||||
assert.doesNotThrow(() => { synced = syncStateFrontmatter(MINIMAL_STATE, dir); },
|
||||
'a non-git project must not throw');
|
||||
const fm = extractFrontmatter(synced);
|
||||
|
||||
assert.ok(!('state_head' in fm),
|
||||
`state_head must be absent outside a git repo, got ${JSON.stringify(fm.state_head)}`);
|
||||
});
|
||||
|
||||
test('drops a PRE-EXISTING state_head when the commit becomes unresolvable (never carried forward)', () => {
|
||||
// The omission test above feeds MINIMAL_STATE, which has no pre-existing
|
||||
// state_head — so it never reaches the #2202 carry-forward loop, which
|
||||
// copies any key absent from derivedFm straight back from the old file.
|
||||
// This fixture DOES carry a stamp, so it exercises that branch.
|
||||
//
|
||||
// state-transition.cts classifies state_head as { preservation: 'derive' }:
|
||||
// "Never preserved: a stale stamp would claim STATE.md was written against
|
||||
// a commit it wasn't." A carried-forward value contradicts that contract and
|
||||
// asserts provenance the file no longer has.
|
||||
const STAMPED_STATE = [
|
||||
'---',
|
||||
'status: executing',
|
||||
'state_head: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
'---',
|
||||
'',
|
||||
'# Session State',
|
||||
'',
|
||||
'Status: executing',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const dir = track(createTempProject('gsd-2573-stale-stamp-'));
|
||||
|
||||
let synced;
|
||||
assert.doesNotThrow(() => { synced = syncStateFrontmatter(STAMPED_STATE, dir); },
|
||||
'a non-git project must not throw even with a pre-existing stamp');
|
||||
const fm = extractFrontmatter(synced);
|
||||
|
||||
assert.ok(!('state_head' in fm),
|
||||
`a stale state_head must be DROPPED, not carried forward, when the commit is unresolvable — got ${JSON.stringify(fm.state_head)}`);
|
||||
});
|
||||
|
||||
test('restamps state_head to the new HEAD after a commit (freshness proxy resets on write)', () => {
|
||||
// Goodhart guard, asserted rather than assumed: the counter resets as a
|
||||
// side effect of ANY state write, so state_head means "written at this
|
||||
// commit", never "STATE's content is accurate". Pinning it here so nobody
|
||||
// later builds a gate on the derived commit distance.
|
||||
const dir = track(mkGit('gsd-2573-restamp-'));
|
||||
const first = extractFrontmatter(syncStateFrontmatter(MINIMAL_STATE, dir)).state_head;
|
||||
|
||||
fs.writeFileSync(path.join(dir, 'unrelated.txt'), 'change\n');
|
||||
runGit(['add', '-A'], { cwd: dir });
|
||||
runGit(['commit', '-m', 'unrelated'], { cwd: dir });
|
||||
const second = extractFrontmatter(syncStateFrontmatter(MINIMAL_STATE, dir)).state_head;
|
||||
|
||||
const head = runGit(['rev-parse', 'HEAD'], { cwd: dir }).stdout.trim();
|
||||
assert.notStrictEqual(second, first, 'a new commit must produce a new state_head');
|
||||
assert.strictEqual(second, head, 'state_head must track the current HEAD');
|
||||
});
|
||||
|
||||
test('carries a body-absent last_activity forward instead of dropping it (#2622 B1)', () => {
|
||||
// #2622 B1: the #2202 carry-forward loop skips `source: 'free'` fields
|
||||
// (state_head) so an unresolvable stamp is never re-asserted — but it must
|
||||
// NOT skip `last_activity` ({source:'body', preservation:'derive'}). When the
|
||||
// body carries no "Last activity:" line, buildStateFrontmatter omits the
|
||||
// field, and the existing frontmatter value has to survive: dropping it is
|
||||
// silent frontmatter data loss and would defeat #2570's staleness signal
|
||||
// downstream. A non-git project keeps this on the carry-forward path
|
||||
// (state_head is simply absent) and needs no subprocess.
|
||||
const STATE_WITH_ACTIVITY = [
|
||||
'---',
|
||||
'status: executing',
|
||||
'last_activity: 2026-01-15',
|
||||
'---',
|
||||
'',
|
||||
'# Session State',
|
||||
'',
|
||||
'Status: executing',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const dir = track(createTempProject('gsd-2622-b1-'));
|
||||
const fm = extractFrontmatter(syncStateFrontmatter(STATE_WITH_ACTIVITY, dir));
|
||||
|
||||
assert.strictEqual(fm.last_activity, '2026-01-15',
|
||||
'a body-absent last_activity must carry forward, not be dropped by the state_head narrowing');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ─── #2573: property invariants for the state_head fence ─────────────────────
|
||||
//
|
||||
// `state_head` is read from disk and then passed to git AS AN ARGUMENT, which
|
||||
// makes this a parser with a security-relevant fence — the class the repo's
|
||||
// testing standards require fast-check coverage for. Example-based tests pin
|
||||
// the shapes we thought of; these pin the invariant for the ones we didn't.
|
||||
|
||||
describe('readStateHeadFreshness — property invariants (#2573)', () => {
|
||||
const fc = require('./helpers/fast-check-setup.cjs');
|
||||
const { runGit } = require('./helpers/process-seam.cjs');
|
||||
const { after } = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { readStateHeadFreshness } = require('../gsd-core/bin/lib/state.cjs');
|
||||
|
||||
const propDirs = [];
|
||||
after(() => { while (propDirs.length) cleanup(propDirs.pop()); });
|
||||
|
||||
function gitRepo() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-prop-'));
|
||||
propDirs.push(dir);
|
||||
runGit(['init', '-q'], { cwd: dir });
|
||||
runGit(['config', 'user.email', 't@t.com'], { cwd: dir });
|
||||
runGit(['config', 'user.name', 'T'], { cwd: dir });
|
||||
runGit(['config', 'commit.gpgsign', 'false'], { cwd: dir });
|
||||
fs.writeFileSync(path.join(dir, 'a.txt'), 'a\n');
|
||||
runGit(['add', '-A'], { cwd: dir });
|
||||
runGit(['commit', '-q', '-m', 'seed'], { cwd: dir });
|
||||
return dir;
|
||||
}
|
||||
|
||||
const HEX_RE = /^[0-9a-f]{4,40}$/i;
|
||||
|
||||
const repo = gitRepo();
|
||||
|
||||
test('(a) total function — never throws for arbitrary input', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.anything(), (value) => {
|
||||
readStateHeadFreshness(repo, value);
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('(b) fence — non-hex input never yields a stamp', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string(), (s) => {
|
||||
const r = readStateHeadFreshness(repo, s);
|
||||
if (HEX_RE.test(s.trim())) return true; // valid shape: out of scope here
|
||||
return r.state_head === null && r.commits_behind === null && r.commit_stale === null;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('(c) tri-state integrity — unknown never reads as known-fresh', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string(), (s) => {
|
||||
const r = readStateHeadFreshness(repo, s);
|
||||
const validTri = r.commit_stale === null || r.commit_stale === true || r.commit_stale === false;
|
||||
const unknownIsNull = r.commits_behind === null ? r.commit_stale === null : true;
|
||||
const agreement = typeof r.commits_behind === 'number'
|
||||
? r.commit_stale === (r.commits_behind > 0)
|
||||
: true;
|
||||
return validTri && unknownIsNull && agreement;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('(d) no git-argument injection — dash-led values are rejected by the fence', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom('--all', '-n', '--not', '--output=/tmp/pwn', '--help', '-- --all'),
|
||||
fc.string(),
|
||||
(flag, tail) => {
|
||||
const r = readStateHeadFreshness(repo, `${flag}${tail}`);
|
||||
return r.state_head === null && r.commits_behind === null && r.commit_stale === null;
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('(f) a NON-ANCESTOR stamp resolves to unknown, never to "known fresh"', () => {
|
||||
// `rev-list --count A..B` exits 0 with "0" when A is unreachable from B, so
|
||||
// reset --hard / rebase / squash / force-push past the stamp used to render
|
||||
// as commit_stale:false — "known fresh" for a codebase that was rewound.
|
||||
// That collapses the exact unknown-vs-fresh distinction the tri-state exists
|
||||
// to preserve, so a non-ancestor stamp must come back null.
|
||||
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-nonanc-'));
|
||||
propDirs.push(d);
|
||||
const g = (argv) => runGit(argv, { cwd: d }).stdout;
|
||||
g(['init', '-q']); g(['config', 'user.email', 't@t.com']); g(['config', 'user.name', 'T']);
|
||||
g(['config', 'commit.gpgsign', 'false']);
|
||||
fs.writeFileSync(path.join(d, 'a.txt'), 'a\n');
|
||||
g(['add', '-A']); g(['commit', '-q', '-m', 'base']);
|
||||
const base = g(['rev-parse', 'HEAD']).trim();
|
||||
fs.writeFileSync(path.join(d, 'b.txt'), 'b\n');
|
||||
g(['add', '-A']); g(['commit', '-q', '-m', 'c1']);
|
||||
const tip = g(['rev-parse', 'HEAD']).trim();
|
||||
g(['reset', '--hard', '-q', base]);
|
||||
|
||||
const r = readStateHeadFreshness(d, tip);
|
||||
assert.strictEqual(r.commits_behind, null, 'a non-ancestor stamp has no meaningful distance');
|
||||
assert.strictEqual(r.commit_stale, null, 'unknown must NOT report as false ("known fresh")');
|
||||
});
|
||||
|
||||
test('(e) a real HEAD sha always resolves to zero commits behind', () => {
|
||||
const head = runGit(['rev-parse', 'HEAD'], { cwd: repo }).stdout.trim();
|
||||
const r = readStateHeadFreshness(repo, head);
|
||||
assert.strictEqual(r.commits_behind, 0);
|
||||
assert.strictEqual(r.commit_stale, false);
|
||||
assert.strictEqual(r.state_head, head.slice(0, 7));
|
||||
});
|
||||
|
||||
test('(g) a project whose nearest .git is an ANCESTOR repo resolves to unknown, never "known fresh"', () => {
|
||||
// #2573 degrade path D5. `git rev-parse HEAD` walks UP from cwd to the
|
||||
// nearest enclosing .git — nothing pins that repo to the project. A GSD
|
||||
// project living under an unrelated repo (a dotfiles/notes checkout, or the
|
||||
// outer workspace of a planning.sub_repos layout) measures its freshness
|
||||
// against a repo it has no relationship to.
|
||||
//
|
||||
// The stamp below IS that ancestor repo's HEAD, so pre-fix the ancestry
|
||||
// check passes, rev-list returns 0, and the tri-state reports
|
||||
// commit_stale:false — "known fresh" for a directory that is not in that
|
||||
// repo at all. Same invariant violation as (f), reached by another route.
|
||||
const outer = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-ancestor-'));
|
||||
propDirs.push(outer);
|
||||
const g = (argv) => runGit(argv, { cwd: outer }).stdout;
|
||||
g(['init', '-q']); g(['config', 'user.email', 't@t.com']); g(['config', 'user.name', 'T']);
|
||||
g(['config', 'commit.gpgsign', 'false']);
|
||||
fs.writeFileSync(path.join(outer, 'unrelated.txt'), 'x\n');
|
||||
g(['add', '-A']); g(['commit', '-q', '-m', 'outer']);
|
||||
const outerHead = g(['rev-parse', 'HEAD']).trim();
|
||||
|
||||
// The project itself is NOT a git repo — it merely sits inside one.
|
||||
const project = path.join(outer, 'nested-project');
|
||||
fs.mkdirSync(path.join(project, '.planning'), { recursive: true });
|
||||
|
||||
const r = readStateHeadFreshness(project, outerHead);
|
||||
assert.strictEqual(r.commit_stale, null,
|
||||
'a stamp resolved against an ancestor repo is UNKNOWN — it must not report false ("known fresh")');
|
||||
assert.strictEqual(r.commits_behind, null,
|
||||
'distance measured against an unrelated repo is not a meaningful count');
|
||||
});
|
||||
|
||||
test('(h) a SYMLINKED project path still resolves — repo pinning compares identity, not spelling', () => {
|
||||
// Guard against over-tightening (g). `git rev-parse --show-toplevel` reports
|
||||
// the REAL path while the project root arrives as the caller spelled it, and
|
||||
// those differ routinely: macOS temp dirs (/var/folders → /private/var/folders),
|
||||
// any symlinked checkout, Windows casing. A raw string compare would report a
|
||||
// perfectly normal project as unknown — the inverse of the bug (g) fixes, and
|
||||
// exactly what broke the macOS and Windows CI shards.
|
||||
const realDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-symreal-'));
|
||||
propDirs.push(realDir);
|
||||
const g = (argv) => runGit(argv, { cwd: realDir }).stdout;
|
||||
g(['init', '-q']); g(['config', 'user.email', 't@t.com']); g(['config', 'user.name', 'T']);
|
||||
g(['config', 'commit.gpgsign', 'false']);
|
||||
fs.mkdirSync(path.join(realDir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(realDir, 'a.txt'), 'a\n');
|
||||
g(['add', '-A']); g(['commit', '-q', '-m', 'base']);
|
||||
const head = g(['rev-parse', 'HEAD']).trim();
|
||||
|
||||
const linkDir = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-symlink-')), 'proj');
|
||||
propDirs.push(path.dirname(linkDir));
|
||||
try {
|
||||
fs.symlinkSync(realDir, linkDir, 'dir');
|
||||
} catch {
|
||||
return; // symlink creation unavailable (e.g. unprivileged Windows) — nothing to assert
|
||||
}
|
||||
|
||||
const r = readStateHeadFreshness(linkDir, head);
|
||||
assert.strictEqual(r.commit_stale, false,
|
||||
'a symlinked project path is the SAME repo — it must resolve, not degrade to unknown');
|
||||
assert.strictEqual(r.commits_behind, 0);
|
||||
});
|
||||
|
||||
test('(i) a sub_repos workspace resolves to unknown even though it owns its own repo', () => {
|
||||
// #2573 D5, sub_repos flavor. (g) covers the case where the project owns NO
|
||||
// .git. This is the harder one: the outer workspace owns BOTH .planning/ and
|
||||
// its own repo, so projectOwnsItsRepo passes — yet every code commit lands in
|
||||
// a nested child repo and the outer HEAD never advances.
|
||||
//
|
||||
// Pre-fix that stamps the outer HEAD, --is-ancestor passes trivially,
|
||||
// rev-list counts 0, and the tri-state reports commit_stale:false — "known
|
||||
// fresh" — no matter how far the children have moved. That is a WRONG answer,
|
||||
// not a missing one: the same invariant (g) protects, reached by a third
|
||||
// route. docs/CONFIGURATION.md describes sub_repos as scoping work per
|
||||
// sub-repo "instead of treating the outer repo as a monorepo", so an outer
|
||||
// wrapper that is itself a repo is a supported layout, not a contrived one.
|
||||
const outer = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-subrepos-'));
|
||||
propDirs.push(outer);
|
||||
const g = (argv) => runGit(argv, { cwd: outer }).stdout;
|
||||
g(['init', '-q']); g(['config', 'user.email', 't@t.com']); g(['config', 'user.name', 'T']);
|
||||
g(['config', 'commit.gpgsign', 'false']);
|
||||
fs.mkdirSync(path.join(outer, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(outer, '.planning', 'config.json'),
|
||||
JSON.stringify({ planning: { sub_repos: ['frontend'] } }, null, 2),
|
||||
);
|
||||
fs.writeFileSync(path.join(outer, 'wrapper.txt'), 'x\n');
|
||||
g(['add', '-A']); g(['commit', '-q', '-m', 'outer']);
|
||||
const outerHead = g(['rev-parse', 'HEAD']).trim();
|
||||
|
||||
// A separately tracked child repo — where the real work happens. The outer
|
||||
// repo is deliberately NOT advanced past `outerHead` afterwards, which is
|
||||
// precisely the topology that makes the stale reading look fresh.
|
||||
const child = path.join(outer, 'frontend');
|
||||
fs.mkdirSync(child, { recursive: true });
|
||||
const gc = (argv) => runGit(argv, { cwd: child }).stdout;
|
||||
gc(['init', '-q']); gc(['config', 'user.email', 't@t.com']); gc(['config', 'user.name', 'T']);
|
||||
gc(['config', 'commit.gpgsign', 'false']);
|
||||
fs.writeFileSync(path.join(child, 'app.js'), 'let a = 1;\n');
|
||||
gc(['add', '-A']); gc(['commit', '-q', '-m', 'child']);
|
||||
|
||||
const r = readStateHeadFreshness(outer, outerHead);
|
||||
assert.strictEqual(r.commit_stale, null,
|
||||
'a sub_repos workspace cannot substantiate a freshness claim from the outer ' +
|
||||
'HEAD — it must report unknown, never false ("known fresh")');
|
||||
assert.strictEqual(r.commits_behind, null,
|
||||
'a distance measured against the wrapper repo is not a meaningful count');
|
||||
});
|
||||
|
||||
test('(j) a plain single-repo project is NOT degraded by the sub_repos check', () => {
|
||||
// Over-tightening guard for (i), mirroring what (h) does for (g). An empty or
|
||||
// absent sub_repos must leave the normal path untouched — a check that
|
||||
// degraded every project to unknown would "pass" (i) while destroying the
|
||||
// feature, which is the failure mode this pins.
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-plain-'));
|
||||
propDirs.push(dir);
|
||||
const g = (argv) => runGit(argv, { cwd: dir }).stdout;
|
||||
g(['init', '-q']); g(['config', 'user.email', 't@t.com']); g(['config', 'user.name', 'T']);
|
||||
g(['config', 'commit.gpgsign', 'false']);
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.planning', 'config.json'),
|
||||
JSON.stringify({ planning: { sub_repos: [] } }, null, 2),
|
||||
);
|
||||
fs.writeFileSync(path.join(dir, 'a.txt'), 'a\n');
|
||||
g(['add', '-A']); g(['commit', '-q', '-m', 'base']);
|
||||
const head = g(['rev-parse', 'HEAD']).trim();
|
||||
|
||||
const r = readStateHeadFreshness(dir, head);
|
||||
assert.strictEqual(r.commit_stale, false,
|
||||
'an empty sub_repos list is a normal single-repo project — it must resolve');
|
||||
assert.strictEqual(r.commits_behind, 0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user