* 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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user