Repository navigation
ci(#5104): the raw STATE.md write guard sees wrapped calls and same-function variable targets - #5119
davesienkowski wants to merge 17 commits into
Conversation
trek-e
left a comment
There was a problem hiding this comment.
Summary
Changes requested. Axis 2 of the raw STATE.md write guard now sees wrapped calls and resolves variable targets, but the promised one-line shape is still missed and the new taint rule can flag writes that are not STATE.md.
Classification & gate compliance
- Enhancement track. Linked issue #5104 carries
approved-enhancement(Layer 1 approved in the issue thread).Closes #5104present, conventional titleci(#5104):, not a draft. Governing ADR: ADR-4629 Decision 5 (no allowlist) — honored. - Labels
area: workflow+no-changelog; changeset lint reports no user-facing change, so no changeset is required. Branch is BEHINDnext(MERGEABLE). Required checks (Tests, lint-tests, Required tests, changeset-lint, docs-lint) are SUCCESS at this head; earlier-run Changeset/Docs entries are CANCELLED and superseded by later SUCCESS runs.
Must-Have Acceptance Checklist (issue #5104, Layer 1)
- First argument captured across line breaks, over comment-stripped text.
- Resolve a variable target through same-function assignments with bounded hops — partly met: a same-line assignment-plus-write is missed (Major 1).
-
for…of/ destructuring bindings are unresolved (two binding rows). - Reuses the
nearestPrecedingAssignmentlogic (assignmentLineReshared). - Axis 2 only, no
src/change, no allowlist, no new dependency. - Tests: wrapped, variable, sibling-block binding, negatives, real-tree control.
- Miss 3 is out of scope per the ruling; the PR body says a separate #4629 child covers it but cites no issue number (Nit 2).
Findings
Major
- (judgment + static trace)
scripts/lint-state-write-path-drift.cjsfindRawStateWritescallsresolvesToStatePath(stripped, lineIdx - 1, targetArg); the backward scan starts ABOVE the call line, soconst p = path.join(dir, 'STATE.md'); fs.writeFileSync(p, c);on one line resolves to nothing and is not flagged. The PR body and issue fixture 3 list exactly this one-hop shape as caught. Every F4 positive row puts the assignment on its own line, so the gap is untested. Fix: scan fromlineIdxinclusive, limited to text before the call's column, and add a one-line positive row. - (judgment)
identifiersIn+resolvesToStatePathtreat any identifier on the right-hand side as tainting.const dir = path.dirname(statePath); const p = path.join(dir, 'ROADMAP.md'); fs.writeFileSync(p, c)is flaggedRAW_STATE_WRITEalthough the file written is ROADMAP.md, and ADR-4629 Decision 5 forbids an allowlist, so the contributor has no way to clear it. No negative control covers astatePath-derived directory with a non-STATE.mdliteral tail. Fix: stop descent when apath.join/resolvetail is a literal that does not name STATE.md, or add the negative control and document the posture.
Minor
FUNCTION_DECL_LINE_RE(scripts/lint-state-write-path-drift.cjs:280) matches onlyfunction name(. Arrow functions and class/object methods are not scope boundaries, so resolution can cross into a sibling body with a same-named variable (false positive); the new resolver is broader (up to 4 hops, every identifier) and the limit is untested for arrows/methods.- Boundary coverage: the hop-bound test asserts
MAXandMAX+1only; addMAX-1(limit-1, limit, limit+1). - No
fast-checkproperty for what is now a text parser/resolver (captureFirstArg,identifiersIn, binding regexes), and no CRLF fixture. identifiersIndrops template interpolation with\$\{[^}]*\}, so nested braces (${f({a:1})}) truncate and identifiers after the inner}are lost (false negative). Untested.
Nit
- Docs: "Updated docs/" is unchecked in the body and the PR relies on the exemption; consider a one-line note in the ADR-4629 guard table that Axis 2 now resolves same-function variables and still misses data-flow targets.
- The
no-changeloglabel description says "docs-only", but the diff is a CI lint guard (valid per changeset lint). The Miss-3 follow-up child is not linked. - Branch is BEHIND
next; rebase before merge. A rebase clears base drift only; it does not retire findings 1-6.
Security
No ReDoS found: the assignment/loop/destructuring regexes are lazy and bounded by line length, with cost O(lines × identifiers × hops). Output still passes sanitizeForReport. No traversal or argv surface added.
Blast radius & risk tier
findRawStateWrites is the Axis 2 scanner inside one lint script, with a test-side alias in tests/state-write-path-drift-guard.test.cjs; it runs in CI only and has no runtime callers. Not a central or bridge symbol.
Co-change completeness
The test file and script are both in the diff. No missing co-change file identified.
Memtrace Evidence
get_impact(findRawStateWrites, upstream, depth 2, branchnext): ambiguous — two declarations (the script function atscripts/lint-state-write-path-drift.cjs:627-652and a test-side constant), so no traversal was run on a single anchor. Impact is bounded by reading: a CI lint function with no runtime callers.get_symbol_context(findRawStateWrites): same ambiguity (2 candidates), no caller/callee rows returned. The documented contract on the script declaration ("unratcheted, unexempted: any occurrence is a violation") matches ADR-4629 Decision 5.recall_decision("raw STATE.md write guard no allowlist lint-state-write-path-drift"): returned only unrelated/indirect decisions (e.g. #3824 machine-readable state contract at step boundaries). No recorded decision contradicts this change. The result is statistically ranked, not a proof of absence.find_code_review_issues: not run for this PR — the diff text could not be supplied to the tool in this session. Findings above come from the isolated reviewer's static analysis of the diff and from direct reads, not from this tool.
Verdict
Changes requested (2 Major, 4 Minor, 3 Nit). CI is green on the required checks at this head, but that does not retire any finding above.
82b5777 to
9d7950e
Compare
|
@trek-e thanks. I pushed
The guard now carries five hand-written scanners. If more misses turn up, I'd propose an AST-based Axis 2 as its own issue. Re-requested your review. Verification
|
9d7950e to
250b5a4
Compare
|
Rebased onto Local on the new head: My 10-03 reply above still covers your findings.
|
trek-e
left a comment
There was a problem hiding this comment.
Review — ci(#5104): the raw STATE.md write guard sees wrapped calls and same-function variable targets
Head reviewed: 250b5a4f58714dc1ebe7be161678a6236edeefca. Full diff read (scripts/lint-state-write-path-drift.cjs and tests/state-write-path-drift-guard.test.cjs).
Summary
Axis 2 now captures wrapped calls and resolves variable targets with a bounded hop count. Review items 1, 2 (inline literal tail), 4, 5 and 6 from the 2026-09-30 review are fixed and verified by executing the code. Item 3 is declined and pinned, and a related false-positive class (m2) remains. Changes requested on the three Minor items below.
Classification & gate compliance
- Enhancement track. #5104 is OPEN with
approved-enhancement;Closes #5104present; conventional title; not draft; no competing open PR. - Scope: scripts/ + tests/ only, no src/ change, no allowlist, no dependency. Governing ADR-4629 Decision 5 honored.
- No changeset (changeset-lint
ok_no_user_facing_changes,no-changeloglabel); no docs (docs-lint green). - The issue said to reuse
nearestPrecedingAssignment; only its regex is shared (assignmentLineRe), the resolver is a newnearestTargetBinding. Axis 3 unchanged. - CI at this head: 28 success, 5 skipped, 0 failed, 0 pending, 0 action_required runs. Branch is BEHIND by 1 (
82f7acdfa7, CI budget history, does not touch the guard); clean rebase. A rebase fixes behind-ness only, not the findings below:git fetch origin next && git rebase origin/next.
Functional checklist / root-cause verdict
- Wrapped call captured over comment-stripped joined text (probe line numbers correct, CRLF equal to LF).
- Variable target resolved, bounded at MAX_TARGET_RESOLUTION_HOPS=4; MAX-1/MAX/MAX+1 tested.
- for...of / destructuring stop resolution (two rows).
- One-line
const p = path.join(dir,'STATE.md'); fs.writeFileSync(p,c)flagged at line 2 (executed). -
path.join(path.dirname(statePath),'ROADMAP.md')not flagged (executed). - Real tree via
--root: rawWriteViolations 0. - Root cause: the scan was per-line and matched only the literal
statePath/STATE.mdtext in the captured argument. The fix addresses that at the scanner, not by special-casing. Miss 3 (data-flow) stays open by ruling (#5120).
Blast radius
findRawStateWrites runs only in lint:ci (guard script plus its test and the --json CLI). New export MAX_TARGET_RESOLUTION_HOPS only. Risk: low. Perf probes: 3000-hop chain 7 ms; 3000 writes 140 ms; 200k-char line 4 ms.
Co-change completeness
Guard and test are both in the diff. No INVENTORY, CONTEXT.md, changeset or docs surface applies. ADR-4629 guard-table line refs remain stale (Nit n2, contributor declines ADR edits).
CodeGraph Evidence
codegraph_impacttargetfindRawStateWrites(base index, run by orchestrator): 5 affected symbols —findRawStateWritesand its callerscollect/maininscripts/lint-state-write-path-drift.cjs, the test filetests/state-write-path-drift-guard.test.cjs, andscanMarkdownLetterAxisConsumersinscripts/lint-phase-id-drift.cjs(likely a same-name symbol; not a runtime dependency). No runtime (non-lint) dependents. Low risk.codegraph_callerstargetnearestPrecedingAssignment(run by orchestrator): 1 caller,findUnstrippedContentWrites(scripts/lint-state-write-path-drift.cjs:531). Its behavior is unchanged byassignmentLineRe(same regex string, verified by reading the diff).codegraph_callees/codegraph_nodewere not run for this review; the callee list offindRawStateWrites(stripComments,captureFirstArg,resolvesToStatePath,sanitizeForReport) and theliteralJoinTailcharacterization are from reading the diff.
Memtrace unavailable: monthly query quota exhausted (20972/20933, resets 2026-10-20)
Test quality
Fail-first: the F4 rows target shapes that returned 0 on base (wrapped call, variable, one-line); PR body reports 8 failing against the unchanged guard. Property test discriminates STATE.md vs other files across shapes and CRLF. Boundaries covered. No wall-clock, no source-grep. Concern: limit: rows assert known-wrong verdicts (m3).
Prior review resolution (trek-e, 82b5777)
1 fixed; 2 fixed for inline literal tails; 3 not fixed (declined, pinned); 4 fixed; 5 fixed; 6 fixed; 7 declined; 8 #5120 linked; 9 behind by 1.
Findings
Minor
- m1 (judgment, executed probes)
scripts/lint-state-write-path-drift.cjsnearestTargetBinding: scope is line-based; arrows/methods are not boundaries and a one-line block/function is not read. Probes: sibling-arrowpresolves to another body'sSTATE.mdvariable (flagged); parameter shadowing on a prior line flagged;if (c) { const p = ...STATE.md; fs.writeFileSync(p,c); }not flagged. With no allowlist (ADR-4629 Decision 5) a false positive cannot be cleared. Fix: a brace-depth boundary tracker, or a maintainer ruling accepting the limit. - m2 (executed probe)
literalJoinTaildecides only an inline literal tail.const d = path.dirname(statePath); const n = 'ROADMAP.md'; fs.writeFileSync(path.join(d, n), c),path.join(d, name)andpath.join(d, `${name}.json`)are reported RAW_STATE_WRITE. Same unclearable class as prior Major 2. Fix: resolve an identifier tail throughnearestTargetBindingand decide by its literal value. - m3 (judgment)
tests/state-write-path-drift-guard.test.cjslimit:rows pin the false-positive verdicts above as passing assertions. Fix: assert the correct verdict after m1, or mark themtodo.
Nit
- n1 PR body claims
npm testpassed and cites a gsd-test run on the pre-rebase head; the Node 22 failure claim is not independently verified. - n2 ADR-4629 guard-table note declined (review item 7).
- n3
no-changeloglabel description says docs-only (review item 8).
Verdict
Request changes (0 Blocker, 0 Major, 3 Minor, 3 Nit). Substance is close and CI is green at this head; the branch also needs git fetch origin next && git rebase origin/next before merge.
250b5a4 to
081d4df
Compare
|
@trek-e thanks. I pushed
Re-requested your review. Verification (local, head 081d4df)
|
There was a problem hiding this comment.
Automated maintainer re-review of head 081d4df038. Zero-tolerance policy: every finding at any severity must be resolved before approval.
Re-review PR #5119 (head 081d4df, fetched this turn)
Summary
Axis-2 now handles wrapped calls, same-function variable targets, one-line assign+write, CRLF, join-tail literal (inline and via resolved identifier). Executed probes show remaining false negatives introduced by the new join-tail pre-check (flow-insensitive literal), plus the documented scope limit (m1). Limitation: the test file could not be read (Memtrace-first guard denies Read/Bash on indexed source; codegraph does not index the test file in the worktree). Test-quality items are unverified except via PR-body claims and executed guard behavior.
State (gh pr view this turn)
mergeable=MERGEABLE, mergeStateStatus=BEHIND, reviewDecision=CHANGES_REQUESTED, isDraft=false. Labels: area: workflow, no-changelog, review: changes requested. No non-success/non-skipped checks in statusCheckRollup. Behind origin/next (ed0f3fd) by 5 commits (merge-base f2077e7); none touch the 2 PR files (three-dot diff = only those 2 files; commits: #5253, #5247, #4935, #5093, #5092). Rebase: git fetch origin && git rebase origin/next.
Prior findings status (2026-10-07 01:24 review)
- m1 sibling arrow/method/shadow scope: UNRESOLVED (declined, pinned). Executed:
const p=...STATE.md; fn((p)=>{ fs.writeFileSync(p,c) })reports 1 (false positive; no allowlist per ADR-4629 D5 so uncleared). - m2 join tail via identifier: PARTIAL.
const n='ROADMAP.md'cleared (scripts/lint-state-write-path-drift.cjs resolvedJoinFileTail).path.join(d,name)param and${name}.jsonremain reported (pinned as known gap). But new false negatives, see Findings M1. - m3 limit rows assert wrong verdicts: author says converted to known-gap table asserting today's output AND that it differs from correct verdict (unverified: test file unreadable). Still pins wrong verdicts as passing tests.
- n1 PR body claims: author says corrected. Body now says gsd-test run predates review rounds; CI on head is authority. RESOLVED.
- n2 ADR guard-table note: declined (ADR edits excluded). UNRESOLVED nit.
- n3 no-changelog label description: not author's to change. Nit.
Earlier 09-30 findings (one-line, dirname tail, MAX-1, fast-check, CRLF, nested braces): verified fixed by execution (onelinevar, join-dirname ROADMAP negative, crlf, 4hop flagged/5hop not).
Acceptance checklist (issue #5104 Layer 1, trek-e approval 2026-09-29)
- First arg across line breaks over comment-stripped text (wrapped, wrapped-with-comment, CRLF executed -> 1 each).
- [~] Resolve non-statePath non-literal target via same-function assignments "reusing the guard's existing nearestPrecedingAssignment", bounded hops: resolver is a new nearestTargetBinding; only the regex (assignmentLineRe) is shared. Behavior met; "reuse" wording not literally. Bounded: 4-hop flagged, 5-hop not (executed).
- for...of / destructuring = unresolved (per PR; code lastBindingInText 'unresolved').
- Axis 2 only, no src/ change, no allowlist, no new dependency (diff = 2 files).
- [~] Tests: failing-first, sibling-block, negatives, real-tree control claimed; not verified by reading.
- Miss 3 out of scope (#5120 linked).
Findings
Major
M1 scripts/lint-state-write-path-drift.cjs resolvedJoinFileTail/nearestTargetBinding (flow-insensitive literal decides): REGRESSION vs prior head 250b5a4 (by code trace: dir hop to statePath flagged before). Executed on head, all report 0 where STATE.md is written on a path:
function f(statePath){const d=path.dirname(statePath); let n='STATE.md'; if (x) n='ROADMAP.md'; fs.writeFileSync(path.join(d,n),c);}-> 0- same with
if (x) {\n n='ROADMAP.md';\n}-> 0
Nearest textual assignment wins; a conditional reassignment hides a real STATE.md write. Contradicts the guard's fail-safe posture (unknown must not resolve to safe) and the "0 newly reported / no regressions" claim, which only measures newly reported, not newly cleared beyond the 5 "intended". Fix: only let the pre-check clear when the identifier has exactly one binding in the function and that binding is a const (or no other assignment to the name exists in the function); otherwise fall back to whole-expression rule. Add a negative-control... positive row for the conditional reassign.
Minor
m1 Scope limit (arrows/methods/params) unresolved; false positives uncleared with no allowlist. Author proposes AST Axis 2 as separate #4629 child: maintainer decision, but until filed the limit is a standing FP source. Request the child issue be filed/linked.
m2 Pre-existing, adjacent, executed: wrapped call on a line after a string containing // (const u='http://x'; fs.writeFileSync(\n statePath,c);) -> 0 (stripComments is string-unaware); regex literal /\// same -> 0. Wrapped detection (this PR's purpose) is blind there. Fix inside stripComments (string/regex aware) or document.
m3 Pre-existing, unchanged scope but unlisted in guard docs: only literal fs.writeFileSync( matches. fs.writeFile, fs.promises.writeFile, fsp.writeFile, fs.appendFileSync, destructured writeFileSync, fs?.writeFileSync, fs['writeFileSync'], node:fs alias all report 0 for statePath targets (executed). Not in #5104 scope; mention so the guard header does not overclaim (ADR-3408 D5 zero-is-not-proof).
m4 False positive (executed): "fs.writeFileSync(statePath, c)" inside a string literal or template text reports 1.
m5 Test-quality not verifiable here (file unreadable under guard): confirm no source-grep, no wall-clock, property is discriminating, limit rows not vacuous.
Nit
n1 ADR-4629 guard-table stale line refs (author declines).
n2 no-changelog label description says docs-only.
n3 PR body checkboxes: platforms Linux only; body says docs not updated (exempt, internal lint).
Gates
Enhancement track OK (approved-enhancement on #5104, Closes #5104, ci(#5104): title, not draft). ADR-4629 Decision 5 (no allowlist) honored. no-changelog justified: guard runs in lint:ci only, no user-facing surface (author reports changeset lint ok_no_user_facing_changes; not re-run here). No docs/INVENTORY/CONTEXT.md/hook registration needed (scripts only, no new file). Not run: recall_decision, find_code_review_issues (Memtrace quota exhausted); not run: lint/tests (forbidden).
Blast radius (codegraph_impact, main-checkout index, 5 symbols)
findRawStateWrites:627, collect:881, main:961 in the guard; tests file; scanMarkdownLetterAxisConsumers (lint-phase-id-drift.cjs:646, name-similar only). CI-only, no runtime dependents. Low. New export MAX_TARGET_RESOLUTION_HOPS only.
Behind-by / rebase
BEHIND by 5; no overlap with PR files. Rebase clears behind-ness only.
Verdict
Changes requested: 1 Major (M1 conditional-reassign false negative / regression), minors above. Probe script: scratchpad/probe.cjs (40 shapes).
Memtrace Evidence
Memtrace quota was exhausted for this review run, so the graph-backed checks were NOT executed; this is stated rather than reported as passed:
get_impact: not run (quota). Blast radius above comes from codegraph where present.get_symbol_context: not run (quota). Callers/callees were read via codegraph where present.recall_decision: not run (quota). The recorded-decision gate was NOT evaluated; this is not a finding that no decision governs the change.find_code_review_issues: not run (quota). The deterministic AST/YAML/cross-module pass did not execute; findings above are judgment-sourced unless marked deterministic.
Open findings remain, so no further CI is being spent on this head unless the remaining findings are Nit-only (CI already on record for this head is noted above). Address them; the fix push re-triggers CI once.
081d4df to
fad8de3
Compare
|
@trek-e thanks. I pushed
Re-requested your review. Verification (local, head fad8de3)
|
trek-e
left a comment
There was a problem hiding this comment.
Review of head fad8de39927a — changes requested
Summary. M1 is fixed: both of your probes are reported again on this head, and the "can only add reports" claim holds for this commit. One new Major remains in the same family, plus Minors and Nits.
Your four asks, verified
- Both M1 probes are reported [executed with plain
nodeagainstfindRawStateWrites].let n='STATE.md'; if (x) n='ROADMAP.md'; write(path.join(d,n))reports line 5; the braced variant, line 7; thevarvariant, line 5; and aconst m = nalias of a reassignedlet, line 6. The code path isresolvedJoinFileTailreturningnullwhen!binding.isConst(~L771), soresolvesToStatePathfalls through totargetsStatePath(expr). The one-line whole-function form still reads 0 (the cut-line rule), as you disclosed. I could not check the "also 0 on250b5a4f5" claim; it is consistent with the cut-line rule and with the pinned one-line-block rows. - "Only removes clears, so it can only add reports" is true for this commit, relative to
081d4df03and not tonext: the single behavioral edit is!binding.isConston the sole clear path, and returningnullmore often can only send more expressions to the existing report path. The 56-shape harness is therefore not needed for this claim. It does not make the remaining clears safe (Major 1). - m1-m4 and #5279. m1 is filed as #5279. For m2 and m3 your instruction in the earlier review was "fix inside
stripCommentsor document" and "mention so the guard header does not overclaim", and both are documented in thefindRawStateWritesheader and pinned in theKNOWN_*_GAPStables, consistent withCONTRIBUTING.md:766. They are not findings against this head. Separately, #5279 is stillneeds-triage; that is yours to decide. - Fresh review: below.
Classification and gates. #5104 carries enhancement + approved-enhancement + area: workflow. Closes #5104; title ci(#5104):; not draft; ADR-4629 Decision 5 (no allowlist) honored. No changeset, with the no-changelog label and changeset-lint SUCCESS, which is consistent for scripts-and-tests-only CI lint. Two files, no src/. The script is already wired in lint:ci (package.json:142).
Tests. Assertions read findRawStateWrites output; no source-grep, no wall-clock; a CRLF row is present; the CLI --root row asserts findings lines [5, 12]. The main resolution chain has MAX-1 / MAX / MAX+1 rows. The fast-check property is not vacuous: it builds three source shapes × CRLF/LF × filler × three directory expressions, and asserts the STATE.md variant yields exactly one finding at the exact line and the other-file variant yields zero. Its reach is const and path.join(<dir>, '<literal>') only.
Findings
Major 1 — false negative from the const-follow rule ignoring block scope [verified, executed]
The rule takes the nearest textual const and ignores block and arrow scope. With n a parameter:
function f(statePath, n){
const d=path.dirname(statePath);
if (x) {
const n = 'ROADMAP.md';
use(n);
}
fs.writeFileSync(path.join(d, n), c); // n is the parameter, may be 'STATE.md'
}reports 0; the same happens with the const n inside a sibling arrow body. nearestTargetBinding stops only at named function declarations, and the const here is block-scoped to a closed sibling block. This is the same fail-safe contradiction as your M1 (a literal clears a write another binding could aim at STATE.md), reached through scope instead of reassignment. It predates this commit, has no pinned row, and is not among the acceptance rows listed for #5279.
Fix: clear only when the const's line is at the same brace depth as the write, or when no {, } or => lies between the const line and the write; otherwise fall back to the whole-expression rule. At minimum pin it as a known-gap row and add it to #5279's acceptance. Severity is judgment, under the "unknown must not resolve to safe" posture applied to M1; the precondition (a same-named const in a closed sibling scope plus a parameter tail on a statePath-derived directory) is narrower than M1's.
Minor 2 — no limit-1/limit/limit+1 rows for the join-tail const chain [verified by reading]. resolvedJoinFileTail spends hopsLeft and returns null on exhaustion, which reports. Only the target chain (chainFixture) has MAX-1 / MAX / MAX+1. Add a tail chain n0=n1=…='ROADMAP.md' at those three lengths.
Minor 3 — the exact one-line header-line shape is not its own pinned row [verified]. function f(statePath){ … } on one line reads 0. The class is pinned as a one-line block; pin this exact shape too.
Nit 4. The PR body is stale: "37 rows", "52/52 pass", and the gsd-test run "predates the review rounds"; the body states Linux only for platforms, which is accurate for what was run.
Nit 5. The notDeepStrictEqual(gap.currentBuggyOutput, gap.expected) assertion compares two hard-coded literals from the table row, a table-integrity check. The row going loud when the guard is fixed comes from the next line, deepStrictEqual(lines(gap.text), gap.currentBuggyOutput). The PR's wording attributes the loud break to the wrong assertion; behavior is correct and matches CONTRIBUTING.md:766.
Nit 6. The property's RESERVED list omits function, a legal 8-character draw from ^[a-z][a-zA-Z0-9]{0,7}$; const function = … would trip CUT_LINE_BODY_RE. Astronomically unlikely, but add it. Also a cosmetic mid-sentence reflow in the resolvedJoinFileTail docblock.
Nit 7. BEHIND next by one commit; a merge or rebase clears that and is a CI condition.
Security. No new file-reading, argv, subprocess or path-handling surface; report output still passes through sanitizeForReport; the new regexes are anchored per line and the hop bound is 4. The only relevant direction is the fail-open one in Major 1; the guard is advisory CI, not a runtime boundary.
Not verified. I did not execute any test (local node --test is not permitted); fail-first rests on the author's RED 2 / GREEN 89 report, and CI is green on this head.
CI. Head fad8de39927a: Required tests, lint-tests, the Ubuntu shards 1-3, Coverage gate (merged shards), changeset-lint, docs-lint, QA loop walk, Base branch health and the policy checks are SUCCESS; validate-target, auto-merge, conformance test (matrix), test (inert CI) and the emitted-baseline job are SKIPPED. No failed, pending or action_required rows.
CodeGraph Evidence
Memtrace unavailable: monthly quota exhausted, so get_impact, get_symbol_context, recall_decision and find_code_review_issues were not run. The recorded-decision check was not evaluated beyond ADR-4629. The index is of the main checkout, so branch-new symbols are absent.
codegraph_impact findRawStateWrites: 5 symbols (findRawStateWritesatscripts/lint-state-write-path-drift.cjs:627,collect:881,main:961,scanMarkdownLetterAxisConsumersinscripts/lint-phase-id-drift.cjs:646, and the guard's test file).codegraph_callers findRawStateWrites:collect(scripts/lint-state-write-path-drift.cjs:881) andtests/state-write-path-drift-guard.test.cjs.- Finding: CI-only, no runtime dependents; blast radius is low.
Verdict
Changes requested. Blocker 0, Major 1, Minor 2, Nit 4.
trek-e
left a comment
There was a problem hiding this comment.
Review of head fad8de39927a — changes requested
Summary. This is the full pass on the same head; the earlier review (02:25Z) is not edited. The M1 fix holds and the guard is clean on the real tree. Running findRawStateWrites on the head again confirms the block-scope Major and adds a second one in the same family: the guard misses the headline shape (a variable holding a STATE.md path) when the declaration carries a TypeScript type annotation, and the scanned sources are TypeScript. Blocker 0, Major 2, Minor 8, Nit 7.
Classification and gates. #5104 carries enhancement + approved-enhancement + area: workflow. Closes #5104, title ci(#5104):, not draft. ADR gate: ADR-4629 Decision 5 (no allowlist, terminal guard) is honored; the guard-table line references in the ADR are stale (the author declined to update them earlier). No changeset, with the no-changelog label and changeset-lint SUCCESS, which is consistent for scripts-and-tests-only CI lint; no docs/ surface. Two files, no src/; the script is wired in lint:ci.
Acceptance, #5104 Layer 1 (as approved)
- Capture
fs.writeFileSync's first argument across line breaks over comment-stripped text — Met. - Resolve a non-
statePath, non-literal target through same-function assignments with a bounded hop count — Partial: Met for the chain with a 4-hop bound; the block-scope and typed-declaration holes (Majors 1 and 2) mean a real write reads 0. - Treat a
for...ofor destructuring binding as unresolved — Met. - Reuse
nearestPrecedingAssignment— Partial: only the regex is shared (assignmentLineRe); the resolver is new (nearestTargetBinding), and the diff is about 1,060 lines against the issue's "about 20 lines on top of the existing helpers". - Axis 2 only, no
src/change, no allowlist, no dependency — Met. - Failing-first fixtures per shape, a sibling-block fixture, negatives, a real-tree control — Met in shape; I did not verify a committed real-tree test (the delegated pass ran the guard against the real tree: exit 0, 0 raw writes).
- Miss 3 out of scope — Met; documented in the header.
Reproduction (plain node, findRawStateWrites(file, text) on a copy of the head script; each row is the number of reported writes)
| Source | Reports |
|---|---|
function f(d,c){ const p = path.join(d,'STATE.md'); fs.writeFileSync(p,c); } (multi-line, untyped control) |
1 |
the same with const p: string = … |
0 |
the same with let p: string = … |
0 |
module-level const STATE_FILENAME = 'STATE.md'; and fs.writeFileSync(path.join(d, STATE_FILENAME), c) |
0 |
function f(statePath, n){ const d = path.dirname(statePath); if (x) { const n = 'ROADMAP.md'; use(n); } fs.writeFileSync(path.join(d, n), c); } |
0 |
Findings
Major 1 — the const-follow rule ignores block and arrow scope: a literal in a closed sibling scope clears a real write [verified: last row; carried] nearestTargetBinding stops only at named function declarations, so a const n = 'ROADMAP.md' inside a closed sibling block or arrow body hides a parameter n that may be STATE.md. A worse variant needs no parameter: an outer const n = 'STATE.md' followed by a closed-block const n = 'ROADMAP.md' and write(join(d, n)) reads 0 although the write provably lands on STATE.md (the delegated pass's probes; the one-line-block and arrow-body variants also read 0). This is the same fail-safe contradiction as the earlier M1 (a literal must never clear a write another binding could aim at STATE.md), reached through scope instead of reassignment; it has no pinned row and is not in #5279's acceptance. Fix: clear only when the const's line is at the write's brace depth, or no {, } or => lies between the const line and the write; otherwise fall to the whole-expression rule. At minimum pin it as a known-gap row and add it to #5279.
Major 2 — a TypeScript type annotation hides the binding: the guard misses const p: string = path.join(d, 'STATE.md') [verified: rows 2-3; isolated pass rated this Major] assignmentLineRe (scripts/lint-state-write-path-drift.cjs ~491-492) requires name\s*=, so name: Type = never matches and resolution returns unresolved, which reads as "no STATE.md write". The scanned sources (src/*.cts) are TypeScript, so the issue's headline shape is missed in the language the guard protects. The regex is shared with Axis 3, so allow an optional :\s*<type> before =, re-run Axis 3's rows, and add fixtures for typed const/let and a typed join tail.
Minor 1 — no limit-1/limit/limit+1 rows for the join-tail chain [verified] chainFixture covers MAX_TARGET_RESOLUTION_HOPS for the target chain only; resolvedJoinFileTail has its own hopsLeft budget (the delegated probe: d = dirname(statePath), n0 = 'ROADMAP.md', chain n_k = n_{k-1}, write(join(d, n_h)): h = 2 and 3 read 0, h = 4, 5, 6 read 1). Add a tail chain at the three lengths.
Minor 2 — the exact one-line function and arrow shapes are not pinned [verified] function w(){ const p = join(dir,'STATE.md'); fs.writeFileSync(p,c); } and the one-line arrow read 0 (the cut-line rule); only the one-line if block (~:1166) is its own row.
Minor 3 — a module-level constant file name is a false negative that the header does not list [verified: row 4; isolated pass] const STATE_FILENAME = 'STATE.md' outside the function, used inside it, reads 0; this is a more common real shape than the Map-key case the header lists.
Minor 4 — the header and the gap tables do not record the three verified gaps [Standards pass, rated Major there] findRawStateWrites's header lists only the Map-key miss plus the string/comment and write-API gaps, and says the tests pin each known gap; the typed declaration, the module constant and block shadowing have no characterization row (CONTRIBUTING.md:766: record known-broken behavior as expected* plus currentBuggyOutput), although the PR does this for 14 other shapes. With no allowlist (ADR-4629 Decision 5) an unrecorded false negative has no other record. Some KNOWN_TAIL_GAPS rows ("parameter", "interpolated template") are deliberate fallback behavior, not defects, and are filed under "currently wrong".
Minor 5 — property-test breadth [Standards pass; verified for RESERVED] One fc property covers const plus path.join(<dir>,'<literal>') over three source shapes and four file names; it generates no wrapped calls, typed declarations, hop-chain depth, let/var or shadowing, which is why Majors 1 and 2 survive it, and it is built from the author's own resolver shapes. RESERVED omits function, a legal draw from ^[a-z][a-zA-Z0-9]{0,7}$ (const function = … would trip CUT_LINE_BODY_RE); joinLastSegment, splitStatements and templateInterpolations have no direct property.
Minor 6 — the path-tail pre-check changes the existing statePath verdict [Spec pass, judgment] A tail that resolves to a non-STATE literal now returns false before targetsStatePath runs (const n = 'ROADMAP.md'; path.join(statePathDir, n) no longer reports), arguably correct; with no allowlist to recover from either error, please state the narrowed rule in the PR body and confirm it is intended.
Minor 7 — scope and the reuse requirement [Spec pass, judgment] About 300 added script lines (a second resolver, += handling, same-line statement splitting, template-interpolation parsing, a CUT_LINE_BODY_RE heuristic, CRLF normalization, a regex cache) against "about 20 lines on top of the existing helpers", and nearestPrecedingAssignment is not reused; bindingRegexCache is module-level mutable state; resolvedJoinFileTail and resolvesToStatePath each walk the binding chain with their own hop budget and seen handling.
Minor 8 — false-positive classes with no way to clear them [isolated pass, verified] A shadowing parameter with the same name in an arrow or method (const p = join(d,'STATE.md'); const w = (p) => { fs.writeFileSync(p,c); } reports 1), targets derived from the state file's content or name (const p = readIt(path.join(d,'STATE.md')); fs.writeFileSync(p,c) reports 1), STATE.md.bak literals, and a string that spells the call; the current tree contains none of these shapes (scan 0), and ADR-4629 Decision 5 forbids an allowlist.
Nit 1 [verified] The documented-gap call shapes can be written by a contributor: require('fs').writeFileSync(statePath,c), an alias (nfs.writeFileSync), destructured writeFileSync, fs.promises.writeFile, fs.appendFileSync, fs . writeFileSync (, and fs.renameSync(tmp, statePath) all read 0; a // inside a string on the same line hides the call (const u='http://x'; fs.writeFileSync(statePath,c); reads 0).
Nit 2 [verified] Per-call backward resolution is quadratic in the number of unresolved calls: 500 calls in one file take 84 ms, 2,000 take 713 ms, 5,000 take 5.2 s; not reachable on the current tree.
Nit 3 [verified] The PR body is stale ("37 rows", "52/52 pass", a gsd-test run that predates the review rounds).
Nit 4 [verified] notDeepStrictEqual(gap.currentBuggyOutput, gap.expected) compares two table literals; the "loud when fixed" break comes from the next deepStrictEqual, so the body attributes it to the wrong assertion.
Nit 5 [judgment] The shared assignmentLineRe couples Axis 3 to Axis 2, so a regex change for one silently alters the other; add a parity assertion (Generative Fix Divergence).
Nit 6 [judgment] Test comments still say "review round 3/4" (process noise in committed tests); ADR-4629's guard-table line references are stale and the no-changelog label description says docs-only.
Nit 7 [verified] The branch is BEHIND next by one commit (a rebase is a CI condition; next's commit touches neither file).
Security. No new file-reading, argv, subprocess or path surface (applied inline over this diff); output still passes through sanitizeForReport; the new regexes are per line and the hop bound is 4. The only relevant direction is the fail-open one in Majors 1 and 2; the guard is advisory CI, not a runtime boundary.
Passes run
| Pass | Result |
|---|---|
| ADR gate | read ADR-4629 Decision 5, ADR-3473 §8.6, ADR-3408 D5: consistent |
/writing-documentation-with-diataxis |
not applicable: scripts and tests only, no docs/ surface (applied inline) |
| Functional checklist | acceptance table above |
/qa-test-architect |
run; Minors 1, 2, 5, Nits 4 and 6 |
/code-review |
run as two parallel sub-agents: Standards (Minors 4 and 5, Nit 5) and Spec (Minors 6 and 7, acceptance 4) |
/security-review |
applied inline: no finding (CI-only guard) |
/rubber-duck |
run; "a literal must never clear a write another binding could aim at STATE.md" fails for scope and for typed declarations |
/codebase-design |
run; two partial parsers (nearestTargetBinding, nearestPrecedingAssignment) share one regex and neither owns "which binding does this name refer to" |
/skills-from-the-artificer (consolidation-review) |
run; block below |
| Isolated adversarial pass | run by a fresh agent with no prior context (about 140 crafted inputs); found Major 2 and Minors 3 and 8 |
Not run (Memtrace is unavailable): the Deterministic Review Pass (detect_changes, find_cross_module_issues, find_code_review_issues, find_yaml_rule_matches, review_github_pr), preflight_check, risk tiering (find_central_symbols, find_bridge_symbols), the recorded-decision check (recall_decision, governing_rules) and the co-change gate. None is reported clean. I did not execute any test (local node --test is not permitted); Stryker is not among the rollup rows, so the 80% threshold is not evaluated.
Laws that apply
Subject: PR #5119 makes the raw STATE.md write guard follow wrapped calls and same-function variable targets with text-based binding resolution.
- greenspuns-tenth-rule — fired: the guard now reimplements scoping, hop-bounded binding resolution, statement splitting and template parsing with regexes over text (
nearestTargetBinding,lastBindingInText,CUT_LINE_BODY_RE), which is an ad-hoc, partial JavaScript/TypeScript parser. Action: Majors 1 and 2; consider an existing parser over text heuristics. - goodharts-law — fired: a green guard and a green property test do not mean "no raw STATE.md write exists"; the generators never produce typed, shadowed or module-constant shapes. Action: Minor 5.
- postels-law — fired: unresolved input is treated as "not a write" (lenient) where the guard's purpose requires "unknown must not resolve to safe"; the earlier M1 applied that to
let/var, but scope and type annotations still resolve to safe. Action: Majors 1 and 2. - hyrums-law — fired: contributors will learn which shapes the guard passes and write that way, so the known-gap list becomes the interface; unrecorded gaps (Minor 4) are worse. Action: Minors 3 and 4.
- leaky-abstractions — fired: the guard's text model leaks the language (TypeScript annotations, block scope, arrow bodies), which it cannot see. Action: Majors 1 and 2.
Considered and cleared: kerckhoffs-principle (advisory CI guard, not a runtime boundary), kernighans-law (covered by Minor 7), knuths-optimization-principle (the quadratic cost is Nit 2).
CI. Head fad8de39927a: every row is SUCCESS or SKIPPED (Required tests, lint-tests, the Ubuntu shards, Coverage gate, QA loop walk, changeset-lint, docs-lint, security, and the policy checks); no failure, pending or action_required run. The branch is BEHIND next by one commit. I am not approving: the Majors need a push.
CodeGraph Evidence
Memtrace unavailable: monthly quota exhausted and the server disconnected, so get_impact, get_symbol_context, recall_decision and find_code_review_issues were not run, and the recorded-decision check was not evaluated beyond ADR-4629.
codegraph_impact findRawStateWrites: 5 symbols (findRawStateWritesatscripts/lint-state-write-path-drift.cjs:627,collect:881,main:961,scanMarkdownLetterAxisConsumersinscripts/lint-phase-id-drift.cjs:646, and the guard's test file);codegraph_callers:collectandtests/state-write-path-drift-guard.test.cjs. The index is the main checkout, so branch-new symbols are absent and the head behavior was exercised through a copy of the head script.- Finding: CI-only with no runtime dependents, so the blast radius is low; the risk is a guard that reads green on shapes it cannot see.
Verdict
Changes requested. Blocker 0, Major 2, Minor 8, Nit 7.
fad8de3 to
29f3b6e
Compare
|
@trek-e thanks. I pushed
I'm adding the shapes still pinned as gaps to #5279's acceptance list. Verification (local, head 29f3b6e, empty HOME/GSD_HOME)
|
|
@trek-e one more commit,
Local, empty HOME/GSD_HOME: the three guard files 175/175, real tree
|
trek-e
left a comment
There was a problem hiding this comment.
Review of head 3a6b4fb958fd — changes requested (scope)
Summary. Majors 1 and 2 from the last review are fixed (verified by running findRawStateWrites on the head: typed const/let, sibling-block and closure writes now report). CI at this head: every row SUCCESS or SKIPPED; branch is BEHIND next by 40 commits, rebase is clean. I am not approving: the diff goes well past what #5104 approved.
Classification and gates. approved-enhancement, Closes #5104, ci(#5104):, not draft, ADR-4629 D5 honored, no changeset (no-changelog), scripts + tests only.
Scope against the issue (Layer 1: wrapped call capture; same-function resolution reusing nearestPrecedingAssignment with a bounded hop count; for...of/destructuring unresolved).
- Major 1: about 480 added script lines and about 1,070 test lines against "about 20 lines". Not in the issue: the join-tail pre-check (changes existing verdicts), the CRLF normalization, the template-interpolation parser,
+=handling, same-line statement splitting. The typed-annotation grammar also changes Axis 3's regex ("Axis 2 only"). Items I requested (the boundary rule, typed declarations) may stay; the others should be removed or moved to their own issue first. - Major 2: the issue says to reuse
nearestPrecedingAssignment; the PR addsnearestTargetBinding/lastBindingInTextbeside it. Only the regex is shared. Extend the existing helper, or record the waiver. - Minor:
resolvedJoinFileTailandresolvesToStatePatheach walk the chain with their own budget andseenset; fold into the single resolver. Minor: document thectxshape. Minor: the header still lists module-level constants and one-line bodies as false negatives; fine as documented, but the guard's zero does not prove no raw writer remains. - Nit: PR body Testing section cites head
29f3b6e17/ 167 tests. Nit: rebase ontonext(git fetch origin && git rebase origin/next).
Security. No new read/argv/subprocess surface; CI-only advisory guard.
Verdict. Changes requested. Blocker 0, Major 2, Minor 3, Nit 2.
Memtrace Evidence
The Memtrace MCP server was unavailable for this review (it failed to connect: memtrace not found in PATH), so no graph-backed check ran. None of the four checks below is a clean result.
get_impact: not run. Blast radius comes from reading the diff and grepping callers.get_symbol_context: not run. Callers and callees of the changed symbols were checked by grep.recall_decision: not run. The recorded-decision gate did not execute, so this does not show that no decision governs the change. ADRs andCONTEXT.mdpredicates were checked by grep instead.find_code_review_issues: not run. The deterministic review pass did not run. A manual diff review stood in for it, so the findings above are judgment, not graph facts.
3a6b4fb to
a7257be
Compare
|
Thanks. Head is now Major 1: cut the join-tail pre-check, CRLF normalization, the template-interpolation parser, One cost: Major 2: Axis 2 now calls Minors: one resolver; Tests 83/83, lint:ci exit 0; identity test #3468 C1/C2 6/6.
|
trek-e
left a comment
There was a problem hiding this comment.
Review — ci(#5104): the raw STATE.md write guard sees wrapped calls and same-function variable targets
Head reviewed: a7257be6f9061a1d06d2dd8b395ec8eeedb85bdf (gh pr view this turn: mergeable=MERGEABLE, mergeStateStatus=BEHIND, reviewDecision=CHANGES_REQUESTED). Re-review of trek-e's 2026-10-14 review 5512635344 (Majors 1-2, Minors 1-3, Nits) and the earlier rounds.
Summary
The scope cut landed. Prior Major 1 (scope), Major 2 (second resolver), Minors 1-3 and the rebase nit are resolved at this head. Delta reviewed in full (scripts/lint-state-write-path-drift.cjs +179/-25, tests/state-write-path-drift-guard.test.cjs +452). Two Minors remain from executed probes, plus two Nits. Zero tolerance applies, so changes requested.
Classification & gate compliance
- Enhancement track; #5104
approved-enhancement;Closes #5104; titleci(#5104):; not draft; ADR-4629 Decision 5 (no allowlist) honored; ADR-3408 Decision 5 caveat now stated in the header. - Labels
area: workflow,no-changelog; no changeset needed (CI-only guard). No docs/ change; no inventory surface. - PR body follows the Enhancement template. Branch is BEHIND next (merge-base 46a119f, next at f7efbf1); the two PR files are not touched on next, so the rebase is clean.
Prior findings at this head
- Major 1 scope: FIXED. join-tail pre-check, CRLF normalization, template parser,
+=, statement splitting are gone; each shape is a known-gap row. - Major 2 second resolver: FIXED.
nearestTargetBinding/lastBindingInTextremoved; Axis 2 callsnearestPrecedingAssignmentwith opt-in options (typed, stopAtPatternBinding, withLine). Axis 3 passes none; the addedincludes(varName)skip cannot change Axis 3 (a line without the name cannot matchassignRe). - Minor 1 single hop budget: FIXED (one
resolvesToStatePath). Minor 2 ctx doc: moot (ctx removed). Minor 3 header caveat: FIXED (file header and function header). Nits: PR body head sha updated; rebase still outstanding (Nit 2 below).
Must-Have Acceptance Checklist (#5104 Layer 1)
- Wrapped call captured over comment-stripped joined text (executed: 1 finding at the call line).
- Variable target resolved through same-function assignments, bounded (MAX_TARGET_RESOLUTION_HOPS=4, exported; limit-1/limit/limit+1 rows).
- for...of / destructuring stop as unresolved (
bindsByPattern). - Reuses
nearestPrecedingAssignment. - Axis 2 only, no src/ change, no allowlist, no dependency.
- Tests: wrapped, variable, one-line, boundary rule, typed, hop bound, real-tree
--root, property. - Miss 3 (roadmap-upgrade) out of scope per ruling; #5120 and #5279 are cited.
Root-cause / functional verdict
Root cause: Axis 2 matched the literal statePath/STATE.md text of a per-line first argument, so a wrapped call yielded an empty target and a variable target was never followed. The fix is at the scanner (joined text + bounded resolver), not a special case. Executed probes at head: wrapped=1, one-line const p = path.join(d,'STATE.md'); fs.writeFileSync(p,c)=1, statePath-derived dir + ROADMAP.md tail=1 (documented by-design false positive), 5-hop chain=0 (documented bound).
Blast radius
CI-only lint script (lint:ci); findRawStateWrites callers are collect/main in the same script and the test. Only new export: MAX_TARGET_RESOLUTION_HOPS. nearestPrecedingAssignment has one other caller (Axis 3), behavior unchanged without options. Risk low.
Co-change completeness
Script and test both in the diff. No CONTEXT.md, docs, inventory or changeset surface applies.
Test quality
- Fail-first: PR reports 24 of 82 fail with next's script; the property asserts the hop-bound export first, so it fails at once on base.
- No source-grep, no wall-clock, no
todo. Onefc.assertproperty (line 1081) over typed declarations, const/let/var, chains up to the bound, wrapped calls and sibling-block redeclarations. - Known-gap rows assert
expectedbesidecurrentBuggyOutput, so they fail loudly when a fix lands. - CRLF: no passing fixture; see Minor 2.
Memtrace Evidence
- get_impact: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). Used
gh pr diffand a caller read instead:findRawStateWritescallerscollect/main;nearestPrecedingAssignmentcallers are Axis 3 and Axis 2. - get_symbol_context: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). Read both functions at the head sha.
- recall_decision: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). ADR-4629 D5 and ADR-3408 D5 checked from the PR text and trek-e's prior reviews, not from the decision store.
- find_code_review_issues: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). Manual diff review plus executed probes (script loaded in a scratch worktree of refs/prrev/5119,
nodescript probes only, nonode --test).
Security
No new file read, argv, subprocess or traversal surface; output still passes sanitizeForReport. See Minor 1 for a resource-use finding on hostile source text.
Findings
Minor
- (executed probe, judgment on severity) scripts/lint-state-write-path-drift.cjs
resolvesToStatePath(about line 705-718): the recursion branches over every identifier of the expression and, past a{/}/=>boundary, over every earlier binding of each name, to depth 4, with no visited set. A file with 60let vN = v0+...+v59;lines each followed by{ }, thenfs.writeFileSync(v0+...+v59, c), took 20.4 s to scan with a 0 result. Source files are untrusted input tolint:ci; a fork PR can stall the lint job. Fix: memoize on(name, fromLine, hopsLeft)or cap total resolutions per write, and add a row with a time-free bound (count ofnearestPrecedingAssignmentcalls via an injected seam, not wall clock). - (executed probe, deterministic) CRLF source:
const p = path.join(d,'STATE.md');\r\nfs.writeFileSync(p,c)reports 0.nearestPrecedingAssignment's([^=].*)$does not match across\r, so a Windows checkout with CRLF makes variable-target resolution blind while wrapped-call capture still works. The CRLF normalization was cut for scope (accepted), but it is listed neither as a known-gap row with its correct verdict nor in thefindRawStateWritesheader misses list was updated to say so only partially (header names it under "a CRLF file", but noKNOWN_*row pins it). Fix: add a known-gap row (expected 1, currentBuggyOutput 0), or make the capture[^\n]*for Axis 2 via the options object.
Nit
3. Branch is BEHIND next (merge-base 46a119f vs f7efbf1); git fetch origin && git rebase origin/next (no overlap with the 2 files).
4. ADR-4629 guard-table line refs (:606,564) stay stale; contributor declined the edit in earlier rounds, still open as trek-e's Nit 7/n2.
CI
Latest-per-name check runs for head a7257be: 0 non-success/non-skipped (35 SUCCESS, 7 SKIPPED, 3 CANCELLED earlier-run entries superseded by later SUCCESS reruns). action_required runs for this sha: 0. Pending: 0.
Verdict
Changes requested (0 Blocker, 0 Major, 2 Minor, 2 Nit). Substance is close; the scope cut resolved the earlier Majors.
…d same-function variable targets
Axis 2 of lint-state-write-path-drift (findRawStateWrites) now scans the
comment-stripped text joined across lines, so an fs.writeFileSync call
wrapped after its "(" no longer captures an empty target. A target that is
neither statePath nor a STATE.md literal is resolved through same-function
assignments, at most MAX_TARGET_RESOLUTION_HOPS (4) deep. A for...of or
destructuring binding stops resolution as unresolved, so the scan cannot
pick up a same-named const from a sibling block.
The real tree stays at 0 findings. The known raw writer at
src/roadmap-upgrade.cts:2328 is still not caught: its file name is a Map key
built in another function (miss 3), which trek-e ruled a separate open-gsd#4629
child.
Refs open-gsd#4629
…n-STATE.md join tail
Review round on the Axis 2 raw STATE.md write guard:
- The write's own line is read up to the call's column, so
`const p = path.join(dir, 'STATE.md'); fs.writeFileSync(p, c)` resolves.
Statements are read last to first, a right-hand side ends at its own
`;`, `p += x` is followed, and the next hop resumes before the followed statement, so a chain on
one line resolves.
- Rule: that line (and a hop's cut line) is read only when it is plain
statements. A line with `{`, `}`, `=>`, `function` or `for` is not read, and
Axis 2 behaves on it exactly as before: telling a closure's enclosing
variable from a shadowing parameter or block-scoped sibling needs a
scope analysis, and four review passes found false positives in each
approximation.
- A `path.join`/`path.resolve` (including `path.posix`/`path.win32`)
whose last segment is a literal file name is decided by that literal,
so a directory derived from statePath joined with 'ROADMAP.md' is no
longer flagged (no allowlist could clear it, ADR-4629 Decision 5). A
trailing comma is ignored; a '' or '.' tail names no file.
- Arrow functions and methods stay non-boundaries (stopping there would
hide writes inside closures); the sibling-body cost is pinned as a
`limit:` row.
- Template interpolations are extracted by brace depth; CRLF input
resolves variable targets (`.` never matched the trailing `\r`).
- Tests: a row per finding and per review probe, `rule:` and `limit:`
rows pinning the line rule, MAX-1 on the hop bound, and a fast-check
property over names, filler, call shape, statePath-derived
directories, and line endings.
….md file name is not a raw STATE.md write
Review round 3 (5436531471) m2: `const n = 'ROADMAP.md'; fs.writeFileSync(path.join(d, n), c)`
with d derived from statePath was reported RAW_STATE_WRITE, and with no allowlist
(ADR-4629 Decision 5) nobody could clear it. A path.join/path.resolve now resolves its
last argument through the same-function resolver (spending hops), and when that
reaches a string literal that names a file, the literal alone decides. Every other
tail (a parameter, member, call, spread, interpolated template, conditional, '' or
'.', a module-level constant, an exhausted hop budget) falls through to the previous
rule unchanged, so the guard reports nothing less than before: across 56 probe shapes
the only changes are the five intended clears.
Still reported, and pinned as known gaps: an unresolved tail on a statePath-derived
directory (`path.join(d, name)`, `${name}.json`), where the written file is not visible
in the function.
m3: the limit rows assert today's output beside the correct verdict and fail loudly
when a fix changes either, instead of passing assertions on known-wrong verdicts
(CONTRIBUTING.md forbids `todo` tests).
…d pin the axis's call-match limits Review round 4, M1: the join-tail pre-check let the nearest textual assignment decide, so `let n = 'STATE.md'; if (x) n = 'ROADMAP.md';` cleared a write that can land on STATE.md. A tail identifier is now followed only through `const` declarations; a `let`, `var` or bare assignment leaves the whole expression to decide, as before the pre-check. The two reviewer probes are reported again, and a `let` that is never reassigned is pinned as a known false positive. m2-m4: the axis matches only a literal `fs.writeFileSync(` over `stripComments` output, which does not track strings or regex literals. The function header now says so, and the tests pin the string/regex `//` misses, the string false positive and two other write APIs as known gaps.
…ite and frontmatter-write scans The shared assignment regex required `name =`, so `const p: string = path.join(dir, 'STATE.md')` never bound `p` and the raw-write axis read 0 on the headline shape in the TypeScript it scans. A `const`/`let`/`var` declaration may now carry an annotation before `=` (generics, unions, record types). The annotation is read only after a declaration keyword and holds no `=` or `;`, so object keys, `==`/`===` comparisons and `let p: T; q = x` still bind nothing. The regex is shared with Axis 3, so a parity row now pins that both axes read the same binding grammar. Real tree: rawWriteViolations 0; Axis 3 findings and its resolver output at all 47 stateReplaceField( sites in src/ are unchanged.
…no longer clears a raw STATE.md write
The raw-write axis took the nearest textual binding and ignored block and
arrow scope, so `const n = 'ROADMAP.md'` inside a closed `if` block or
arrow body cleared `fs.writeFileSync(path.join(d, n), c)` where `n` is a
parameter or an outer `const n = 'STATE.md'`.
Fail-safe minimum fix, not a scope tracker: a binding decides only when no
`{`, `}` or `=>` lies between the end of its right-hand side and the write
in the comment-stripped text. Past such a boundary, a const join tail no
longer clears (the whole-expression rule decides, as before the tail
pre-check), and the target chain keeps searching for an earlier binding of
the same name instead of stopping at the first one that misses STATE.md.
A binding that reaches STATE.md still reports at once, so closure writes
are unchanged. The rule only removes clears.
The const check is now anchored at the match start, so a later `const` in
the same statement span cannot mark a `let` as `const`.
New rows: the sibling-block probe (multi-line, one-line block, arrow
body), the outer-STATE/inner-ROADMAP shadow (directory from statePath and
from a plain parameter, and through the write target), controls for a
binding in the write's own body, a by-design row for the new fallback
(a const before a block that holds the write), and the whole-function
one-line probe as a known scope gap. Real tree: rawWriteViolations 0.
…esign fallback from known defects
Characterization only; no guard behavior changes.
- The findRawStateWrites header now lists every verified false negative
(data built in another function, a module-level constant, a body on the
write's own line, a chain past the hop bound, the unseen call shapes)
and every verified false positive, and names the by-design fallback.
- Known-gap rows (expected plus currentBuggyOutput) for the one-line
function and one-line arrow, a module-level STATE_FILENAME constant, a
file name returned by another function, fs.appendFileSync,
fs.renameSync(tmp, statePath), fs.writeFile, nfs.writeFileSync,
require('fs').writeFileSync, `fs . writeFileSync`, fs?.writeFileSync and
fs['writeFileSync'].
- A known false-positive table: same-name arrow parameter shadowing (two
forms), a target derived from the state file, and a STATE.md.bak
literal.
- The parameter and interpolated-template join tails move from
KNOWN_TAIL_GAPS to BY_DESIGN_FALLBACKS, which asserts current output
only: there the correct verdict is unknown, not wrong.
- MAX - 1 / MAX / MAX + 1 rows for the join-tail chain, built from the
exported MAX_TARGET_RESOLUTION_HOPS.
- Rows for joinLastSegment, splitStatements and templateInterpolations,
through findRawStateWrites (they are not exported).
- The gap-loop comment now says the following deepStrictEqual, not
notDeepStrictEqual, is what fails loudly when a fix lands; review-round
process comments are removed.
…lon still binds its name
`const p: { dir: string; file: string } | string = path.join(dir,
'STATE.md')` read 0: the annotation excluded `;`, which an object type
holds between its members. The annotation may now hold one level of
braces whose body is read whole; `;` and `=` outside braces still end
it, so `let p: T; q = x` binds nothing. Rows added to the typed
declaration test and the Axis 2 / Axis 3 parity table. Real tree:
rawWriteViolations 0; Axis 3 output unchanged at all 47 sites.
…ained and shadowed declarations The existing property drew only `const` plus path.join(<dir>, '<literal>') over shapes the resolver was built for, so it could not have caught the typed-declaration or sibling-block misses. A second property states its oracle from the spec: a write whose target can be STATE.md is reported once at its own line, and a write whose every possible target is another known file is not. It draws const/let/var, five type annotations, a chain of 1..MAX_TARGET_RESOLUTION_HOPS declarations, a closed sibling block that redeclares the target as ROADMAP.md, a wrapped call, filler and CRLF. Against the previous head it fails (shrunk counterexample: const, depth 1, dir, shadow); here it passes on seeds 42, 1, 2, 3, 7, 11, 99, 1234 and 2026. The RESERVED set shared by both properties now holds the JS/TS reserved and keyword-like words (function, class, return, typeof, await, ...) and every name the fixtures already use.
…ot in module state The per-name assignment regexes lived in a module-level Map that grew across every scanned file. They now live in the context that one findRawStateWrites call already threads through the resolver, and nearestTargetBinding looks its pair up once per call instead of once per line. No verdict changes. Measured on N unresolved writes in one function (best of 3): 500 writes 138 ms before, 128 ms after; 2000 writes 2210 ms before, 2085 ms after; 5000 writes 14134 ms before, 11068 ms after. The quadratic cost of per-call backward resolution is unchanged; the AST follow-up (open-gsd#5279) replaces it.
…sses as known gaps The no-worse harness re-ran two misses reported in earlier rounds and found neither pinned nor listed: a `;` inside a template nested in another template's interpolation ends a right-hand side early, and `p = fs.writeFileSync(p, c)` reads its target's binding as empty. Both read 0 on the previous head and on this one. They are now known-gap rows (expected plus currentBuggyOutput) and listed among the verified false negatives in the findRawStateWrites header. No behavior change.
…e header and as rows
The header's false-positive list now names the arrow parameter read as an
assignment and the sibling arrow body, both already pinned. It states that
the body-boundary test is textual, so an unrelated closed block, an object
literal, a callback or a `${}` interpolation also stops a const tail from
clearing a write; four by-design rows pin that. Four known-gap rows pin
missed writes: a declaration whose value starts on the next line (typed and
untyped), an annotation with nested braces, and a `;` inside a
string-literal type.
… 1 and reuse nearestPrecedingAssignment Review 5512635344 (Majors 1 and 2, Minor 1): - Axis 2 now resolves a target through nearestPrecedingAssignment itself, extended with opt-in options (typed, stopAtPatternBinding, withLine). Axis 3 passes no options, so its regex and verdicts are unchanged. nearestTargetBinding, lastBindingInText and the binding regex cache are gone. - Removed, moved to open-gsd#5279: the join-tail pre-check (joinLastSegment, fileNameLiteral, resolvedJoinFileTail), CRLF normalization, the template-interpolation parser, += handling, and same-line statement splitting (splitStatements, statementSpans, CUT_LINE_BODY_RE). With the join tail gone there is one resolver, one hop budget. - Kept, as requested in earlier rounds: multi-line capture, the bounded hop count, the for...of / destructuring stop, the write's own line read up to the call, the body-boundary rule, and typed declarations. - Each shape the cut gives up is a known-gap row with its correct verdict.
…zero is not proof Review 5512635344, Minor 3: the file header's Axis 2 paragraph now states that a zero from this line scan does not prove no raw STATE.md writer remains, and points at findRawStateWrites' list of verified misses (ADR-3408 Decision 5) and the syntax-tree follow-up, open-gsd#5279.
…he misses it closed - Known-gap rows (false positives) for three shapes whose verdict moved with the cut: a one-line top-level function's parameter resolved into an earlier function, a bare `x =>` arrow parameter shadowing an outer variable, and a `for` header's loop-scoped `let` earlier on the write's line. The findRawStateWrites header lists them. - One positive row for three shapes that were known misses before the cut and now report: an assignment and write in a one-line block, in a one-line arrow body, and a reassignment in a closed block on the write's line.
…ound is not exported
Without the export, fc.integer({ max: undefined }) draws unbounded chain
lengths and the run dies out of memory instead of failing. Asserting the
export first makes the fail-first run against next's guard report its
failures (24 of 82) with no shim.
… write stay bounded
Every hop of resolvesToStatePath branched over each identifier of a
right-hand side, with no record of what it had already resolved. A file
with 60 `let vN = v0 + ... + v59;` lines, each followed by `{ }`, and
`fs.writeFileSync(v0 + ... + v59, c)` took 112 s to scan locally (20.4 s
in review 5558390402).
Each name's lookup is now memoized per write on (name, line, hops left),
in a context findRawStateWrites makes fresh per write, never in module
state. The verdict depends only on those and the scanned lines, so no
verdict moves: the old and new script agree on all 650 tracked files
that call fs.writeFileSync (964 findings, 0 differences). The 60-name
file now scans in about 1.1 s locally.
findRawStateWrites takes an optional stats object and reports
assignmentLookups, the nearestPrecedingAssignment calls it made (the
count is overwritten per call, not accumulated). A new row bounds that
count on the crafted shape at 20 names without a clock: 33215 calls
without the memo (the row fails), 1694 with it, against a bound of
2 * N * (N + 1) * MAX_TARGET_RESOLUTION_HOPS = 3360 (at
MAX_TARGET_RESOLUTION_HOPS = 4).
The bound is per write only. Residuals, measured locally:
- Cost scales with the number of writes, because the memo is rebuilt per
write: the 60-name file with the write line repeated 40 times took
48.6 s (634960 lookups).
- The assignment regex prefix backtracks on a long whitespace run: one
40000-space line holding the name, plus one write, took 0.9 s (80000
spaces: 3.1 s); with 20 writes, 17.6 s. origin/next's Axis 2 took 2 to
8 ms on the same inputs.
a7257be to
472cfb2
Compare
|
Thanks for the re-review (5558390402). Head is now
|
trek-e
left a comment
There was a problem hiding this comment.
Review — ci(#5104): the raw STATE.md write guard sees wrapped calls and same-function variable targets
Head reviewed: 472cfb2575a713232006dc9e7969d53699d1aede (gh pr view this turn: mergeable=MERGEABLE, mergeStateStatus=BEHIND, reviewDecision=CHANGES_REQUESTED).
Summary
Re-review of trek-e's 2026-10-17 review (head a7257be6f, Minors 1-2, Nits 3-4). The delta since that review is one commit, 472cfb2575 (script +53/-12, test +25). Prior Minor 2 (CRLF known-gap row) is resolved. Prior Minor 1 (resolution cost on hostile source) is only partly resolved: the exponential branching is memoized, but an executed probe still stalls the guard on a hostile line. Zero tolerance applies, so changes requested.
Classification & gate compliance
- Enhancement track. #5104 carries
enhancement+approved-enhancement+area: workflow. Approval comment from trek-e scopes Layer 1 (Axis 2 only); the diff touches onlyscripts/lint-state-write-path-drift.cjsandtests/state-write-path-drift-guard.test.cjs. Closes #5104present; titleci(#5104):conventional; not draft; branchfeat/5104-axis2-variable-and-multiline-targetmatches<prefix>/NNN-slug; Enhancement template used.- Labels
no-changelog,area: workflow,review: changes requested. CI-only guard, no changeset, no docs surface, no inventory surface. Consistent. - ADR-4629 Decision 5 (no allowlist) honored; ADR-3408 Decision 5 caveat is in the file header. Memtrace unavailable, so ADRs were checked from the issue and PR text.
- Branch is BEHIND
origin/next(merge-base755bf26f0, next at274f8cae1).git diff 755bf26f0 origin/nextis empty for both PR files, so the rebase is clean.
Must-Have Acceptance Checklist (#5104 Layer 1)
- Wrapped
fs.writeFileSyncfirst argument captured over comment-stripped joined text. - Non-
statePath/non-literal target resolved through same-function assignments, bounded (MAX_TARGET_RESOLUTION_HOPS= 4, exported, limit-1/limit/limit+1 rows). -
for...of/ destructuring binding stops resolution as unresolved. - Reuses
nearestPrecedingAssignment(opt-in options, Axis 3 passes none). - Axis 2 only; no
src/change, no allowlist, no new dependency. - Failing-first fixtures, negatives, sibling-block fixture, real-tree control. Miss 3 correctly out of scope (#5120, #5279).
- Not an issue criterion but an issue-stated posture ("same string-match posture"): the guard runs on untrusted source in
lint:ci; see Minor 1.
Prior findings at this head (trek-e review of a7257be6f)
- Minor 1 (hostile-source resolution cost): PARTLY FIXED.
resolvesToStatePathnow memoizes per write on (name, index, hopsLeft); the 20-name branching shape is bounded by a counted seam. Residuals remain, see Finding 1. - Minor 2 (CRLF known-gap row): FIXED.
tests/state-write-path-drift-guard.test.cjs:928-933pinsexpected: [3]besidecurrentBuggyOutput: []. - Nit 3 (behind next): OPEN.
- Nit 4 (ADR-4629 line refs
:606,564stale): OPEN, author declined again; author suggests citing the symbol name instead.
Root-cause / functional verdict
Root cause (unchanged, verified): Axis 2 matched the literal statePath/STATE.md text of a per-line first argument, so a wrapped call produced an empty target and a variable target was never followed. The fix is in the scanner (joined text plus bounded resolver), not a special case.
Delta check of 472cfb2575: the memo key name\0index\0hopsLeft fully determines the verdict (inputs are scanLines, which is fixed per write, plus those three), so it changes no verdict. findRawStateWrites(rel, text, stats = {}): the only callers are collect (2 args) and the test file; no .map(findRawStateWrites) index-as-third-argument hazard. stats.assignmentLookups is reset per call. Executed at head (scratch worktree, node probe scripts only): shape from the bound row with N=20 and 40 writes: 67760 lookups, 317 ms, 0 findings; N=60, 1 write: 483 ms. The memo works for the shape it was built for.
Blast radius
CI-only lint script (lint:ci). findRawStateWrites callers: collect and the test. nearestPrecedingAssignment callers: Axis 2 and Axis 3 (Axis 3 passes no options; unchanged). New exports: MAX_TARGET_RESOLUTION_HOPS. Risk low. Real-tree result stays rawWriteViolations: 0 per CI.
Co-change completeness
Script and test both in the diff. No CONTEXT.md, docs, changeset or inventory surface applies for a CI-only guard with no-changelog.
Test quality
- Fail-first: author reports 25 of 83 tests fail with
next's script; the property asserts the hop-bound export first. The new bound row fails on any script lacking thestatsseam (typeof stats.assignmentLookups), and per author's measurement fails without the memo (33215 vs bound 3360). - Bound row is counted, not timed; no wall-clock assertion. No source-grep, no
todo. Onefc.assertproperty (line 1106). - Known-gap rows assert
expectedbesidecurrentBuggyOutputand that they differ. - The new bound row does not cover the whitespace-backtracking or many-writes shapes (Finding 1); the row's own comment says so.
Memtrace Evidence
- get_impact: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). Used instead:
git grepforfindRawStateWritescallers (script and test only) and the diff ofnearestPrecedingAssignmentcallers. - get_symbol_context: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). Read
resolvesToStatePath,nameResolvesToStatePath,findRawStateWritesat the head sha fromgit diff refs/prrev/5119~1 refs/prrev/5119. - recall_decision: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). ADR-4629 D5 / ADR-3408 D5 taken from the issue, the PR body and trek-e's prior reviews, not the decision store.
- find_code_review_issues: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). Manual diff review plus executed probe scripts (no
node --test) in a scratch worktree of the head.
Security
No new file read, argv, subprocess or traversal surface; findings still pass sanitizeForReport. Resource use on hostile source text is the one open item (Finding 1): source files are untrusted input to lint:ci (CONTRIBUTING hostile-input rule), and a fork PR can stall the lint job.
CI
Latest-per-name check runs for 472cfb2575: 23 success, 5 skipped, 0 failing, 0 pending, 0 other non-success. action_required runs for this head sha: none ([]). Note: 28 check runs listed for this head versus 35 success + 7 skipped on a7257be6f; the maintainer should confirm the full matrix executed (the PR is BEHIND, and the count is lower).
Findings
Minor
- (executed probe, deterministic; severity is judgment)
scripts/lint-state-write-path-drift.cjsresolvesToStatePath/nameResolvesToStatePath(about lines 722-755) and the assignment regex used throughnearestPrecedingAssignment: the memo is rebuilt for every write, and each lookup is a line scan whose regex backtracks on a long whitespace run. The doc comment states both residuals ("Neither is bounded here"). Executed at head: a single source lineconst p<40000 spaces>x;followed by N copies offs.writeFileSync(p, c);in one function: N=1 1179 ms, N=5 6369 ms, N=20 22581 ms, 0 findings. Same input on the whitespace placed after the=right-hand side: 3 ms, so the cost is in the binding regex prefix. The author's own numbers (40-write shape 48.6 s) agree. Source files are untrusted input tolint:ci; this is the same stall class as the earlier Minor 1. Fix: cache the binding lookup per file (keyname,from) instead of per write, and make the regex prefix linear (for example[ \t]*bounded or a[^\S\n]form shared with Axis 3, confirming identical Axis 3 verdicts), or cap the total lookups per file with a degraded verdict; add a counted-seam row for the many-writes shape. The author asked which of (b) per-file budget or (c) linear prefix to take; the maintainer must answer, and either is acceptable. - (judgment)
scripts/lint-state-write-path-drift.cjsfindRawStateWrites(rel, text, stats = {}): a test-only seam sits in the production signature and its doc comment records thatnullthrows and that the count is overwritten per call. Fix: acceptstatsand guard withif (stats) stats.assignmentLookups = 0(or default via a module-private counter), sonulldoes not throw.
Nit
3. Branch is BEHIND origin/next (274f8cae1); git fetch origin && git rebase origin/next (no overlap with the two files).
4. tests/state-write-path-drift-guard.test.cjs around line 768: the new row comment cites "#5119 review 5558390402" and "when this row was added" measurements. Review-round references in comments age badly (the author removed the same kind earlier); state the rule, not the review.
5. ADR-4629 guard-table line refs (:606,564) are still stale; author declined again and suggested citing the symbol name. Still open from trek-e's earlier Nit.
Open author questions (unanswered, need the maintainer)
- (a) Restore only the inline-literal-tail stop so
path.join(path.dirname(statePath), 'ROADMAP.md')through a variable stays unflagged, or keep it pinned as a documented known gap? - (b) vs (c) for the Finding 1 residuals.
Verdict
Changes requested (0 Blocker, 0 Major, 2 Minor, 3 Nit). Substance is close; the exponential case is fixed and the CRLF gap is pinned. The remaining Minor is a reproducible stall on a hostile single line.
Enhancement PR
Linked Issue
Closes #5104
Child of epic #4629. Approved as Layer 1 by @trek-e (issuecomment-5891253965).
What this enhancement improves
Axis 2 of
scripts/lint-state-write-path-drift.cjs(findRawStateWrites), the terminal guard that ADR-4629 Decision 5 names for "No raw STATE.md write outside the seam". A rawfs.writeFileSyncagainst STATE.md skips the lock, so it can lose a concurrent update (the #500/#905/#1230 family).Before / After
Before (
next@ a1c7bc7):findRawStateWritesreported 0 for each of these shapes:After: each shape is reported at the line of the
fs.writeFileSync(call, asRAW_STATE_WRITE. The real tree still reportsrawWriteViolations: 0: the prototype measured 0 new findings onsrc/, and this branch confirms it.How it was implemented
All changes are in
scripts/lint-state-write-path-drift.cjs, Axis 2. It stays a string match, with no AST.captureFirstArgcaptures a first argument that follows a line break.statePathnor aSTATE.mdliteral, one resolver,resolvesToStatePath, follows its identifiers back through same-function assignments, at mostMAX_TARGET_RESOLUTION_HOPS(4) deep. It stops at the enclosing namedfunction, the boundarynearestPrecedingAssignmentalready uses. String contents and property names after a.are not resolved. Each hop resumes above the assignment it followed, so the scan always ends.nearestPrecedingAssignment. Axis 2 calls it directly, with a 4thoptionsargument:typed(a TypeScript annotation between the name and=, read only afterconst/let/varand holding no=or;),stopAtPatternBinding(afor...ofheader or a destructuring declaration that binds the name ends the search as unresolved, which is not flagged) andwithLine(return the line with the text, so the next hop can resume above it). Axis 3 passes no options, so its regex and behavior are unchanged. Thefor...ofstop matters live: without it the rollback loop atsrc/roadmap-upgrade.cts:2395resolved to theconst filePathat:2329(lines onnext@ 62549bf).{,}or=>lies between an assignment and the write, a binding that misses STATE.md does not end the search: it moves on to the next earlier binding of that name. So a literal in a closed sibling block or an arrow body never clears a write. A binding that reaches STATE.md still reports across the boundary. This can only add reports. There is no brace-depth tracker; full scoping is 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.findRawStateWritesheader say this axis's zero does not prove no raw writer remains (ADR-3408 Decision 5), and list every verified miss and false positive.+=handling and same-line statement splitting are not in this PR. Each shape the cut gives up is pinned as a known-gap row (expectedbesidecurrentBuggyOutput) and tracked in 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.Not closed here (by design): the raw writer at
src/roadmap-upgrade.cts:2345. Its file name is aMapkey filled in another function (miss 3), and no resolver scoped to one function reaches it. Per the ruling on #5104, that half is #5120 (approved; sequenced after #5056 Phase 13): a test-time check on the written path's basename. Until then, this axis's zero does not mean no raw writer exists (ADR-3408 Decision 5).Testing
How I verified the enhancement works
tests/state-write-path-drift-guard.test.cjssection F4 holds:MAX_TARGET_RESOLUTION_HOPS;KNOWN_TAIL_GAPS,KNOWN_SCOPE_GAPS,KNOWN_MATCH_GAPS), each row withexpectedbesidecurrentBuggyOutput;--rootCLI row.next'sscripts/lint-state-write-path-drift.cjsswapped in (no shim needed; the property asserts the hop-bound export first), 25 of the file's 83 tests fail. Against this head all pass.472cfb257onnext@ 755bf26, with emptyHOME/GSD_HOME: the two test files that load this script ran 84 tests, all passing.npm run lint:ciexit 0.rawWriteViolations: 0, and the behavioral identity test,tests/state-transition.test.cjs"enhance(#3408): applyStatePreservation dispatches on the declared policy, not the field — Phase 1 #3468 matrix C1/C2", passes 6/6. Neither proves no raw STATE.md writer exists. This axis is a line scan with the misses listed infindRawStateWrites's header; the syntax-tree pass is 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 and the run-time check is enhance(#4629): the test suite fails when product code raw-writes a file named STATE.md outside the write seam #5120.next's guard vs this head, both axes, every source file undersrc/,scripts/,gsd-core/bin/,hooks/,bin/, 792 files): 0 verdict changes. Axis 3 verdicts are part of that comparison (0 changes).Platforms tested
Runtimes tested
Scope confirmation
Scope held: Axis 2, opt-in options on
nearestPrecedingAssignment(Axis 3 passes none, output unchanged), and the test file. Nosrc/change, no allowlist, no new dependency.for...inis not treated as a binding, because the approval namedfor...ofand destructuring.Documentation
docs/to reflect this changedocs/content added in this PR is written in Englishapply the
no-docslabel or add<!-- docs-exempt: <reason> -->inside eachtriggering changeset fragment and leave a comment explaining why.
Internal lint guard only. No changeset fragment exists, so
lint:docshas nothing to trigger on. ADR-4629 cites the guard at:606,564. Those line numbers were already stale onnext, and I left the ADR untouched.Checklist
Closes #NNN— PR will be auto-closed if missingapproved-enhancementlabel — PR will be closed if missingnpm test), on the head commit per CI.changeset/fragment added (npm run changeset -- --type Changed --pr <NNN> --body "...") — orno-changeloglabel applied if not user-facingBreaking changes
None. The guard runs only in
lint:ci, it stays green onnext, and its--jsonshape is unchanged.