Repository navigation
enhance(#4629): the Axis-2 raw STATE.md write guard parses the file instead of matching lines, so scope, strings and other write APIs stop fooling it #5279
Description
GSD Version
1.16.0 (next @ ed0f3fd)
What existing feature or behavior does this improve?
Axis 2 of the STATE.md write-path guard, scripts/lint-state-write-path-drift.cjs (findRawStateWrites), the terminal check that ADR-4629 Decision 5 names as the enforcement half beside the type system. A #4629 child, requested in the #5119 round-4 review (finding m1).
Current behavior
Axis 2 matches text line by line, and its header says so on purpose (line 114: "String literals are matched, never parsed as an AST"). #5104 / #5119 taught it wrapped calls and same-function variable targets, but a line scan cannot see scope, strings or call shapes, so these stay wrong. #5119 pins each as a known-gap test row that asserts today's output and that it differs from the correct verdict:
- Scope (false positives, no way to clear them): a sibling arrow or method that binds the same name, a nested function's parameter shadowing an outer variable,
p => statePathread as an assignment. ADR-4629 Decision 5 rules out an allowlist, so a false positive here blockslint:ciuntil the code is reshaped. - Scope (misses): an assignment and write inside a one-line block, or a reassignment in a closed block on the write's line.
- Strings and regex literals:
stripCommentsdoes not track them, so a//inside'http://x'or/\//hides a call that starts on that line (a miss), and a string that only spells the call is reported. - Call shapes: only the literal
fs.writeFileSync(is matched.fs.promises.writeFile,fs.writeFile,fs.appendFileSync, a destructured or aliasedwriteFileSync,fs?.writeFileSyncandfs['writeFileSync']are not seen.
Proposed behavior
Replace Axis 2's line scanners with a syntax-only TypeScript AST pass (ts.createSourceFile, no type checker, no program). Visit write calls by callee shape, take the target argument node, and resolve identifiers through the enclosing scope's declarations. Strings, regex literals, comments and block scope then come from the parser instead of from approximations. Axes 1 and 3-5 are out of scope and keep their line scans.
Acceptance:
- Every row in ci(#5104): the raw STATE.md write guard sees wrapped calls and same-function variable targets #5119's
KNOWN_SCOPE_GAPS,KNOWN_TAIL_GAPS(where the file is visible in the function) andKNOWN_MATCH_GAPSflips to itsexpectedverdict, and each row is converted to an ordinary assertion. - Every current positive and control row in
tests/state-write-path-drift-guard.test.cjskeeps its verdict. - The real tree still reports
rawWriteViolations: 0, or each new finding is migrated onto the seam in the same PR (no allowlist, ADR-4629 Decision 5). - The guard header's design note is rewritten to describe the AST pass, and a parse failure fails closed (reported, never skipped).
Reason and benefit
For contributors: today a correct write that happens to sit in a sibling closure fails lint:ci with no allowlist to clear it, and a real raw STATE.md write can pass by being written through fs.promises.writeFile. Both cost a review round. For users: the guard is what keeps an unlocked STATE.md write (the corruption class #4629 exists to close) from landing, so its blind spots are where that class comes back.
Scope of changes
scripts/lint-state-write-path-drift.cjs (Axis 2 only) and tests/state-write-path-drift-guard.test.cjs. No src/ change, no new dependency: typescript is already a devDependency (package.json), and four test files already load it. Measured on next @ ed0f3fd with typescript 6.0.3: parsing the 72 files under src/, scripts/, gsd-core/bin/, hooks/ and bin/ that contain fs.writeFileSync( takes 295 ms in one process. In an earlier probe the AST pass would replace about 330 lines of scanners (splitStatements, statementSpans, lastBindingInText, nearestTargetBinding, the join-tail helpers).
Breaking changes
None for users. lint:ci results can change: false positives clear, and real writes through the newly seen call shapes get reported.
Alternatives considered
- Keep extending the line scanner (a brace-depth tracker, a string-aware
stripComments). Probed during ci(#5104): the raw STATE.md write guard sees wrapped calls and same-function variable targets #5119: braces in strings, templates and regex literals, brace-less arrow bodies,catch (e)shadowing and block-scopedvareach need more parsing, so the tracker turns into a partial parser.stripCommentsis shared by all five axes, so changing it moves verdicts outside Axis 2. - An allowlist for the scope false positives. Rejected by ADR-4629 Decision 5.
Area affected
Workflow / STATE.md write path (epic #4629)
Additional context
- Epic: Epic: STATE.md writes declare intent and are verified — retire the opaque transformFn contract #4629. Parent ADR: ADR-4629 (Decision 5: "Enforcement rides the type system ... plus the existing terminal Axis-2 guard ... extended"). Also ADR-3408 Decision 5 (a zero from this guard is not proof).
- Requested in the ci(#5104): the raw STATE.md write guard sees wrapped calls and same-function variable targets #5119 review (m1). enhance(#4629): the Axis-2 raw STATE.md write guard misses a target held in a variable or split across lines #5104 / ci(#5104): the raw STATE.md write guard sees wrapped calls and same-function variable targets #5119 is the line-based extension this would replace.
- Related, separate: enhance(#4629): the test suite fails when product code raw-writes a file named STATE.md outside the write seam #5120 (a runtime check for raw STATE.md writes during the test suite).
Activity
ci(open-gsd#5104): keep the raw-write binding regex cache per call, n…
davesienkowski commentedon Oct 10, 2026
Acceptance addendum from the #5119 review (5477747649, Major 1 and Minors 2, 3, 8). #5119 head
29f3b6e17pins these as rows, and the AST pass should flip each one to itsexpectedverdict:function w(){ const p = join(dir,'STATE.md'); fs.writeFileSync(p,c); }). True block, arrow and method scoping, which ci(#5104): the raw STATE.md write guard sees wrapped calls and same-function variable targets #5119 approximates by refusing to let a literal clear a write across{,}or=>.const STATE_FILENAME = 'STATE.md'at module level, used inside a function.KNOWN_TARGET_FALSE_POSITIVES, new table). A same-name arrow parameter that shadows a STATE.md binding. A target derived from STATE.md's content. ASTATE.md.bakliteral.KNOWN_MATCH_GAPS).appendFileSync,renameSync(tmp, statePath),fs.writeFile, an aliasedfs,require('fs').writeFileSync,fs . writeFileSync (,fs?.writeFileSync,fs['writeFileSync'].BY_DESIGN_FALLBACKS(also new: a parameter or interpolated-template join tail) records intended behavior, so it is not in scope to flip.3a6b4fb95. Missed writes: a declaration whose value starts on the next line (typed and untyped), nested braces in a type annotation, a;inside a string-literal type. Four newBY_DESIGN_FALLBACKSrows differ from the parameter and template rows above: they come from the line scan's textual body boundary, so true scoping should resolve them. A non-STATEconsttail separated from the write by an unrelated closed block, an object literal, a callback or a${}interpolation reports today; the AST pass should clear it and flip those rows.trek-e commentedon Oct 14, 2026
Not triaged to a decision yet: this request needs to stand on its own.
Its acceptance criteria are written against the known-gap tables (
KNOWN_SCOPE_GAPS,KNOWN_TAIL_GAPS,KNOWN_MATCH_GAPS,KNOWN_TARGET_FALSE_POSITIVES) and the "round-4" review of #5119. Those tables exist only on the unmerged branch of #5119 (still open);origin/nexthas no such tables. A request cannot depend on an unmerged change, and work is not stacked on an open PR.The underlying gap is real on
next:stripComments(scripts/lint-state-write-path-drift.cjs:240-274) is not string- or regex-aware, so a//inside a string hides a realfs.writeFileSync(call on the same line (e.g.const u = 'http://x'; fs.writeFileSync(statePath, c);reports 0 findings), contradicting the function's own docstring (lines 230-238). ADR-4629 Decision 5 rejects the seededlocal/no-adhoc-state-writeratchet/allowlist; it does not forbid changing the scanning technique.Please restate the acceptance criteria against
nextas it is now: each shape that should be reported or cleared, written as a concrete fixture, with no reference to rows from an unmerged PR. Once that is in place this goes back to the maintainer for a decision.davesienkowski commentedon Oct 16, 2026
Restated against
next@ 46a119f as concrete fixtures. TodayfindRawStateWrites(
scripts/lint-state-write-path-drift.cjs) flags only a single-linefs.writeFileSync(whose firstargument contains
statePathorSTATE.md.Acceptance (every fixture below was run against the guard on
next)://in a string or regex before the call, a call split over lines, a variable or constant target (including CRLF,+=, chained assignment, TypeScript annotation), other call shapes.STATE.md.bakliteral, a string spelling the call, adirname(statePath)join to another file.rawWriteViolations: 0, or findings migrate onto the seam (no allowlist).The technique is the maintainer's call.
Fixtures (snippet | expected | today on next)
Pins (flag | flag):
P1
fs.writeFileSync(statePath, c);P2
fs.writeFileSync(path.join(cwd, 'STATE.md'), c);P3
fs.writeFileSync(${cwd}/STATE.md, c);P4
fs.writeFileSync(${a({b:1}).dir}/STATE.md, c);Must flag (flag | clear):
A1
const u = 'http://x'; fs.writeFileSync(statePath, c);A2
const r = /\//; fs.writeFileSync(statePath, c);B1
fs.writeFileSync(NLstatePath,NLc);B2
fs.writeFileSync(path.join(cwd,NL'STATE.md'), c);C1
const p = path.join(dir, 'STATE.md');NLfs.writeFileSync(p, c);C2
const STATE_FILENAME = 'STATE.md';NLfunction w(){ fs.writeFileSync(path.join(d, STATE_FILENAME), c); }C3 as C1 with CRLF line endings
C4
let p = dir;NLp += '/STATE.md';NLfs.writeFileSync(p, c);C5
let p; p = path.join(dir,'STATE.md'); fs.writeFileSync(p, c);C6
const a = statePath; const b = a; fs.writeFileSync(b, c);C7 (.cts)
const p: {a: string} = path.join(dir, 'STATE.md');NLfs.writeFileSync(p, c);C8 (.cts)
const p: string = path.join(dir, 'STATE.md');NLfs.writeFileSync(p, c);D1
fs.promises.writeFile(statePath, c)D2fs.writeFile(statePath, c, cb)D3fs.appendFileSync(statePath, c)D4fs.renameSync(tmp, statePath)D5const { writeFileSync } = require('fs'); writeFileSync(statePath, c)D6const f = require('fs'); f.writeFileSync(statePath, c)D7require('fs').writeFileSync(statePath, c)D8fs?.writeFileSync(statePath, c),fs['writeFileSync'](statePath, c),fs . writeFileSync (statePath, c)(D3 and D4 are scope calls for the maintainer.)
Must clear, flagged today (clear | flag):
E1
fs.writeFileSync(path.join(cwd, 'STATE.md.bak'), c);E2
const s = "fs.writeFileSync(statePath, c)";E3
fs.writeFileSync(path.join(path.dirname(statePath), 'ROADMAP.md'), c);Controls (clear | clear; must stay clear once variables resolve):
F1
// fs.writeFileSync(statePath, c)F2/* fs.writeFileSync(statePath, c) */F3
fs.writeFileSync(path.join(cwd, 'ROADMAP.md'), c);F4
fs.readFileSync(statePath, 'utf8');F5
fs.writeFileSync(out, statePath);F6
const a = () => { const p = 'x'; };NLconst b = (p) => { fs.writeFileSync(p, c); };F7
const p = path.join(d,'STATE.md');NLconst b = (p) => { fs.writeFileSync(p, c); };F8
const r = path.join(path.dirname(statePath), 'ROADMAP.md');NLfs.writeFileSync(r, c);F9
const t = fs.readFileSync(statePath,'utf8').trim();NLfs.writeFileSync(t, c);(path read from STATE.md content; a data value, not the STATE.md path)ci(open-gsd#5104): keep the raw-write binding regex cache per call, n…
ci(open-gsd#5104): cut the raw-write guard back to the approved Layer…
ci(open-gsd#5104): say in the guard header that the raw-write axis's …
davesienkowski commentedon Oct 16, 2026
@trek-e the restatement you asked for is in the comment above (fixtures against
next, no #5119 rows). Here is the runnable backing for its "today on next" column.One command, from a
nextcheckout root, with the script below saved asrepro-5279.cjs. On a fresh clone, runnpm ci(ornpm run build:lib) first, because the guard requires the gitignored, generatedgsd-core/bin/lib/pattern.cjs:It writes each fixture to a temp
src/fixture.ctsand runsnode scripts/lint-state-write-path-drift.cjs --root <tmp> --json. Onnext@ 19f2d5e (guard identical to 46a119f):ok=13 FN=30 FP=3 of 46.next. Your'http://x'case is A1: 0 findings.createWriteStream,copyFileSync,fs/promises, ESM import,'STATE' + '.md',{ statePath: p },fsNL.writeFileSync(.rawWriteViolations: 0.typescriptandespreeare already devDependencies, so a parser adds no dependency.createWriteStream/copyFileSync) remain your scope call. "Parse failure is reported" cannot be tested until a parser exists.Table (next) and script
ci(open-gsd#5104): keep the raw-write binding regex cache per call, n…
ci(open-gsd#5104): cut the raw-write guard back to the approved Layer…
ci(open-gsd#5104): say in the guard header that the raw-write axis's …
trek-e commentedon Oct 18, 2026
Approved. The restated acceptance criteria are concrete and runnable against
next: re-running the posted script on currentnextgives ok=13, FN=30, FP=3 of 46, matching the report.needs-reproductionis removed.Decisions recorded for the scope calls the thread left open:
writeFileSync,promises.writeFile,writeFile,appendFileSync,renameSync(destination argument),createWriteStreamandcopyFileSync(destination argument), including aliased, destructured, optional-chained and bracket forms..cts/.cjsonly; prompt files keep their existing handling.rawWriteViolations: 0, or each new finding migrates onto the seam (no allowlist).ci(open-gsd#5104): keep the raw-write binding regex cache per call, n…
ci(open-gsd#5104): cut the raw-write guard back to the approved Layer…
ci(open-gsd#5104): say in the guard header that the raw-write axis's …
ci(open-gsd#5104): keep the raw-write binding regex cache per call, n…
ci(open-gsd#5104): cut the raw-write guard back to the approved Layer…
ci(open-gsd#5104): say in the guard header that the raw-write axis's …
davesienkowski commentedon Oct 24, 2026
@trek-e thanks for the approval. #5279 is sequenced behind #5119: both change the Axis-2 resolver in scripts/lint-state-write-path-drift.cjs, and the AST pass replaces that code. I will not open the PR until #5119 merges, then branch from next and flip the 46-fixture repro (ok=13, FN=30, FP=3 at your re-run) to ok, with the call shapes and parse-failure rule you listed. That covers all 46 rows of the acceptance table you approved, including the split literal (H1) and the destructured alias (H2).
ci(open-gsd#5104): list the multi-line value, default parameter, spli…