Skip to content

ci(#5104): the raw STATE.md write guard sees wrapped calls and same-function variable targets - #5119

Open
davesienkowski wants to merge 26 commits into
open-gsd:nextfrom
davesienkowski:feat/5104-axis2-variable-and-multiline-target
Open

davesienkowski wants to merge 26 commits into
open-gsd:nextfrom
davesienkowski:feat/5104-axis2-variable-and-multiline-target

Conversation

@davesienkowski

@davesienkowski davesienkowski commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

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 raw fs.writeFileSync against STATE.md skips the lock, so it can lose a concurrent update (the #500/#905/#1230 family).

Before / After

Before (next @ a1c7bc7): findRawStateWrites reported 0 for each of these shapes:

fs.writeFileSync(\n  statePath,\n  content)                              -> 0
const p = path.join(dir, 'STATE.md'); fs.writeFileSync(p, ...)         -> 0
const name = 'STATE.md';\nconst p = path.join(dir, name);\nfs.writeFileSync(p, ...)   -> 0

After: each shape is reported at the line of the fs.writeFileSync( call, as RAW_STATE_WRITE. The real tree still reports rawWriteViolations: 0: the prototype measured 0 new findings on src/, 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.

  • Wrapped calls. The scan runs over the comment-stripped text joined across lines, so the existing depth- and string-aware captureFirstArg captures a first argument that follows a line break.
  • Variable targets. When the target is neither statePath nor a STATE.md literal, one resolver, resolvesToStatePath, follows its identifiers back through same-function assignments, at most MAX_TARGET_RESOLUTION_HOPS (4) deep. It stops at the enclosing named function, the boundary nearestPrecedingAssignment already uses. String contents and property names after a . are not resolved. Each hop resumes above the assignment it followed, so the scan always ends.
  • Reuse of nearestPrecedingAssignment. Axis 2 calls it directly, with a 4th options argument: typed (a TypeScript annotation between the name and =, read only after const/let/var and holding no = or ;), stopAtPatternBinding (a for...of header or a destructuring declaration that binds the name ends the search as unresolved, which is not flagged) and withLine (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. The for...of stop matters live: without it the rollback loop at src/roadmap-upgrade.cts:2395 resolved to the const filePath at :2329 (lines on next @ 62549bf).
  • The write's own line. It is read up to the call, so an assignment earlier on that line is seen and one after the call is not. A named-function header in that text is dropped, so a one-line nested function can still read its enclosing function's variable.
  • Boundary rule. When a {, } 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.
  • Header caveat. The file header's Axis 2 paragraph and the findRawStateWrites header say this axis's zero does not prove no raw writer remains (ADR-3408 Decision 5), and list every verified miss and false positive.
  • Cut for scope (review 5512635344). The join-tail pre-check, CRLF normalization, the template-interpolation parser, += handling and same-line statement splitting are not in this PR. Each shape the cut gives up is pinned as a known-gap row (expected beside currentBuggyOutput) 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 a Map key 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.cjs section F4 holds:
    • the rule rows: wrapped call, variable target, the write's own line, the boundary rule, typed declarations, the for...of / destructuring stop, and the function boundary;
    • a limit-1/limit/limit+1 hop row built from MAX_TARGET_RESOLUTION_HOPS;
    • a row showing Axis 3's reading is unchanged when no options are passed;
    • three known-gap tables (KNOWN_TAIL_GAPS, KNOWN_SCOPE_GAPS, KNOWN_MATCH_GAPS), each row with expected beside currentBuggyOutput;
    • one fast-check property (typed declarations, const/let/var, hop chains, wrapped calls, sibling-block redeclarations);
    • the --root CLI row.
  • Fail-first: with next's scripts/lint-state-write-path-drift.cjs swapped 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.
  • Head 472cfb257 on next @ 755bf26, with empty HOME/GSD_HOME: the two test files that load this script ran 84 tests, all passing. npm run lint:ci exit 0.
  • The guard's zero, with its pairing (ADR-3408 Decision 5): the real tree reports 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 in findRawStateWrites'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.
  • No-worse harness (next's guard vs this head, both axes, every source file under src/, scripts/, gsd-core/bin/, hooks/, bin/, 792 files): 0 verdict changes. Axis 3 verdicts are part of that comparison (0 changes).
  • The full suite was not run locally; CI on the head commit is the authority.

Platforms tested

  • macOS
  • Windows (including backslash path handling)
  • Linux
  • N/A (not platform-specific)

Runtimes tested

  • Claude Code
  • Antigravity
  • OpenCode
  • Other: ___
  • N/A (not runtime-specific)

Scope confirmation

  • The implementation matches the scope approved in the linked issue — no additions or removals
  • If scope changed during implementation, I updated the issue and got re-approval before continuing

Scope held: Axis 2, opt-in options on nearestPrecedingAssignment (Axis 3 passes none, output unchanged), and the test file. No src/ change, no allowlist, no new dependency. for...in is not treated as a binding, because the approval named for...of and destructuring.


Documentation

  • Updated the relevant file(s) under docs/ to reflect this change
  • All docs/ content added in this PR is written in English
  • If genuinely no user-facing docs impact (infrastructure / internal refactor / test-only),
    apply the no-docs label or add <!-- docs-exempt: <reason> --> inside each
    triggering changeset fragment and leave a comment explaining why.

Internal lint guard only. No changeset fragment exists, so lint:docs has nothing to trigger on. ADR-4629 cites the guard at :606,564. Those line numbers were already stale on next, and I left the ADR untouched.

Checklist

  • Issue linked above with Closes #NNN — PR will be auto-closed if missing
  • Linked issue has the approved-enhancement label — PR will be closed if missing
  • Changes are scoped to the approved enhancement — nothing extra included
  • All existing tests pass (npm test), on the head commit per CI
  • New or updated tests cover the enhanced behavior
  • .changeset/ fragment added (npm run changeset -- --type Changed --pr <NNN> --body "...") — or no-changelog label applied if not user-facing
  • No unnecessary dependencies added

Breaking changes

None. The guard runs only in lint:ci, it stays green on next, and its --json shape is unchanged.

Round 3 (2026-10-21)

Addresses review 5563197184 at 8cea5f843, rebased on next 812ddb5fe. Detail, probes and commands are in the round-3 PR comment.

  • Minor 1, fixed. Raw-write target resolution in scripts/lint-state-write-path-drift.cjs is now linear in each line and shared across a file's writes: the assignment read is split into assignmentHeadRegex and assignedValue, LOOP_BINDING_RE no longer overlaps, and nearestPrecedingAssignment gains opt-in cache and firstLine options used by Axis 2 only. The review's probe went from 490 / 9894 ms to 5 / 3 ms (N = 1, 20). The old and new scripts differ on 0 inputs over the tracked tree and the fuzz corpora. Lines that repeat a write, an open call or a declaration many times stayed quadratic in one line's length; round 4 bounds them (below).
  • Minor 2, fixed. A null or undefined stats is ignored; row added.
  • Nits 3 and 4 done. Nit 5 declined again (ADR text is maintainer-owned).
  • Question (a). Recommend keeping the through-a-variable form flagged; reasons in the comment.

Round 4 (2026-10-22)

Addresses review 5608091717 at 1830f5210, rebased on next 26387bc83. Detail, probes and commands are in the round-4 PR comment.

  • Minor 1, fixed (option b, as ruled). Three fail-closed bounds, all exported:
    • MAX_RAW_WRITE_TEXT_LENGTH = 4096 caps each write's first argument and its line before the call.
    • MAX_RAW_WRITE_RESOLVER_WORK = 250000000 caps the resolver's counted work per file: argument and line lengths, each lookup, each name's compiled reader, each line read (charged per occurrence of the name, and per :, (, [ or { when it occurs), and each right-hand side read.
    • MAX_RESOLVED_NAME_LENGTH = 256 caps the length of a name the resolver follows.
    • Past any of them, the write is reported as raw_state_write_unresolved on the raw-write axis, at most once per line, so the bounds add reports and never drop one.
    • A call left open on every line of a 216 KB file went from 11164 ms to under 300 ms locally. Other resolver shapes found while probing, including two that ran out of heap, now finish with that finding. Timings are for the shapes probed.
    • Quoted strings are dropped from a resolved expression in one pass, with the same output as the regex it replaces, which was quadratic on a run of escaped quotes.
    • The stats seam gains argumentChars, ownLineChars, resolverWork, readersBuilt and stringScanChars.
  • Nit 2, fixed. The raw U+2028 in a test row is now an escape.
  • Nit 3, done. Rebased; the 19 reviewed commits are unchanged.
  • Question (a) stays pinned, per the review. Nit 5 stays declined, pending the maintainer's call.

Generated by AI during contribution; reviewed and posted by a human maintainer (@davesienkowski).

@trek-e trek-e left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 #5104 present, conventional title ci(#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 BEHIND next (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 nearestPrecedingAssignment logic (assignmentLineRe shared).
  • 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

  1. (judgment + static trace) scripts/lint-state-write-path-drift.cjs findRawStateWrites calls resolvesToStatePath(stripped, lineIdx - 1, targetArg); the backward scan starts ABOVE the call line, so const 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 from lineIdx inclusive, limited to text before the call's column, and add a one-line positive row.
  2. (judgment) identifiersIn + resolvesToStatePath treat 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 flagged RAW_STATE_WRITE although 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 a statePath-derived directory with a non-STATE.md literal tail. Fix: stop descent when a path.join/resolve tail is a literal that does not name STATE.md, or add the negative control and document the posture.

Minor

  1. FUNCTION_DECL_LINE_RE (scripts/lint-state-write-path-drift.cjs:280) matches only function 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.
  2. Boundary coverage: the hop-bound test asserts MAX and MAX+1 only; add MAX-1 (limit-1, limit, limit+1).
  3. No fast-check property for what is now a text parser/resolver (captureFirstArg, identifiersIn, binding regexes), and no CRLF fixture.
  4. identifiersIn drops template interpolation with \$\{[^}]*\}, so nested braces (${f({a:1})}) truncate and identifiers after the inner } are lost (false negative). Untested.

Nit

  1. 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.
  2. The no-changelog label 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.
  3. 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, branch next): ambiguous — two declarations (the script function at scripts/lint-state-write-path-drift.cjs:627-652 and 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.

@trek-e trek-e added the review: changes requested PR reviewed — changes required before merge label Sep 30, 2026
@davesienkowski
davesienkowski force-pushed the feat/5104-axis2-variable-and-multiline-target branch from 82b5777 to 9d7950e Compare October 3, 2026 01:07
@davesienkowski

Copy link
Copy Markdown
Collaborator Author

@trek-e thanks. I pushed 9d7950ec0, rebased onto next @ 62549bf24.

  1. Fixed. The write's line is now read up to the call. A right-hand side ends at its own ;, a one-line chain resolves, and p += x is followed. Rule: that line is read only when it holds plain statements. On a line with {, }, =>, function or for, Axis 2 behaves exactly as before. Four fresh-context reviews found false positives in every scope approximation I tried.
  2. Fixed. A path.join/path.resolve ending in a file-name literal is decided by that literal. Your dirname(statePath) + 'ROADMAP.md' shape is a negative control, including with a trailing comma and path.posix.
  3. Not changed. Documented and pinned instead. Making arrows and methods boundaries hid closure writes the original head caught. The sibling-body case is a limit: row.
    4-6. Fixed. Added a MAX-1 row, a CRLF row (it found a real miss: . never matched \r), and a fast-check property. Interpolations are now matched by brace depth.
  4. Not changed. I keep ADR edits out of contribution PRs.
  5. Linked: enhance(#4629): the test suite fails when product code raw-writes a file named STATE.md outside the write seam #5120. 9. Done.

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
  • Probes from four fresh-context reviews (190, against 21c86a1d4). Where this head flags more: 10 real STATE.md writes the original missed; 6 of one class, string/regex/template text that reads like name = ...STATE.md on a line now read (prior lines already had this); 4 where existing over-approximations are newly reached (+= like y = y + ..., CRLF now equal to LF, nested-brace templates, one lost accidental stop).
  • New misses against the original head, all Nit-level: a regex or nested template literal containing ; in a right-hand side, and p = fs.writeFileSync(p, c).
  • Tests: state-write-path-drift-guard (F4: 37 rows, with rule: and limit: rows) + lint-state-field-drift-retained, 74/74. Disabling the line rule fails 4 rows. Property: 6 seeds x 200 runs.
  • npm run build:lib 0; npm run lint:ci 0. memtrace find_code_review_issues on the new functions: 0.
  • 613 tracked JS/TS files (7928 writes): same findings as the original head outside this test file. Real tree: 0 in 0.21s. That zero is not proof of no raw writer; the data-flow case is enhance(#4629): the test suite fails when product code raw-writes a file named STATE.md outside the write seam #5120.
  • Synthetic worst case (1000 writes in one file): 1.6x to 2.6x the original head.
  • CI on 9d7950ec0: running when this was posted.

Generated by AI during review response; reviewed and posted by a human maintainer (@davesienkowski).

@davesienkowski
davesienkowski requested a review from trek-e October 3, 2026 01:08
@davesienkowski
davesienkowski force-pushed the feat/5104-axis2-variable-and-multiline-target branch from 9d7950e to 250b5a4 Compare October 5, 2026 21:59
@davesienkowski

Copy link
Copy Markdown
Collaborator Author

Rebased onto next @ 13d37238b (it was 65 commits behind). No conflicts, and none of the commits since touch the guard or its test. No code change: git range-diff shows both commits identical. Head is now 250b5a4f5.

Local on the new head: build:lib 0, lint:ci 0, state-write-path-drift-guard + lint-state-field-drift-retained 74/74. CI is running.

My 10-03 reply above still covers your findings.

Generated by AI during review response; reviewed and posted by a human maintainer (@davesienkowski).

@trek-e trek-e left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 #5104 present; 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-changelog label); no docs (docs-lint green).
  • The issue said to reuse nearestPrecedingAssignment; only its regex is shared (assignmentLineRe), the resolver is a new nearestTargetBinding. 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.md text 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_impact target findRawStateWrites (base index, run by orchestrator): 5 affected symbols — findRawStateWrites and its callers collect/main in scripts/lint-state-write-path-drift.cjs, the test file tests/state-write-path-drift-guard.test.cjs, and scanMarkdownLetterAxisConsumers in scripts/lint-phase-id-drift.cjs (likely a same-name symbol; not a runtime dependency). No runtime (non-lint) dependents. Low risk.
  • codegraph_callers target nearestPrecedingAssignment (run by orchestrator): 1 caller, findUnstrippedContentWrites (scripts/lint-state-write-path-drift.cjs:531). Its behavior is unchanged by assignmentLineRe (same regex string, verified by reading the diff).
  • codegraph_callees / codegraph_node were not run for this review; the callee list of findRawStateWrites (stripComments, captureFirstArg, resolvesToStatePath, sanitizeForReport) and the literalJoinTail characterization 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.cjs nearestTargetBinding: scope is line-based; arrows/methods are not boundaries and a one-line block/function is not read. Probes: sibling-arrow p resolves to another body's STATE.md variable (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) literalJoinTail decides 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) and path.join(d, `${name}.json`) are reported RAW_STATE_WRITE. Same unclearable class as prior Major 2. Fix: resolve an identifier tail through nearestTargetBinding and decide by its literal value.
  • m3 (judgment) tests/state-write-path-drift-guard.test.cjs limit: rows pin the false-positive verdicts above as passing assertions. Fix: assert the correct verdict after m1, or mark them todo.

Nit

  • n1 PR body claims npm test passed 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-changelog label 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.

@davesienkowski
davesienkowski force-pushed the feat/5104-axis2-variable-and-multiline-target branch from 250b5a4 to 081d4df Compare October 7, 2026 03:09
@davesienkowski

Copy link
Copy Markdown
Collaborator Author

@trek-e thanks. I pushed 081d4df03, rebased onto next @ f2077e75a.

  • m2: fixed for a tail that resolves. A path.join/path.resolve now resolves its last argument through the same-function resolver. When that reaches a string literal that names a file, the literal decides. Your const n = 'ROADMAP.md' probe is no longer reported. Every other tail falls through to the previous rule unchanged: a parameter, member, call, spread, interpolated template, conditional, '' or ., or a module-level constant. Across 56 probe shapes the guard reports nothing it reported before, except five intended clears. An earlier draft that also cleared unresolved tails dropped real STATE.md writes (const sub = ''; path.join(statePath, sub), a module-level STATE_FILE), so I kept the no-worse rule. Your path.join(d, name) and `${name}.json` probes are still reported and pinned as known gaps: the written file is not visible inside the function.
  • m3: changed. CONTRIBUTING.md forbids todo tests, so the limit: rows became a known-gap table. Each row records the correct verdict and today's output, asserts today's output, and asserts the two differ, so a fix breaks the row loudly instead of passing it.
  • m1: not changed, and I'd propose a separate route. I probed a brace-depth tracker against the current scanner. Braces inside strings, templates and regex literals, brace-less arrow bodies, catch (e) shadowing and block-scoped var each need more parsing, so a tracker ends up rebuilding a parser piece by piece. typescript is already a devDependency, and four tests already load it. In a local measurement, parsing the 22 files that contain a write took about 240 ms. A syntax-only Axis 2 on it would replace about 330 lines of scanners. That changes the guard's "never parsed as an AST" design (script line 114), so I'd file it as its own Epic: STATE.md writes declare intent and are verified — retire the opaque transformFn contract #4629 child if you agree. Until then, the scope rows document the limit.
  • n1: I corrected the PR body. The gsd-test run is labelled with its pre-review head, and the Node 22 claim is removed.
  • n2, n3: as before. ADR edits stay out of contribution PRs, and the no-changelog label description is not mine to change.

Re-requested your review.

Verification (local, head 081d4df)
  • Red first: the new test file against the previous guard (250b5a4f5) gives 82 tests, 81 pass, 1 fail ("a join tail bound in the function to another file is NOT reported").
  • state-write-path-drift-guard + lint-state-field-drift-retained 83/83; real tree rawWriteViolations: 0; npm run lint:ci 0.
  • No-worse harness (the previous guard vs this head, 56 shapes): 48 reported before, 43 still reported, 5 intended clears, 0 newly reported, 0 regressions.

Generated by AI during review response; reviewed and posted by a human maintainer (@davesienkowski).

@davesienkowski
davesienkowski requested a review from trek-e October 7, 2026 03:10

@trek-e trek-e left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}.json remain 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.

@davesienkowski

Copy link
Copy Markdown
Collaborator Author

@trek-e thanks. I pushed fad8de399, rebased onto next @ ed0f3fd55. The earlier commits carry over unchanged.

Re-requested your review.

Verification (local, head fad8de3)
  • RED: the new rows against the previous guard give 89 tests, 87 pass, 2 fail (the let/var tail row and the let known-gap row). GREEN: 89/89. Both runs used an empty HOME/GSD_HOME.
  • Probes: your two conditional-reassignment shapes give 0 on 081d4df03, then line 5 and line 7 on this head (same as 250b5a4f5).
  • npm run build:lib 0, npm run lint:ci 0, real tree rawWriteViolations: 0.
  • The 56-shape no-worse harness from the last round was not rerun, because it is not on disk. The change only removes clears, so it can only add reports.
  • m5, test quality: assertions read findRawStateWrites output, with no source grep and no clock. The property row asserts that STATE.md is reported and another file name is not, in each case. Every known-gap row also asserts notDeepStrictEqual(current, expected).

Generated by AI during review response; reviewed and posted by a human maintainer (@davesienkowski).

@davesienkowski
davesienkowski requested review from trek-e and removed request for trek-e October 9, 2026 19:37

@trek-e trek-e left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Both M1 probes are reported [executed with plain node against findRawStateWrites]. let n='STATE.md'; if (x) n='ROADMAP.md'; write(path.join(d,n)) reports line 5; the braced variant, line 7; the var variant, line 5; and a const m = n alias of a reassigned let, line 6. The code path is resolvedJoinFileTail returning null when !binding.isConst (~L771), so resolvesToStatePath falls through to targetsStatePath(expr). The one-line whole-function form still reads 0 (the cut-line rule), as you disclosed. I could not check the "also 0 on 250b5a4f5" claim; it is consistent with the cut-line rule and with the pinned one-line-block rows.
  2. "Only removes clears, so it can only add reports" is true for this commit, relative to 081d4df03 and not to next: the single behavioral edit is !binding.isConst on the sole clear path, and returning null more 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).
  3. m1-m4 and #5279. m1 is filed as #5279. For m2 and m3 your instruction in the earlier review was "fix inside stripComments or document" and "mention so the guard header does not overclaim", and both are documented in the findRawStateWrites header and pinned in the KNOWN_*_GAPS tables, consistent with CONTRIBUTING.md:766. They are not findings against this head. Separately, #5279 is still needs-triage; that is yours to decide.
  4. 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 (findRawStateWrites at scripts/lint-state-write-path-drift.cjs:627, collect :881, main :961, scanMarkdownLetterAxisConsumers in scripts/lint-phase-id-drift.cjs:646, and the guard's test file).
  • codegraph_callers findRawStateWrites: collect (scripts/lint-state-write-path-drift.cjs:881) and tests/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 trek-e left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

  1. Capture fs.writeFileSync's first argument across line breaks over comment-stripped text — Met.
  2. 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.
  3. Treat a for...of or destructuring binding as unresolved — Met.
  4. 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".
  5. Axis 2 only, no src/ change, no allowlist, no dependency — Met.
  6. 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).
  7. 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 (findRawStateWrites at scripts/lint-state-write-path-drift.cjs:627, collect :881, main :961, scanMarkdownLetterAxisConsumers in scripts/lint-phase-id-drift.cjs:646, and the guard's test file); codegraph_callers: collect and tests/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.

@davesienkowski
davesienkowski force-pushed the feat/5104-axis2-variable-and-multiline-target branch from fad8de3 to 29f3b6e Compare October 10, 2026 21:25
@davesienkowski

Copy link
Copy Markdown
Collaborator Author

@trek-e thanks. I pushed 29f3b6e17, rebased onto next @ 651bd2f5e. The four earlier commits are unchanged; seven new commits sit on top.

  • Major 1: fixed. A binding can clear a write only when no {, } or => lies between it and the write. Past such a boundary, the join tail falls back to the whole-expression rule. In the target chain, a binding past a boundary that misses STATE.md does not end the search, which moves on to the next earlier binding of the name. Taken literally ("unresolved") in the target chain, the rule would have dropped writes inside callbacks that the guard reports today. The change only removes clears. Your sibling-block probe and the outer-STATE/inner-ROADMAP variant now read 1. The whole-function-on-one-line form still reads 0 (the cut-line rule) and is pinned as a known gap.
  • Major 2: fixed. assignmentLineRe accepts a type annotation between the name and =: generics, unions, Record<..>, and object types that contain ;. A parity row pins Axes 2 and 3 to the same verdict on 16 declaration forms (Nit 5). Axis 3 matches the previous head at all 47 stateReplaceField( sites.
  • Minor 1: fixed. A limit-1/limit/limit+1 row for each chain, built from MAX_TARGET_RESOLUTION_HOPS.
  • Minors 2 and 3: pinned, not fixed. One-line function, one-line arrow and module-level constant are known-gap rows (expected beside currentBuggyOutput) and are listed in the header. Reading a cut line that holds a body breaks the existing one-line nested-header controls.
  • Minor 4: fixed. The header lists every verified false negative and false positive. The parameter and interpolated-template tails moved to a new BY_DESIGN_FALLBACKS table, which asserts current output only.
  • Minor 5: fixed. RESERVED holds the full keyword list. A second property takes its oracle from the spec, not from the resolver. It draws const/let/var, annotations, hop chains up to the bound and sibling-block redeclarations; against the previous head it fails with a shrunk shadow case. The three helpers are not exported, so table rows pin them through findRawStateWrites.
  • Minor 6: intended. The narrowed join-tail rule is now stated in the PR body and in resolvesToStatePath's docblock.
  • Minor 7: partly. bindingRegexCache is now per call, not module state, with no perf loss. The two chain walkers stay; 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 collapses them.
  • Minor 8, Nit 1: pinned. False-positive rows: same-name arrow parameter, a target derived from STATE.md, a STATE.md.bak literal. Known-gap rows: appendFileSync, renameSync(tmp, statePath), fs.writeFile, an alias, require('fs')., fs . writeFileSync (, fs?., fs['writeFileSync'].
  • Nit 2: measured only; still quadratic, and 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 replaces the scan. Nit 3: PR body updated. Nit 4: the comment now says the following deepStrictEqual is what fails. Nit 6: the "review round" comments are removed; the ADR and the label are unchanged, as before. Nit 7: rebased.

I'm adding the shapes still pinned as gaps to #5279's acceptance list.

Verification (local, head 29f3b6e, empty HOME/GSD_HOME)
  • RED first: each behavior row fails against 262f40a8d and passes on the head.
  • lint-state-field-drift-retained + state-field-drift + state-write-path-drift-guard: 167/167. npm run lint:ci 0. Real tree rawWriteViolations: 0.
  • No-worse harness, previous head vs this head, 249 shapes (every probe from your reviews and my replies, plus every test fixture): 108 reported before, 146 after, 0 line changes. Two shapes are newly cleared, both a typed const n: string = 'ROADMAP.md' join tail; the untyped form was already cleared.
  • Your reproduction table: typed const 0 -> 1, typed let 0 -> 1, sibling block (multi-line) 0 -> 1; module constant and the one-line sibling form stay 0, as pinned gaps.
  • The full suite was not run locally; CI is the authority.

Generated by AI during review response; reviewed and posted by a human maintainer (@davesienkowski).

….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.
…ne and shared across a file's writes

A line holding a variable's name and a long whitespace run stalled the
raw-write scan, and the cost of a file grew with its number of writes.
One `const p<40000 spaces>x;` line followed by 20 writes took 10.8 s
locally, 22.6 s in review.

- The assignment regex matched `(?:^|[^.\w$])(?:const|let|var)?\s*NAME`,
  so every position of a whitespace run could start a match and
  backtrack over the rest of the run. Each match now starts at the name
  and a lookbehind reads the text before it; the language and the
  captured right-hand side are unchanged, and Axis 3 reads through the
  same regex. The typed form `[^=;]+?\s*=` is written `[^=;]+=` (same
  language). The for...of binding regex had the same overlap
  (`\s+(.+?)\s+of\b`); it now reads `(.*?\S)\s+of\b` after taking the
  keyword's whitespace whole, which reads the same pattern.
- nearestPrecedingAssignment takes an opt-in `cache`, one per file for
  Axis 2: each line is read at most once per name across every write,
  and the write's own line, cut at the call, is read per write
  (`firstLine`, never cached).
- A name's verdict below a write's line depends on the write only
  through whether a body boundary lies after that line, so those
  verdicts are shared by every write in the file, keyed on that flag.
  The boundary rule reads a per-file count of boundary lines instead of
  joining the lines between per write.
- findRawStateWrites no longer throws on a null `stats`, and reports
  `linesRead` beside `assignmentLookups`.

Probe, before -> after (local, ms): the whitespace line with N = 1, 5,
20 writes: 508, 2590, 10791 -> 5, 3, 6. Same with a type annotation:
1023 -> 4 (N = 1); with a for...of header: 985 -> 4 (N = 1); the
for...of header with the run also after its keyword did not finish in
300 s before, 5 ms now. The 60-name shape with 40 writes: 24334 -> 106.
One assignment, `{ }` and 8000 writes: 6633 -> 32.

Old and new script compared: every tracked .cts/.cjs/.ts/.js/.mjs file,
each line read for each identifier on it with no options and with the
Axis 2 options, plus findRawStateWrites and findUnstrippedContentWrites
per file (1715 files, 12.2M comparisons); 4.5M comparisons over 150000
fuzzed lines; 2.4M fuzzed binding lines; 150000 fuzzed files (44512
with findings). 0 differences. Mutants of the assignment regex, the loop regex
and the shared verdict key each show differences in the same harness.

New rows: lines read are bounded on a 200-write file (the row fails
without the cache); lookups are bounded on a 50-write branching file
(19950 lookups before, 1379 now, bound 1880); five whitespace shapes
keep their verdicts (time is not asserted); a null or undefined `stats`
changes no finding. The work-bound comment states the rule, not a
review.

Not bounded: a line holding many writes is read once per write, and a
call left open takes the rest of the file as its target. 8000 writes on
one 200 KB line: 2209 -> 627 ms; 8000 open calls on one line: 6341 ->
5292 ms.
…ne terminator after a whitespace run stays linear

The assignment tail `\s*=\s*([^=].*)$` kept the quadratic shape the
prefix had: `.` does not match `\r`, U+2028 or U+2029, so when one
follows a long whitespace run after `=`, each shorter run is tried and
rescanned up to it. `const p =<40000 spaces>x;\r` cost 1613 ms per read
on the previous head (32255 ms with 20 writes), and 531 ms per file
after the per-file cache. origin/next's Axis 3 reads through the same
tail (2268 ms for one such line there).

- assignmentHeadRegex matches up to the `=` and its whole whitespace
  run (a lookahead and backreference, as no possessive quantifier
  exists). assignedValue then decides the old tail from that one
  position: the capture could only start at the end of the run, or one
  character before it when that end is `=` or the end of the line, and
  `.*$` holds only when no line terminator follows. A failed head
  resumes one character on. The result is unchanged.
- findRawStateWrites reports `assignmentMatches`, the heads decided.

Probe, before (472cfb2) -> after (ms): `\r` ending, N = 1 and 20
writes: 1613, 32255 -> 1, 1; U+2028 ending: 1822, 36356 -> 1, 1.

Old and new script compared again, with `\r`, U+2028, U+2029 and
`=` runs added to the fuzz alphabet: 1715 files (12.2M comparisons),
4.5M comparisons over 150000 fuzzed lines, 150000 fuzzed files, 2.4M
fuzzed binding lines: 0 differences. Four mutants (a stricter keyword
form, a greedy loop pattern, the shared verdict key without its
boundary flag, and an off-by-one in the tail decision) show 7156,
38836, 23 and 186 differences.

Rows: a `\r` or U+2028 run decides at most one head per occurrence of
the name, with verdicts pinned; two writes across a `}` keep different
verdicts (pins the boundary flag in the shared key). Two test comments
that cited a review now state the rule.

The header now also discloses a line that repeats a declaration or
`for` header of the name many times as quadratic in that line (180 KB of
`const p: `: 4485 -> 1659 ms; 260 KB of `for (const p `: 3331 -> 3573
ms).
…write past the cap as unresolved

A call left open took the rest of the file as its first argument, once per
call, and each of many writes on one line re-read the line before it, so
both shapes cost time quadratic in the file or the line.

- MAX_RAW_WRITE_TEXT_LENGTH (4096, exported) bounds both reads. The longest
  first argument of a write over the 1715 tracked script and source files
  is 115 characters and the longest line before a call 129, so the cap is
  over 30 times either.
- A write past the cap is not resolved. It is reported as
  raw_state_write_unresolved on the raw-write axis, at most once per line,
  so the cap adds reports and never drops one.
- captureFirstArg takes an optional limit and reads at most limit + 1
  characters. The line before the call is measured before it is sliced.
- A finding's source is built once per line.
- The stats seam gains argumentChars and ownLineChars. New rows pin the cap
  at limit - 1, limit and limit + 1 for both reads, count the work for
  calls left open and for many writes on one line, and run the CLI on a
  call left open.
- The header now describes the cap and lists what it does not bound.
… raw character

The row that ends a whitespace run with a line terminator held a raw
U+2028 in its string literal. It is valid JavaScript but invisible in
review and trips invisible-character scanners. The escape is the same
string. A code-point scan of the guard and its test file finds no other
invisible character.
…report the writes past the budget as unresolved

Shapes of the resolver this change adds still cost time superlinear in a
file: many writes that each name a different variable, where each name's
first lookup read back to the enclosing named function (distinct names x
lines); a line that repeats a typed head, a for header or a destructuring
head, where each repeat scanned to the end of the line; many writes that
re-read one wide right-hand side; and writes that walk every earlier
assignment of their name past body boundaries.

- MAX_RAW_WRITE_RESOLVER_WORK (250000000, exported) bounds the work of
  resolving one file's writes. Over the 1728 tracked script and source
  files the most any file takes is 8186334 (tests/state.test.cjs, 1 MB),
  and in src/ 160872 (src/commands.cts), so the budget is over 30 times
  the first.
- Charged: each write's argument and line before the call, by length; each
  nearestPrecedingAssignment call, LOOKUP_WORK (64) plus the name; each line
  it reads, by lineReadWork: its length plus LINE_READ_WORK (32), times one
  more than its count of ':', '(', '[' and '{' when it holds the name,
  since each of those can start a scan to the end of the line; and each
  assignment right-hand side it resolves, by length.
- assignmentVerdict keeps an assignment's verdict below the write's line
  for later writes, under the same boundary flag as the other shared
  verdicts, so a wide right-hand side is read once. The assignment head
  regex is built once per name.
- A charge that would pass the budget is not made and its read is not
  done. The write that ran out and every later write in the file are
  reported as raw_state_write_unresolved, at most once per line.
- The budget is opt-in through the cache, so Axis 3's reads are unchanged.
- The stats seam gains resolverWork. New rows pin the budget at
  limit - 1, limit and limit + 1, count the work for distinct names, each
  repeated head, a shared right-hand side and boundary walks, and check
  that a write after the budget is spent is still reported.
- The header describes the budget in place of the shapes it left
  unbounded.
… and find the name in linear time

The resolver budget charged a line read by the line's length, but the
assignment head regex starts with the name, so where the name nearly
matches at every offset (a run of 'a' read for the name 'aaa...') one read
cost line length x name length. Two lines of 100000 'a's and 100 writes of
4000-character names took 22 s and spent a quarter of the budget.
String#includes has the same cost for a name with a repeated half.

- A line read under the budget finds the name's offsets with
  Knuth-Morris-Pratt (nameOccurrences), linear in the line, in place of
  includes. The head regex and the pattern-binding regex run sticky at
  those offsets only, which finds the same matches as the global scan.
- lineReadWork charges (line length + LINE_READ_WORK + occurrences x name
  length) x (1 + count of ':', '(', '[' and '{') when the name occurs, and
  line length + LINE_READ_WORK when it does not.
- Each name's regexes and failure table are built once (nameReader).
- Over the 1728 tracked script and source files the most any file now
  takes is 8267472 (tests/state.test.cjs) and in src/ 163185
  (src/commands.cts); MAX_RAW_WRITE_RESOLVER_WORK stays 250000000, 30.2
  and 1532 times those.
- A counted row pins the per-occurrence charge and the review's shape; the
  budget limit rows follow the new charge.
- Axis 3 does not pass a cache, so its reads are unchanged.
- The text cap's comment now cites the measurement after the rebase: over
  the 1728 tracked files the longest first argument and the longest line
  before a call are each 129 characters. The cap commit's message keeps
  the figures measured before the rebase, over 1715 files (115 and 129).
… a resolved name, and search for names with indexOf

Each new name compiles its own regexes (about 70 us and 7 KB for a short
name, 350 us and 30 KB for a 256-character one), and that was not charged:
one distinct short name per write spent the budget in 44 s and 3.8 GB, and
4000-character distinct names ran out of heap inside findRawStateWrites,
which is a crash, not a reported write.

- Building a name's reader is charged READER_WORK (10000) plus
  READER_WORK_PER_CHAR (128) per character. The charge also bounds the
  memory the readers hold for the rest of the file.
- MAX_RESOLVED_NAME_LENGTH (256): a longer name is charged as the whole
  budget, so its write and every later write in the file are reported
  unresolved. Over the 1728 tracked script and source files the longest
  name the resolver looks up is 32 characters (27 in src/), and the longest
  identifier in src/ is 52.
- With names that short, the native indexOf search is cheap on the
  adversarial patterns probed (self-overlapping, near-miss, periodic and
  Fibonacci names up to 256 characters on 1 MB lines), so the
  Knuth-Morris-Pratt search is removed and indexOf is used again.
  The search for a name on a line stops once its occurrences could no
  longer be charged, so the read that runs out of budget does not first
  list every occurrence.
- The stats seam gains readersBuilt. New rows: one reader per distinct name,
  charged by length, for short and long names; and the name cap at
  limit - 1, limit and limit + 1. The text-cap row pads its name with
  spaces, so it no longer depends on the name cap.
- Over the 1728 tracked files the most any file now takes is 9315920
  (tests/state.test.cjs) and in src/ 308033 (src/commands.cts), so the
  budget of 250000000 is 26.8 and 812 times those. The most readers any
  file builds is 110 (18 in src/).
… one pass

identifiersIn dropped quoted strings with
/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g, which, from every quote with no
closer, scans to the end of the text and fails, then tries the next quote.
A run of escaped quotes therefore cost time quadratic in its length, but was
charged by length. A template literal of escaped quotes reaches it as one
argument (the argument capture treats a backtick as a string; this regex
does not). Nested writes sharing one such literal took 26.6 s for a 999 KB
file, and one 400 KB right-hand side took 37.8 s.

- stripQuotedStrings gives the same result in one pass. A quote with no
  closer records where its failed scan stopped, and quotes of the same kind
  before that point are not tried: the failed scan passed each of them as
  the escaped half of a backslash pair, so a scan from one stops at the
  same point. A backslash before a line terminator also stops a scan, as
  `.` does not match one.
- Same output as the regex on 2500000 fuzzed strings (quotes of both
  kinds, backslash runs, \n, \r, U+2028, U+2029, backticks) and on every
  line and whole file of the 1728 tracked files.
- The backtick handling is unchanged in both places.
- The stats seam gains stringScanChars. A counted row puts a run of 1000
  escaped quotes in a target and in a right-hand side; each character is
  examined at most three times.
@davesienkowski
davesienkowski force-pushed the feat/5104-axis2-variable-and-multiline-target branch from 8cea5f8 to 1830f52 Compare October 22, 2026 14:43
@davesienkowski

davesienkowski commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

Review 5608091717 is addressed at 1830f5210 (rebased on next 26387bc83).

  • Finding 1, fixed (option b). Three fail-closed bounds, all exported; the third, MAX_RESOLVED_NAME_LENGTH, is described below. MAX_RAW_WRITE_TEXT_LENGTH = 4096 caps each write's first argument and its line before the call. MAX_RAW_WRITE_RESOLVER_WORK = 250000000 caps the resolver's counted work per file. Past either, the write is reported as raw_state_write_unresolved, at most once per line, so neither bound can drop a report. On your probe, the 12000-call file went from 11963 ms in your run (11164 locally) to under 300 ms. Each bound has limit-1/limit/limit+1 rows. Counted rows cover the open-call and many-writes-on-one-line shapes, plus the other shapes listed below. The real tree stays at 0. Old vs new differ on 0 inputs below the bounds.
  • Nit 2, fixed. The test row uses the escape '\u2028'. No other invisible character in either file.
  • Nit 3, done. next was 13 ahead by then. The 19 reviewed commits are unchanged.
  • Question (a). Kept pinned, per your ruling.
  • Nit 5. Stays declined, pending your call.
Evidence: bounds, probes, rows, mutants, commands

Scope. The timings below hold for the shapes probed, listed in the tables. They are not a proof for all inputs. Probing the resolver this round found slower shapes than the one in your review; they are fixed here.

The bounds

  • MAX_RAW_WRITE_TEXT_LENGTH = 4096. Over the 1728 files tracked after the rebase, the longest first argument and the longest line before a call are each 129 characters (35 and 103 in src/). Commit f02fbaa54 cites 115 and 129: those were measured over the 1715 files tracked before the rebase.
  • MAX_RAW_WRITE_RESOLVER_WORK = 250000000. The most any of the 1728 files takes is 9315920 (tests/state.test.cjs; tests/ is not scanned), and in src/ 308033 (src/commands.cts). So the budget is 26.8 and 812 times those.
  • What the budget charges:
    • each write's argument and line before the call, by length;
    • each lookup: 64 plus the name's length;
    • each name's reader (its compiled regexes, built once per file): 10000 plus 128 per character. The most readers any tracked file builds is 110 (18 in src/);
    • each line read: line length + 32 if the name does not occur on it, otherwise (line length + 32 + occurrences x name length) x (1 + count of :, (, [, {);
    • each assignment right-hand side read, by length.
  • MAX_RESOLVED_NAME_LENGTH = 256. A longer name is charged as the whole budget, so its write and every later write in the file are reported unresolved. The longest name the resolver looks up in the tracked files is 32 characters (27 in src/). The longest identifier in src/ is 52.
  • With names capped at 256, the native indexOf is cheap on the adversarial patterns probed, so the Knuth-Morris-Pratt search from 47d6b60d8 is removed again. Regexes run only at the name's offsets. A name search stops once its occurrences could no longer be charged.
  • Quoted strings are dropped from a resolved expression in one pass (stripQuotedStrings, 1830f5210). The regex it replaces gives the same output but is quadratic on a run of escaped quotes. The new pass matches the regex on 2500000 fuzzed strings and on every line and whole file of the 1728 tracked files.
  • Observed and left unchanged: the argument capture treats a backtick as a string, while the quote stripping does not. Changing either would change verdicts.
  • Real-tree collect takes 181 ms here (225 ms on next in an earlier run).

Your probe and related shapes (ms, one run each, measured at e990312bd; Before = 8cea5f843)

Shape next Before After
fs.writeFileSync(\n x 3000 / 6000 / 12000 3 / 5 / 10 817 / 3014 / 11164 69 / 128 / 249
8000 calls left open on one line (136 KB) 3129 5752 15
8000 writes on one line (192 KB) 13 905 18

Resolver shapes found while probing (ms, measured at e990312bd unless noted)

A file that spends the budget reports its remaining writes as unresolved.

Shape Size next Before After
writes each naming a different variable, 16000 453 KB 21 4956 234
const p: / for (const p / const {p x 20000 on one line 180-260 KB 6-15 816-2778 11-22
5000 writes reaching one 20000-name right-hand side 259 KB 10 27535 101
8000 writes past p = qN; + } pairs 343 KB 12 100850 404
8000 distinct names below 40000 blank lines 263 KB 10 11846 268
the pairs with a 4000-character name 8040 KB 572 33940 777
2 lines of 300000 a, 300 writes of a x 4000..3701 1721 KB - 115510 (gate, on 2c42633be) 100
a x 256 names on 4 lines of 300000 a 1207 KB - - 243
prefix names a, aa, ... on a-runs 1328 KB - - 430
3200 short functions, 20 names x 20 writes 13606 KB - - 1788
one distinct 4-character name per write, 760000 writes 29.6 MB 928 - 2458
one distinct 4000-character name per write, 3000 writes 23.0 MB 2436 heap out of memory at 1500-2000 writes (gate, on 2c42633be and 47d6b60d8) 2944
one distinct 200-character name per write, 20000 writes 8.2 MB - - 1422
at 1830f5210: 256 groups of 100 nested writes sharing a 1999-character literal of escaped quotes 999 KB - 26.6 s (gate, on e990312bd) 1764
at 1830f5210: the same, 790 groups (budget 99.9% spent) 3.08 MB - 89.3 s (gate, on e990312bd) 5144
at 1830f5210: one right-hand side of 400000 escaped-quote characters 400 KB - 37.8 s (gate, on e990312bd) 52
  • Peak RSS, from /usr/bin/time -v, with the text built flat before the call:
Shape next After The harness alone
760000 short names 0.83 GB 1.16 GB 0.42 GB
3000 names of 4000 characters 1.00 GB 0.98 GB 0.12 GB
20000 names of 200 characters - 0.53 GB -
  • The gate's own scripts on this head: mem2.cjs new 4000 3000 exits 0 in 3.8 s, peak RSS 1.40 GB, all 3000 writes reported unresolved. mem.cjs new 4 760000 exits 0 in 2.7 s, peak RSS 1.15 GB, 737189 lines reported unresolved.
  • In one run under load, the 200-character case took 4856 ms; three repeats took about 1420 ms each.
  • The stripComments pass on a 23 MB file takes about 1 s on next too.
  • Probed families: open calls, many writes on one line, distinct names (short, 200 and 4000 characters), repeated typed / for / destructuring heads, shared and wide right-hand sides, boundary walks, blank lines, long names, self-overlapping names, near-miss names, names that are prefixes of each other, typed heads with long names, short functions, and inputs just under both caps.

Rows (tests/state-write-path-drift-guard.test.cjs; 105 tests with the retained-guard file)

  • Limit rows:
    • cap: for the argument and for the line before the call;
    • cap: for the name length;
    • budget: for files taking exactly MAX-1 / MAX / MAX+1 work.
  • Counted rows (stats seam: argumentChars, ownLineChars, resolverWork, linesRead, assignmentLookups, readersBuilt, stringScanChars):
    • calls left open;
    • many writes on one line;
    • distinct names;
    • each repeated head;
    • a right-hand side read once;
    • lookups and line reads charged;
    • a name at overlapping offsets;
    • one reader per distinct name, short and long;
    • a run of escaped quotes in a target and in a right-hand side, each character examined at most three times.
  • Fail-closed rows:
    • a write after the budget is spent is still reported;
    • a write naming statePath outright is still charged;
    • CLI: an open call exits 1.

Fail-first and mutants

  • The test file at this head, run against older heads:
Older head New rows failing
8cea5f843 15 (90 pass)
4df1cd120 10
2c42633be 5
47d6b60d8 4
e990312bd 1
  • 24 mutants, each caught (failing new rows in brackets):
    • Cap: both caps off (2); no line cap (1); no argument limit (1); no argument check (3); no once-per-line (1); cap one character late (4); capped write passes (11).
    • Budget: no budget (6); flat line charge (3); one unit late (1); the write that ran out passes (5); later writes pass (4); no per-write charge (1); right-hand side not kept (1); right-hand side not charged (1); lookup not charged (1); no fixed read cost (1); no occurrence charge (1); no reader charge (2); no per-character reader charge (1); no name cap (1); name cap one character late (1); the quadratic regex restored (1); the scanner without its skip (1).

Differential, 8cea5f843 and e990312bd vs this head (0 differences against each)

  • findRawStateWrites over the 1728 tracked files, plus collect on the real tree.
  • Fuzz: 300000 short files (39467 with findings) and 100000 long ones (62630).
  • The offset-only path of nearestPrecedingAssignment against the old global scan: 3600000 comparisons over prefix-related and overlapping names (58618 non-null).
  • The harness is not blind: with the text cap set to 10 it finds 536 / 6804 differences, with the budget set to 2000 it finds 645 / 10352, and with the name cap set to 1 it finds 644 / 3616.

Commands (at 1830f5210; env -i with empty HOME and GSD_HOME; Node v24.13.0; Linux)

  • node --test tests/state-write-path-drift-guard.test.cjs tests/lint-state-field-drift-retained.test.cjs: 105 pass, 0 fail.
  • npm run lint:ci: exit 0.
  • GITHUB_BASE_REF=next node scripts/changeset/lint.cjs: ok_no_user_facing_changes.
  • node scripts/lint-state-write-path-drift.cjs: exit 0; --json summary all zero, 0 findings.
  • GSD_EMITTED_BASE=origin/next node --test tests/emitted-attribution.test.cjs: 148 pass, 0 fail. origin/next had moved to 09b6a836c, 3 commits past the base, none of them touching these files; the branch merges cleanly.

Generated by AI during triage; reviewed and posted by a human maintainer (@davesienkowski).

@davesienkowski
davesienkowski requested a review from trek-e October 22, 2026 14:44
trek-e

This comment was marked as outdated.

@trek-e
trek-e dismissed their stale review October 23, 2026 00:55

Withdrawn: review produced outside the required /review-open-prs procedure; run is being restarted.

…unresolved, even one naming statePath

Comment-only. The budget header and the MAX_RAW_WRITE_RESOLVER_WORK comment said only writes "not yet found to name the state path" are downgraded; once the budget is spent no argument is read, so a later fs.writeFileSync(statePath, c) is reported as raw_state_write_unresolved too (pinned by the budget test). The nearestPrecedingAssignment cache doc now lists stringScanChars, which findRawStateWrites builds and stripQuotedStrings increments.
@davesienkowski

Copy link
Copy Markdown
Collaborator Author

Review 5622671659 (head 1830f5210) was dismissed with "Withdrawn: review produced outside the required /review-open-prs procedure; run is being restarted." Nits 1 and 2 are fixed anyway and Nit 3 is answered below, so the restarted run starts from a head with those two done. New head: f2aedc2d1, one commit, comments only.

  • Nit 1, fixed (doc option). Two comments (the findRawStateWrites header and the MAX_RAW_WRITE_RESOLVER_WORK comment) said only writes "not yet found to name the state path" are downgraded. They now say what the budget test pins: once the budget is spent, every later write is reported as raw_state_write_unresolved. That includes a write whose argument names statePath itself. I kept the behaviour and did not run targetsStatePath before the exhausted short-circuit. That check would read up to MAX_RAW_WRITE_TEXT_LENGTH + 1 characters of each later write's argument after the budget is spent, outside the budget. The write is still reported either way (fails closed).
  • Nit 2, fixed. The cache shape in the nearestPrecedingAssignment doc now lists stringScanChars.
  • Nit 3, not rebased. next is 13 commits ahead. None of them touches either PR file, and the merge queue tests the merge result.
Self-test (head f2aedc2)
  • git diff 1830f5210 f2aedc2d1: 1 file, +15/-10. Every changed line is a * or // comment line.
  • node --test tests/state-write-path-drift-guard.test.cjs: tests 104, pass 104, fail 0.
  • node scripts/lint-state-write-path-drift.cjs: ok state-write-path-drift: no policy-dispatch, frontmatter-write, raw-write, prompt-layer, or composition-bypass violations found, exit 0.
  • npm run lint:ci: exit 0.
  • git diff --name-only HEAD...origin/next -- scripts/lint-state-write-path-drift.cjs tests/state-write-path-drift-guard.test.cjs: empty.
  • Environment: Linux 6.18 (WSL2), node v24.13.0.

Generated by AI during triage; reviewed and posted by a human maintainer (@davesienkowski).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: workflow Phase execution, ROADMAP, STATE.md no-changelog PR edits CHANGELOG.md directly or is docs-only; opts out of changeset requirement review: changes requested PR reviewed — changes required before merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

enhance(#4629): the Axis-2 raw STATE.md write guard misses a target held in a variable or split across lines

2 participants