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 17 commits into
open-gsd:nextfrom
davesienkowski:feat/5104-axis2-variable-and-multiline-target
Open

davesienkowski wants to merge 17 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.

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).

@davesienkowski
davesienkowski requested a review from trek-e October 10, 2026 21:26
@davesienkowski

Copy link
Copy Markdown
Collaborator Author

@trek-e one more commit, 3a6b4fb95, with no behavior change. A further adversarial pass showed my last reply overstated Minor 4 ("the header lists every verified false negative and false positive"):

  • The header's false-positive list now names the p => statePath arrow parameter and the sibling arrow body, both already pinned as rows. It also says the body-boundary test is textual: an unrelated closed block, an object literal, a callback or a ${} between a non-STATE const tail and the write also stops the clear. Four by-design rows pin those shapes.
  • Four known-gap rows, now listed in the header: a declaration whose value starts on the next line (typed and untyped), nested braces in an annotation, and a ; inside a string-literal type.

Local, empty HOME/GSD_HOME: the three guard files 175/175, real tree rawWriteViolations: 0, lint:ci 0. The four new gap shapes are added to the #5279 addendum.

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 of head 3a6b4fb958fd — changes requested (scope)

Summary. Majors 1 and 2 from the last review are fixed (verified by running findRawStateWrites on the head: typed const/let, sibling-block and closure writes now report). CI at this head: every row SUCCESS or SKIPPED; branch is BEHIND next by 40 commits, rebase is clean. I am not approving: the diff goes well past what #5104 approved.

Classification and gates. approved-enhancement, Closes #5104, ci(#5104):, not draft, ADR-4629 D5 honored, no changeset (no-changelog), scripts + tests only.

Scope against the issue (Layer 1: wrapped call capture; same-function resolution reusing nearestPrecedingAssignment with a bounded hop count; for...of/destructuring unresolved).

  • Major 1: about 480 added script lines and about 1,070 test lines against "about 20 lines". Not in the issue: the join-tail pre-check (changes existing verdicts), the CRLF normalization, the template-interpolation parser, += handling, same-line statement splitting. The typed-annotation grammar also changes Axis 3's regex ("Axis 2 only"). Items I requested (the boundary rule, typed declarations) may stay; the others should be removed or moved to their own issue first.
  • Major 2: the issue says to reuse nearestPrecedingAssignment; the PR adds nearestTargetBinding / lastBindingInText beside it. Only the regex is shared. Extend the existing helper, or record the waiver.
  • Minor: resolvedJoinFileTail and resolvesToStatePath each walk the chain with their own budget and seen set; fold into the single resolver. Minor: document the ctx shape. Minor: the header still lists module-level constants and one-line bodies as false negatives; fine as documented, but the guard's zero does not prove no raw writer remains.
  • Nit: PR body Testing section cites head 29f3b6e17 / 167 tests. Nit: rebase onto next (git fetch origin && git rebase origin/next).

Security. No new read/argv/subprocess surface; CI-only advisory guard.
Verdict. Changes requested. Blocker 0, Major 2, Minor 3, Nit 2.

Memtrace Evidence

The Memtrace MCP server was unavailable for this review (it failed to connect: memtrace not found in PATH), so no graph-backed check ran. None of the four checks below is a clean result.

  • get_impact: not run. Blast radius comes from reading the diff and grepping callers.
  • get_symbol_context: not run. Callers and callees of the changed symbols were checked by grep.
  • recall_decision: not run. The recorded-decision gate did not execute, so this does not show that no decision governs the change. ADRs and CONTEXT.md predicates were checked by grep instead.
  • find_code_review_issues: not run. The deterministic review pass did not run. A manual diff review stood in for it, so the findings above are judgment, not graph facts.

@davesienkowski
davesienkowski force-pushed the feat/5104-axis2-variable-and-multiline-target branch from 3a6b4fb to a7257be Compare October 16, 2026 14:15
@davesienkowski

Copy link
Copy Markdown
Collaborator Author

Thanks. Head is now a7257be6f, rebased onto next (46a119f).

Major 1: cut the join-tail pre-check, CRLF normalization, the template-interpolation parser, += handling and same-line statement splitting. Kept: multi-line capture, bounded hops, the for...of/destructuring stop, the write's own line up to the call, the boundary rule, typed declarations. Script +179/-25 (57 code lines), tests +452. Each shape the cut gives up is a known-gap row, listed for #5279.

One cost: path.join(path.dirname(statePath), 'ROADMAP.md') through a variable now reports and cannot be cleared, although you verified it unflagged in 5436531471. I left it pinned as a known gap. Question: do you want only the inline-literal-tail stop restored, so that shape stays unflagged and the variable-held tail stays cut?

Major 2: Axis 2 now calls nearestPrecedingAssignment with opt-in typed/stopAtPatternBinding/withLine options; Axis 3 passes none, so its regex is unchanged.

Minors: one resolver; ctx is gone; the header says the zero is not proof (ADR-3408 D5).

Tests 83/83, lint:ci exit 0; identity test #3468 C1/C2 6/6.

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

@davesienkowski
davesienkowski requested a review from trek-e October 16, 2026 14:15

@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: a7257be6f9061a1d06d2dd8b395ec8eeedb85bdf (gh pr view this turn: mergeable=MERGEABLE, mergeStateStatus=BEHIND, reviewDecision=CHANGES_REQUESTED). Re-review of trek-e's 2026-10-14 review 5512635344 (Majors 1-2, Minors 1-3, Nits) and the earlier rounds.

Summary

The scope cut landed. Prior Major 1 (scope), Major 2 (second resolver), Minors 1-3 and the rebase nit are resolved at this head. Delta reviewed in full (scripts/lint-state-write-path-drift.cjs +179/-25, tests/state-write-path-drift-guard.test.cjs +452). Two Minors remain from executed probes, plus two Nits. Zero tolerance applies, so changes requested.

Classification & gate compliance

  • Enhancement track; #5104 approved-enhancement; Closes #5104; title ci(#5104):; not draft; ADR-4629 Decision 5 (no allowlist) honored; ADR-3408 Decision 5 caveat now stated in the header.
  • Labels area: workflow, no-changelog; no changeset needed (CI-only guard). No docs/ change; no inventory surface.
  • PR body follows the Enhancement template. Branch is BEHIND next (merge-base 46a119f, next at f7efbf1); the two PR files are not touched on next, so the rebase is clean.

Prior findings at this head

  • Major 1 scope: FIXED. join-tail pre-check, CRLF normalization, template parser, +=, statement splitting are gone; each shape is a known-gap row.
  • Major 2 second resolver: FIXED. nearestTargetBinding/lastBindingInText removed; Axis 2 calls nearestPrecedingAssignment with opt-in options (typed, stopAtPatternBinding, withLine). Axis 3 passes none; the added includes(varName) skip cannot change Axis 3 (a line without the name cannot match assignRe).
  • Minor 1 single hop budget: FIXED (one resolvesToStatePath). Minor 2 ctx doc: moot (ctx removed). Minor 3 header caveat: FIXED (file header and function header). Nits: PR body head sha updated; rebase still outstanding (Nit 2 below).

Must-Have Acceptance Checklist (#5104 Layer 1)

  • Wrapped call captured over comment-stripped joined text (executed: 1 finding at the call line).
  • Variable target resolved through same-function assignments, bounded (MAX_TARGET_RESOLUTION_HOPS=4, exported; limit-1/limit/limit+1 rows).
  • for...of / destructuring stop as unresolved (bindsByPattern).
  • Reuses nearestPrecedingAssignment.
  • Axis 2 only, no src/ change, no allowlist, no dependency.
  • Tests: wrapped, variable, one-line, boundary rule, typed, hop bound, real-tree --root, property.
  • Miss 3 (roadmap-upgrade) out of scope per ruling; #5120 and #5279 are cited.

Root-cause / functional verdict

Root cause: Axis 2 matched the literal statePath/STATE.md text of a per-line first argument, so a wrapped call yielded an empty target and a variable target was never followed. The fix is at the scanner (joined text + bounded resolver), not a special case. Executed probes at head: wrapped=1, one-line const p = path.join(d,'STATE.md'); fs.writeFileSync(p,c)=1, statePath-derived dir + ROADMAP.md tail=1 (documented by-design false positive), 5-hop chain=0 (documented bound).

Blast radius

CI-only lint script (lint:ci); findRawStateWrites callers are collect/main in the same script and the test. Only new export: MAX_TARGET_RESOLUTION_HOPS. nearestPrecedingAssignment has one other caller (Axis 3), behavior unchanged without options. Risk low.

Co-change completeness

Script and test both in the diff. No CONTEXT.md, docs, inventory or changeset surface applies.

Test quality

  • Fail-first: PR reports 24 of 82 fail with next's script; the property asserts the hop-bound export first, so it fails at once on base.
  • No source-grep, no wall-clock, no todo. One fc.assert property (line 1081) over typed declarations, const/let/var, chains up to the bound, wrapped calls and sibling-block redeclarations.
  • Known-gap rows assert expected beside currentBuggyOutput, so they fail loudly when a fix lands.
  • CRLF: no passing fixture; see Minor 2.

Memtrace Evidence

  • get_impact: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). Used gh pr diff and a caller read instead: findRawStateWrites callers collect/main; nearestPrecedingAssignment callers are Axis 3 and Axis 2.
  • get_symbol_context: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). Read both functions at the head sha.
  • recall_decision: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). ADR-4629 D5 and ADR-3408 D5 checked from the PR text and trek-e's prior reviews, not from the decision store.
  • find_code_review_issues: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). Manual diff review plus executed probes (script loaded in a scratch worktree of refs/prrev/5119, node script probes only, no node --test).

Security

No new file read, argv, subprocess or traversal surface; output still passes sanitizeForReport. See Minor 1 for a resource-use finding on hostile source text.

Findings

Minor

  1. (executed probe, judgment on severity) scripts/lint-state-write-path-drift.cjs resolvesToStatePath (about line 705-718): the recursion branches over every identifier of the expression and, past a {/}/=> boundary, over every earlier binding of each name, to depth 4, with no visited set. A file with 60 let vN = v0+...+v59; lines each followed by { }, then fs.writeFileSync(v0+...+v59, c), took 20.4 s to scan with a 0 result. Source files are untrusted input to lint:ci; a fork PR can stall the lint job. Fix: memoize on (name, fromLine, hopsLeft) or cap total resolutions per write, and add a row with a time-free bound (count of nearestPrecedingAssignment calls via an injected seam, not wall clock).
  2. (executed probe, deterministic) CRLF source: const p = path.join(d,'STATE.md');\r\nfs.writeFileSync(p,c) reports 0. nearestPrecedingAssignment's ([^=].*)$ does not match across \r, so a Windows checkout with CRLF makes variable-target resolution blind while wrapped-call capture still works. The CRLF normalization was cut for scope (accepted), but it is listed neither as a known-gap row with its correct verdict nor in the findRawStateWrites header misses list was updated to say so only partially (header names it under "a CRLF file", but no KNOWN_* row pins it). Fix: add a known-gap row (expected 1, currentBuggyOutput 0), or make the capture [^\n]* for Axis 2 via the options object.

Nit
3. Branch is BEHIND next (merge-base 46a119f vs f7efbf1); git fetch origin && git rebase origin/next (no overlap with the 2 files).
4. ADR-4629 guard-table line refs (:606,564) stay stale; contributor declined the edit in earlier rounds, still open as trek-e's Nit 7/n2.

CI

Latest-per-name check runs for head a7257be: 0 non-success/non-skipped (35 SUCCESS, 7 SKIPPED, 3 CANCELLED earlier-run entries superseded by later SUCCESS reruns). action_required runs for this sha: 0. Pending: 0.

Verdict

Changes requested (0 Blocker, 0 Major, 2 Minor, 2 Nit). Substance is close; the scope cut resolved the earlier Majors.

…d same-function variable targets

Axis 2 of lint-state-write-path-drift (findRawStateWrites) now scans the
comment-stripped text joined across lines, so an fs.writeFileSync call
wrapped after its "(" no longer captures an empty target. A target that is
neither statePath nor a STATE.md literal is resolved through same-function
assignments, at most MAX_TARGET_RESOLUTION_HOPS (4) deep. A for...of or
destructuring binding stops resolution as unresolved, so the scan cannot
pick up a same-named const from a sibling block.

The real tree stays at 0 findings. The known raw writer at
src/roadmap-upgrade.cts:2328 is still not caught: its file name is a Map key
built in another function (miss 3), which trek-e ruled a separate open-gsd#4629
child.

Refs open-gsd#4629
…n-STATE.md join tail

Review round on the Axis 2 raw STATE.md write guard:

- The write's own line is read up to the call's column, so
  `const p = path.join(dir, 'STATE.md'); fs.writeFileSync(p, c)` resolves.
  Statements are read last to first, a right-hand side ends at its own
  `;`, `p += x` is followed, and the next hop resumes before the followed statement, so a chain on
  one line resolves.
- Rule: that line (and a hop's cut line) is read only when it is plain
  statements. A line with `{`, `}`, `=>`, `function` or `for` is not read, and
  Axis 2 behaves on it exactly as before: telling a closure's enclosing
  variable from a shadowing parameter or block-scoped sibling needs a
  scope analysis, and four review passes found false positives in each
  approximation.
- A `path.join`/`path.resolve` (including `path.posix`/`path.win32`)
  whose last segment is a literal file name is decided by that literal,
  so a directory derived from statePath joined with 'ROADMAP.md' is no
  longer flagged (no allowlist could clear it, ADR-4629 Decision 5). A
  trailing comma is ignored; a '' or '.' tail names no file.
- Arrow functions and methods stay non-boundaries (stopping there would
  hide writes inside closures); the sibling-body cost is pinned as a
  `limit:` row.
- Template interpolations are extracted by brace depth; CRLF input
  resolves variable targets (`.` never matched the trailing `\r`).
- Tests: a row per finding and per review probe, `rule:` and `limit:`
  rows pinning the line rule, MAX-1 on the hop bound, and a fast-check
  property over names, filler, call shape, statePath-derived
  directories, and line endings.
….md file name is not a raw STATE.md write

Review round 3 (5436531471) m2: `const n = 'ROADMAP.md'; fs.writeFileSync(path.join(d, n), c)`
with d derived from statePath was reported RAW_STATE_WRITE, and with no allowlist
(ADR-4629 Decision 5) nobody could clear it. A path.join/path.resolve now resolves its
last argument through the same-function resolver (spending hops), and when that
reaches a string literal that names a file, the literal alone decides. Every other
tail (a parameter, member, call, spread, interpolated template, conditional, '' or
'.', a module-level constant, an exhausted hop budget) falls through to the previous
rule unchanged, so the guard reports nothing less than before: across 56 probe shapes
the only changes are the five intended clears.

Still reported, and pinned as known gaps: an unresolved tail on a statePath-derived
directory (`path.join(d, name)`, `${name}.json`), where the written file is not visible
in the function.

m3: the limit rows assert today's output beside the correct verdict and fail loudly
when a fix changes either, instead of passing assertions on known-wrong verdicts
(CONTRIBUTING.md forbids `todo` tests).
…d pin the axis's call-match limits

Review round 4, M1: the join-tail pre-check let the nearest textual
assignment decide, so `let n = 'STATE.md'; if (x) n = 'ROADMAP.md';`
cleared a write that can land on STATE.md. A tail identifier is now
followed only through `const` declarations; a `let`, `var` or bare
assignment leaves the whole expression to decide, as before the
pre-check. The two reviewer probes are reported again, and a `let`
that is never reassigned is pinned as a known false positive.

m2-m4: the axis matches only a literal `fs.writeFileSync(` over
`stripComments` output, which does not track strings or regex
literals. The function header now says so, and the tests pin the
string/regex `//` misses, the string false positive and two other
write APIs as known gaps.
…ite and frontmatter-write scans

The shared assignment regex required `name =`, so `const p: string =
path.join(dir, 'STATE.md')` never bound `p` and the raw-write axis read 0
on the headline shape in the TypeScript it scans. A `const`/`let`/`var`
declaration may now carry an annotation before `=` (generics, unions,
record types). The annotation is read only after a declaration keyword
and holds no `=` or `;`, so object keys, `==`/`===` comparisons and
`let p: T; q = x` still bind nothing.

The regex is shared with Axis 3, so a parity row now pins that both axes
read the same binding grammar. Real tree: rawWriteViolations 0; Axis 3
findings and its resolver output at all 47 stateReplaceField( sites in
src/ are unchanged.
…no longer clears a raw STATE.md write

The raw-write axis took the nearest textual binding and ignored block and
arrow scope, so `const n = 'ROADMAP.md'` inside a closed `if` block or
arrow body cleared `fs.writeFileSync(path.join(d, n), c)` where `n` is a
parameter or an outer `const n = 'STATE.md'`.

Fail-safe minimum fix, not a scope tracker: a binding decides only when no
`{`, `}` or `=>` lies between the end of its right-hand side and the write
in the comment-stripped text. Past such a boundary, a const join tail no
longer clears (the whole-expression rule decides, as before the tail
pre-check), and the target chain keeps searching for an earlier binding of
the same name instead of stopping at the first one that misses STATE.md.
A binding that reaches STATE.md still reports at once, so closure writes
are unchanged. The rule only removes clears.

The const check is now anchored at the match start, so a later `const` in
the same statement span cannot mark a `let` as `const`.

New rows: the sibling-block probe (multi-line, one-line block, arrow
body), the outer-STATE/inner-ROADMAP shadow (directory from statePath and
from a plain parameter, and through the write target), controls for a
binding in the write's own body, a by-design row for the new fallback
(a const before a block that holds the write), and the whole-function
one-line probe as a known scope gap. Real tree: rawWriteViolations 0.
…esign fallback from known defects

Characterization only; no guard behavior changes.

- The findRawStateWrites header now lists every verified false negative
  (data built in another function, a module-level constant, a body on the
  write's own line, a chain past the hop bound, the unseen call shapes)
  and every verified false positive, and names the by-design fallback.
- Known-gap rows (expected plus currentBuggyOutput) for the one-line
  function and one-line arrow, a module-level STATE_FILENAME constant, a
  file name returned by another function, fs.appendFileSync,
  fs.renameSync(tmp, statePath), fs.writeFile, nfs.writeFileSync,
  require('fs').writeFileSync, `fs . writeFileSync`, fs?.writeFileSync and
  fs['writeFileSync'].
- A known false-positive table: same-name arrow parameter shadowing (two
  forms), a target derived from the state file, and a STATE.md.bak
  literal.
- The parameter and interpolated-template join tails move from
  KNOWN_TAIL_GAPS to BY_DESIGN_FALLBACKS, which asserts current output
  only: there the correct verdict is unknown, not wrong.
- MAX - 1 / MAX / MAX + 1 rows for the join-tail chain, built from the
  exported MAX_TARGET_RESOLUTION_HOPS.
- Rows for joinLastSegment, splitStatements and templateInterpolations,
  through findRawStateWrites (they are not exported).
- The gap-loop comment now says the following deepStrictEqual, not
  notDeepStrictEqual, is what fails loudly when a fix lands; review-round
  process comments are removed.
…lon still binds its name

`const p: { dir: string; file: string } | string = path.join(dir,
'STATE.md')` read 0: the annotation excluded `;`, which an object type
holds between its members. The annotation may now hold one level of
braces whose body is read whole; `;` and `=` outside braces still end
it, so `let p: T; q = x` binds nothing. Rows added to the typed
declaration test and the Axis 2 / Axis 3 parity table. Real tree:
rawWriteViolations 0; Axis 3 output unchanged at all 47 sites.
…ained and shadowed declarations

The existing property drew only `const` plus path.join(<dir>, '<literal>')
over shapes the resolver was built for, so it could not have caught the
typed-declaration or sibling-block misses.

A second property states its oracle from the spec: a write whose target
can be STATE.md is reported once at its own line, and a write whose every
possible target is another known file is not. It draws const/let/var,
five type annotations, a chain of 1..MAX_TARGET_RESOLUTION_HOPS
declarations, a closed sibling block that redeclares the target as
ROADMAP.md, a wrapped call, filler and CRLF. Against the previous head it
fails (shrunk counterexample: const, depth 1, dir, shadow); here it passes
on seeds 42, 1, 2, 3, 7, 11, 99, 1234 and 2026.

The RESERVED set shared by both properties now holds the JS/TS reserved
and keyword-like words (function, class, return, typeof, await, ...) and
every name the fixtures already use.
…ot in module state

The per-name assignment regexes lived in a module-level Map that grew
across every scanned file. They now live in the context that one
findRawStateWrites call already threads through the resolver, and
nearestTargetBinding looks its pair up once per call instead of once per
line. No verdict changes.

Measured on N unresolved writes in one function (best of 3): 500 writes
138 ms before, 128 ms after; 2000 writes 2210 ms before, 2085 ms after;
5000 writes 14134 ms before, 11068 ms after. The quadratic cost of
per-call backward resolution is unchanged; the AST follow-up (open-gsd#5279)
replaces it.
…sses as known gaps

The no-worse harness re-ran two misses reported in earlier rounds and
found neither pinned nor listed: a `;` inside a template nested in
another template's interpolation ends a right-hand side early, and
`p = fs.writeFileSync(p, c)` reads its target's binding as empty. Both
read 0 on the previous head and on this one. They are now known-gap rows
(expected plus currentBuggyOutput) and listed among the verified false
negatives in the findRawStateWrites header. No behavior change.
…e header and as rows

The header's false-positive list now names the arrow parameter read as an
assignment and the sibling arrow body, both already pinned. It states that
the body-boundary test is textual, so an unrelated closed block, an object
literal, a callback or a `${}` interpolation also stops a const tail from
clearing a write; four by-design rows pin that. Four known-gap rows pin
missed writes: a declaration whose value starts on the next line (typed and
untyped), an annotation with nested braces, and a `;` inside a
string-literal type.
… 1 and reuse nearestPrecedingAssignment

Review 5512635344 (Majors 1 and 2, Minor 1):

- Axis 2 now resolves a target through nearestPrecedingAssignment itself,
  extended with opt-in options (typed, stopAtPatternBinding, withLine).
  Axis 3 passes no options, so its regex and verdicts are unchanged.
  nearestTargetBinding, lastBindingInText and the binding regex cache are gone.
- Removed, moved to open-gsd#5279: the join-tail pre-check (joinLastSegment,
  fileNameLiteral, resolvedJoinFileTail), CRLF normalization, the
  template-interpolation parser, += handling, and same-line statement
  splitting (splitStatements, statementSpans, CUT_LINE_BODY_RE). With the
  join tail gone there is one resolver, one hop budget.
- Kept, as requested in earlier rounds: multi-line capture, the bounded
  hop count, the for...of / destructuring stop, the write's own line read
  up to the call, the body-boundary rule, and typed declarations.
- Each shape the cut gives up is a known-gap row with its correct verdict.
…zero is not proof

Review 5512635344, Minor 3: the file header's Axis 2 paragraph now states
that a zero from this line scan does not prove no raw STATE.md writer
remains, and points at findRawStateWrites' list of verified misses
(ADR-3408 Decision 5) and the syntax-tree follow-up, open-gsd#5279.
…he misses it closed

- Known-gap rows (false positives) for three shapes whose verdict moved
  with the cut: a one-line top-level function's parameter resolved into an
  earlier function, a bare `x =>` arrow parameter shadowing an outer
  variable, and a `for` header's loop-scoped `let` earlier on the write's
  line. The findRawStateWrites header lists them.
- One positive row for three shapes that were known misses before the cut
  and now report: an assignment and write in a one-line block, in a
  one-line arrow body, and a reassignment in a closed block on the write's
  line.
…ound is not exported

Without the export, fc.integer({ max: undefined }) draws unbounded chain
lengths and the run dies out of memory instead of failing. Asserting the
export first makes the fail-first run against next's guard report its
failures (24 of 82) with no shim.
… write stay bounded

Every hop of resolvesToStatePath branched over each identifier of a
right-hand side, with no record of what it had already resolved. A file
with 60 `let vN = v0 + ... + v59;` lines, each followed by `{ }`, and
`fs.writeFileSync(v0 + ... + v59, c)` took 112 s to scan locally (20.4 s
in review 5558390402).

Each name's lookup is now memoized per write on (name, line, hops left),
in a context findRawStateWrites makes fresh per write, never in module
state. The verdict depends only on those and the scanned lines, so no
verdict moves: the old and new script agree on all 650 tracked files
that call fs.writeFileSync (964 findings, 0 differences). The 60-name
file now scans in about 1.1 s locally.

findRawStateWrites takes an optional stats object and reports
assignmentLookups, the nearestPrecedingAssignment calls it made (the
count is overwritten per call, not accumulated). A new row bounds that
count on the crafted shape at 20 names without a clock: 33215 calls
without the memo (the row fails), 1694 with it, against a bound of
2 * N * (N + 1) * MAX_TARGET_RESOLUTION_HOPS = 3360 (at
MAX_TARGET_RESOLUTION_HOPS = 4).

The bound is per write only. Residuals, measured locally:
- Cost scales with the number of writes, because the memo is rebuilt per
  write: the 60-name file with the write line repeated 40 times took
  48.6 s (634960 lookups).
- The assignment regex prefix backtracks on a long whitespace run: one
  40000-space line holding the name, plus one write, took 0.9 s (80000
  spaces: 3.1 s); with 20 writes, 17.6 s. origin/next's Axis 2 took 2 to
  8 ms on the same inputs.
@davesienkowski
davesienkowski force-pushed the feat/5104-axis2-variable-and-multiline-target branch from a7257be to 472cfb2 Compare October 18, 2026 01:34
@davesienkowski
davesienkowski requested a review from trek-e October 18, 2026 01:35
@davesienkowski

Copy link
Copy Markdown
Collaborator Author

Thanks for the re-review (5558390402). Head is now 472cfb257, rebased onto next 755bf26f0 with no conflicts (nit 3). PR body refreshed (head, base, counts).

  • Minor 1: lookups are now memoized per write (472cfb257). Your 60-name shape took 112 s before and about 1.1 s after here (you measured 20.4 s). Residuals: cost grows with write count (40 writes of that shape: 48.6 s), and the assignment regex prefix backtracks on long whitespace runs (one 40000-space line: 0.9 s; with 20 writes: 17.6 s; next: under 10 ms). Do you want (b) a per-file lookup budget or (c) a linear regex prefix (shared with Axis 3; I would confirm identical verdicts) in this PR?
  • Minor 2: the KNOWN_SCOPE_GAPS row "a variable target in a CRLF file" exists (line 904 at a7257be, 926 now). .gitattributes sets eol=lf and all src/ files are LF. [^\n]* remains available.
  • Nit 4: still declined: I keep ADR edits out of contribution PRs. Citing the symbol name instead of a line number would outlast future edits.
  • Question: does "documented by-design false positive" mean you do not want the inline-literal-tail stop restored?

Generated by AI; 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: 472cfb2575a713232006dc9e7969d53699d1aede (gh pr view this turn: mergeable=MERGEABLE, mergeStateStatus=BEHIND, reviewDecision=CHANGES_REQUESTED).

Summary

Re-review of trek-e's 2026-10-17 review (head a7257be6f, Minors 1-2, Nits 3-4). The delta since that review is one commit, 472cfb2575 (script +53/-12, test +25). Prior Minor 2 (CRLF known-gap row) is resolved. Prior Minor 1 (resolution cost on hostile source) is only partly resolved: the exponential branching is memoized, but an executed probe still stalls the guard on a hostile line. Zero tolerance applies, so changes requested.

Classification & gate compliance

  • Enhancement track. #5104 carries enhancement + approved-enhancement + area: workflow. Approval comment from trek-e scopes Layer 1 (Axis 2 only); the diff touches only scripts/lint-state-write-path-drift.cjs and tests/state-write-path-drift-guard.test.cjs.
  • Closes #5104 present; title ci(#5104): conventional; not draft; branch feat/5104-axis2-variable-and-multiline-target matches <prefix>/NNN-slug; Enhancement template used.
  • Labels no-changelog, area: workflow, review: changes requested. CI-only guard, no changeset, no docs surface, no inventory surface. Consistent.
  • ADR-4629 Decision 5 (no allowlist) honored; ADR-3408 Decision 5 caveat is in the file header. Memtrace unavailable, so ADRs were checked from the issue and PR text.
  • Branch is BEHIND origin/next (merge-base 755bf26f0, next at 274f8cae1). git diff 755bf26f0 origin/next is empty for both PR files, so the rebase is clean.

Must-Have Acceptance Checklist (#5104 Layer 1)

  • Wrapped fs.writeFileSync first argument captured over comment-stripped joined text.
  • Non-statePath/non-literal target resolved through same-function assignments, bounded (MAX_TARGET_RESOLUTION_HOPS = 4, exported, limit-1/limit/limit+1 rows).
  • for...of / destructuring binding stops resolution as unresolved.
  • Reuses nearestPrecedingAssignment (opt-in options, Axis 3 passes none).
  • Axis 2 only; no src/ change, no allowlist, no new dependency.
  • Failing-first fixtures, negatives, sibling-block fixture, real-tree control. Miss 3 correctly out of scope (#5120, #5279).
  • Not an issue criterion but an issue-stated posture ("same string-match posture"): the guard runs on untrusted source in lint:ci; see Minor 1.

Prior findings at this head (trek-e review of a7257be6f)

  • Minor 1 (hostile-source resolution cost): PARTLY FIXED. resolvesToStatePath now memoizes per write on (name, index, hopsLeft); the 20-name branching shape is bounded by a counted seam. Residuals remain, see Finding 1.
  • Minor 2 (CRLF known-gap row): FIXED. tests/state-write-path-drift-guard.test.cjs:928-933 pins expected: [3] beside currentBuggyOutput: [].
  • Nit 3 (behind next): OPEN.
  • Nit 4 (ADR-4629 line refs :606,564 stale): OPEN, author declined again; author suggests citing the symbol name instead.

Root-cause / functional verdict

Root cause (unchanged, verified): Axis 2 matched the literal statePath/STATE.md text of a per-line first argument, so a wrapped call produced an empty target and a variable target was never followed. The fix is in the scanner (joined text plus bounded resolver), not a special case.

Delta check of 472cfb2575: the memo key name\0index\0hopsLeft fully determines the verdict (inputs are scanLines, which is fixed per write, plus those three), so it changes no verdict. findRawStateWrites(rel, text, stats = {}): the only callers are collect (2 args) and the test file; no .map(findRawStateWrites) index-as-third-argument hazard. stats.assignmentLookups is reset per call. Executed at head (scratch worktree, node probe scripts only): shape from the bound row with N=20 and 40 writes: 67760 lookups, 317 ms, 0 findings; N=60, 1 write: 483 ms. The memo works for the shape it was built for.

Blast radius

CI-only lint script (lint:ci). findRawStateWrites callers: collect and the test. nearestPrecedingAssignment callers: Axis 2 and Axis 3 (Axis 3 passes no options; unchanged). New exports: MAX_TARGET_RESOLUTION_HOPS. Risk low. Real-tree result stays rawWriteViolations: 0 per CI.

Co-change completeness

Script and test both in the diff. No CONTEXT.md, docs, changeset or inventory surface applies for a CI-only guard with no-changelog.

Test quality

  • Fail-first: author reports 25 of 83 tests fail with next's script; the property asserts the hop-bound export first. The new bound row fails on any script lacking the stats seam (typeof stats.assignmentLookups), and per author's measurement fails without the memo (33215 vs bound 3360).
  • Bound row is counted, not timed; no wall-clock assertion. No source-grep, no todo. One fc.assert property (line 1106).
  • Known-gap rows assert expected beside currentBuggyOutput and that they differ.
  • The new bound row does not cover the whitespace-backtracking or many-writes shapes (Finding 1); the row's own comment says so.

Memtrace Evidence

  • get_impact: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). Used instead: git grep for findRawStateWrites callers (script and test only) and the diff of nearestPrecedingAssignment callers.
  • get_symbol_context: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). Read resolvesToStatePath, nameResolvesToStatePath, findRawStateWrites at the head sha from git diff refs/prrev/5119~1 refs/prrev/5119.
  • recall_decision: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). ADR-4629 D5 / ADR-3408 D5 taken from the issue, the PR body and trek-e's prior reviews, not the decision store.
  • find_code_review_issues: UNAVAILABLE — Memtrace MCP failed to connect (ENOENT). Manual diff review plus executed probe scripts (no node --test) in a scratch worktree of the head.

Security

No new file read, argv, subprocess or traversal surface; findings still pass sanitizeForReport. Resource use on hostile source text is the one open item (Finding 1): source files are untrusted input to lint:ci (CONTRIBUTING hostile-input rule), and a fork PR can stall the lint job.

CI

Latest-per-name check runs for 472cfb2575: 23 success, 5 skipped, 0 failing, 0 pending, 0 other non-success. action_required runs for this head sha: none ([]). Note: 28 check runs listed for this head versus 35 success + 7 skipped on a7257be6f; the maintainer should confirm the full matrix executed (the PR is BEHIND, and the count is lower).

Findings

Minor

  1. (executed probe, deterministic; severity is judgment) scripts/lint-state-write-path-drift.cjs resolvesToStatePath/nameResolvesToStatePath (about lines 722-755) and the assignment regex used through nearestPrecedingAssignment: the memo is rebuilt for every write, and each lookup is a line scan whose regex backtracks on a long whitespace run. The doc comment states both residuals ("Neither is bounded here"). Executed at head: a single source line const p<40000 spaces>x; followed by N copies of fs.writeFileSync(p, c); in one function: N=1 1179 ms, N=5 6369 ms, N=20 22581 ms, 0 findings. Same input on the whitespace placed after the = right-hand side: 3 ms, so the cost is in the binding regex prefix. The author's own numbers (40-write shape 48.6 s) agree. Source files are untrusted input to lint:ci; this is the same stall class as the earlier Minor 1. Fix: cache the binding lookup per file (key name, from) instead of per write, and make the regex prefix linear (for example [ \t]* bounded or a [^\S\n] form shared with Axis 3, confirming identical Axis 3 verdicts), or cap the total lookups per file with a degraded verdict; add a counted-seam row for the many-writes shape. The author asked which of (b) per-file budget or (c) linear prefix to take; the maintainer must answer, and either is acceptable.
  2. (judgment) scripts/lint-state-write-path-drift.cjs findRawStateWrites(rel, text, stats = {}): a test-only seam sits in the production signature and its doc comment records that null throws and that the count is overwritten per call. Fix: accept stats and guard with if (stats) stats.assignmentLookups = 0 (or default via a module-private counter), so null does not throw.

Nit
3. Branch is BEHIND origin/next (274f8cae1); git fetch origin && git rebase origin/next (no overlap with the two files).
4. tests/state-write-path-drift-guard.test.cjs around line 768: the new row comment cites "#5119 review 5558390402" and "when this row was added" measurements. Review-round references in comments age badly (the author removed the same kind earlier); state the rule, not the review.
5. ADR-4629 guard-table line refs (:606,564) are still stale; author declined again and suggested citing the symbol name. Still open from trek-e's earlier Nit.

Open author questions (unanswered, need the maintainer)

  • (a) Restore only the inline-literal-tail stop so path.join(path.dirname(statePath), 'ROADMAP.md') through a variable stays unflagged, or keep it pinned as a documented known gap?
  • (b) vs (c) for the Finding 1 residuals.

Verdict

Changes requested (0 Blocker, 0 Major, 2 Minor, 3 Nit). Substance is close; the exponential case is fixed and the CRLF gap is pinned. The remaining Minor is a reproducible stall on a hostile single line.

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