diff --git a/.coldstart/notebook/.raw/hooks-evidence-mjs-147e7dd5.jsonl b/.coldstart/notebook/.raw/hooks-evidence-mjs-147e7dd5.jsonl index df9d206..47fe89e 100644 --- a/.coldstart/notebook/.raw/hooks-evidence-mjs-147e7dd5.jsonl +++ b/.coldstart/notebook/.raw/hooks-evidence-mjs-147e7dd5.jsonl @@ -3,3 +3,4 @@ {"aliasesVerified":true,"id":"hooks-evidence-mjs-147e7dd5","type":"file","op":"put","anchors":[{"path":"hooks/evidence.mjs","symbols":["extractEvidence","extractCursorEvidence","extractCodexEvidence","classifyBash","codexCmdStrings","makeCollector","contentReadFiles","segmentStats","segmentStatsCursor","normRel"]}],"verified":[],"v":1,"ts":"2026-08-02T18:19:44.291Z","head":"8d5cc74d3cb9"} {"summary":"Per-host transcript-to-evidence extractors with tier classification (edit/read/gs/mention). Host gotchas verified on real transcripts: Cursor's conversation JSONL has NO tool_result records, so its extractor trusts calls and compensates with a stat-existence check on EVERY claim; Codex pairs custom_tool_call to output by call_id (result-confirmed) and its exec input embeds shell as tools.exec_command with the cmd key appearing BOTH quoted and unquoted across rollout versions. Default-deny: an unknown bash verb contributes at most a mention. Reading through the SHELL counts as reading: read verbs by name, plus inline scripts in any language (node -e, python3 -c, php -r, heredocs) matched on the file-open call in their body, since with an inline flag that body is in the command string; a script FILE stays a mention because its body is invisible. Measured on 2961 local transcripts while adding this: attributing the whole command would have over-tiered 50.6% of real inline reads (they carry unrelated path tokens), and output VOLUME was rejected as a precision criterion because cat's median output is 18 lines against an inline script's 10 — the distributions do not separate.","identityAliases":["inline script read detection","bashContentRead"],"incidentAliases":["cat not counted as read","node -e read invisible","script read not captured"],"anchors":[{"path":"hooks/evidence.mjs","symbols":["extractEvidence","extractCursorEvidence","extractCodexEvidence","classifyBash","codexCmdStrings","makeCollector","contentReadFiles","segmentStats","segmentStatsCursor","normRel","inlineScriptTier","bashContentRead"],"hash":"sha256:d8b68b9a4e7f","head":"69c1416ff12d"}],"id":"hooks-evidence-mjs-147e7dd5","type":"file","op":"put","verified":["hooks/evidence.mjs"],"character":"single","v":1,"ts":"2026-08-25T18:44:33.058Z","head":"69c1416ff12d"} {"summary":"Per-host transcript-to-evidence extractors with tier classification (edit/read/gs/mention), and the one table deciding what shell activity counts as reading or writing a file. Host gotchas verified on real transcripts: Cursor's conversation JSONL has NO tool_result records, so its extractor trusts calls and compensates with a stat-existence check on EVERY claim; Codex pairs custom_tool_call to output by call_id and its exec input embeds shell with the cmd key appearing BOTH quoted and unquoted across rollout versions. Default-deny: an unknown bash verb contributes at most a mention. Reading through the SHELL counts as reading (read verbs, plus inline scripts in any language matched on the file-open call in their body); writing through the shell counts as writing (bashEditTargets: redirect, tee, sed -i, literal write arg). A heredoc tag is an ARBITRARY identifier — the old EOF/PY/JS whitelist missed PATCH, TS and MSG and made those scripts invisible to both surfaces. redirectTargets overrides the verb table so `cat > x < x <= 0.3 of the seed's own commits, shared >= 2, top 5) because 'what am I forgetting' is a recall question while the `gs` cut is tuned for precision. It needs no `init` wiring at all: PostToolUse is already registered on all three hosts with a match-everything matcher, and cursor-nudge-handler delegates to the Codex one, so a new detector reaches every host for free.","steps":[{"path":"hooks/cochange-nudge.mjs","symbols":["coChangeNudge","suggest","resetEditTrack","coChangePath"],"role":"the whole feature: gate, per-task edit list, message"},{"path":"hooks/nudge-handler.mjs","symbols":["handle"],"role":"host entry — calls it as detector (7) at priority 3"},{"path":"hooks/kb-recall.mjs","symbols":["handle"],"role":"UserPromptSubmit: the ONLY thing that clears the edit list"},{"path":"src/indexer/cochange.ts","symbols":["deriveCoChange","coChangePath"],"role":"the keeper-derived sidecar the hook reads"}],"invariants":["UserPromptSubmit is the ONLY clearing signal. Elapsed time is the wrong shape for a task boundary — an auto-mode run edits for hours with no prompt, and a session id survives a resume days later. A settle clock, the session id, and a 30-min TTL were each tried and rejected as proxies for it. The 7-day sweep in resetEditTrack is garbage collection for abandoned files, never a boundary.","The nudge (PostToolUse) and the reset (UserPromptSubmit) are wired by SEPARATE init functions — wireClaudeHooks vs wireClaudeKbHooks — so a repo can have the nudge without the reset. There the list never clears; it degrades quietly rather than loudly, because each file is named at most once.","The edit list is its own tmp file, never folded into /tmp/find_nudge_.json, which the search detectors and the preguard's deny-key depend on.","Hooks never import from dist/, so the hook MIRRORS getCacheDir/cacheKey. tests/cochange-nudge.test.ts asserts the two paths are equal — without it a rename on the TS side kills the feature silently."],"verified":["hooks/cochange-nudge.mjs","hooks/nudge-handler.mjs","hooks/kb-recall.mjs","src/indexer/cochange.ts"],"id":"how-the-co-change-signal-reaches-an-agent-that-never-runs-gs","type":"flow","op":"put","v":1,"ts":"2026-08-25T18:18:29.502Z","head":"0ef874f04c41","anchors":[{"path":"hooks/cochange-nudge.mjs","hash":"sha256:4082ffe5081a","head":"0ef874f04c41"},{"path":"hooks/nudge-handler.mjs","hash":"sha256:4d6579a98d8f","head":"0ef874f04c41"},{"path":"hooks/kb-recall.mjs","hash":"sha256:7a8923adf6e0","head":"0ef874f04c41"},{"path":"src/indexer/cochange.ts","hash":"sha256:b77c94aa16f5","head":"0ef874f04c41"}]} {"title":"how the co-change signal reaches an agent that never runs gs","summary":"The \"Edited together\" partners are delivered to the agent WITHOUT it asking, at the moment an edit lands, because agents do not reliably run `gs` before editing. What counts as \"an edit\" is the load-bearing part: an edit TOOL or a deterministic shell write (`>`/`>>`, `tee`, `sed -i`, or a write call naming a literal path, via bashEditTargets in hooks/evidence.mjs). Gating on tool names alone made the whole feature silent for an agent instructed to edit with sed/heredocs/scripts — which is the auto-mode default, so the agent that most needed the nudge was the one guaranteed never to get it. The delivery path shares NO code with the `gs` rendering path: it is detector (7) inside the PostToolUse nudge handler, it reads the keeper's `cochange.json` sidecar directly, and its gate is deliberately LOOSER than `gs` (ratio >= 0.3 of the seed's own commits, shared >= 2, top 5) because \"what am I forgetting\" is a recall question while the `gs` cut is tuned for precision. It needs no `init` wiring: PostToolUse is registered on all three hosts with a match-everything matcher, and cursor-nudge-handler delegates to the Codex one.","identityAliases":["shell edits count as edits","sed heredoc edit nudge"],"incidentAliases":["nudge silent in auto mode","edited via script no nudge"],"steps":[{"path":"hooks/cochange-nudge.mjs","symbols":["coChangeNudge","editedFiles","suggest","resetEditTrack","coChangePath","EDIT_TOOLS","SHELL_TOOLS"],"role":"the whole feature: what counts as an edit, the per-task edit list, the gate, the message"},{"path":"hooks/evidence.mjs","symbols":["bashEditTargets","redirectTargets","inlineScriptTier"],"role":"decides which repo files a shell command WRITES — the shared table, so the nudge and the capture worklist cannot disagree"},{"path":"hooks/nudge-handler.mjs","symbols":["handle"],"role":"host entry — calls it as detector (7) at priority 3"},{"path":"tests/cochange-nudge.test.ts","role":"pins the shell-edit shapes and the deliberate silences"}],"invariants":["only DETERMINISTIC shell writes count; a write to a computed path claims nothing. Measured: the deterministic rules cover 89.2% of real shell writes, and inferring the rest from 'the one source path in the script' added 5.8% while making it possible to name a file the script only READ","a heredoc tag is an arbitrary identifier (EOF, PATCH, TS, MSG) — never re-introduce a tag whitelist, it made whole scripts invisible","a redirect target is an edit even under a read verb, so `cat > x <`/`>>`, `tee`, `sed -i`, or a write call naming a literal path — or a variable holding a literal, resolved one hop — via bashEditTargets in hooks/evidence.mjs). Gating on tool names alone made the whole feature silent for an agent instructed to edit with sed/heredocs/scripts — which is the auto-mode default, so the agent that most needed the nudge was the one guaranteed never to get it. The delivery path shares NO code with the `gs` rendering path: it is detector (7) inside the PostToolUse nudge handler, it reads the keeper's `cochange.json` sidecar directly, and its gate is deliberately LOOSER than `gs` (ratio >= 0.3 of the seed's own commits, shared >= 2, top 5) because \"what am I forgetting\" is a recall question while the `gs` cut is tuned for precision. It needs no `init` wiring: PostToolUse is registered on all three hosts with a match-everything matcher, and cursor-nudge-handler delegates to the Codex one.","steps":[{"path":"hooks/cochange-nudge.mjs","symbols":["coChangeNudge","editedFiles","suggest","resetEditTrack","coChangePath","EDIT_TOOLS","SHELL_TOOLS"],"role":"the whole feature: what counts as an edit, the per-task edit list, the gate, the message"},{"path":"hooks/evidence.mjs","symbols":["bashEditTargets","redirectTargets","inlineScriptTier","inlineCalls"],"role":"decides which repo files a shell command WRITES — the shared table, so the nudge and the capture worklist cannot disagree"},{"path":"hooks/nudge-handler.mjs","symbols":["handle"],"role":"host entry — calls it as detector (7) at priority 3"},{"path":"tests/cochange-nudge.test.ts","role":"pins the shell-edit shapes and the deliberate silences (computed target, read-only file)"}],"invariants":["UserPromptSubmit is the ONLY clearing signal. Elapsed time is the wrong shape for a task boundary — an auto-mode run edits for hours with no prompt, and a session id survives a resume days later. A settle clock, the session id, and a 30-min TTL were each tried and rejected as proxies for it. The 7-day sweep in resetEditTrack is garbage collection for abandoned files, never a boundary.","The nudge (PostToolUse) and the reset (UserPromptSubmit) are wired by SEPARATE init functions — wireClaudeHooks vs wireClaudeKbHooks — so a repo can have the nudge without the reset. There the list never clears; it degrades quietly rather than loudly, because each file is named at most once.","The edit list is its own tmp file, never folded into /tmp/find_nudge_.json, which the search detectors and the preguard's deny-key depend on.","Hooks never import from dist/, so the hook MIRRORS getCacheDir/cacheKey. tests/cochange-nudge.test.ts asserts the two paths are equal — without it a rename on the TS side kills the feature silently.","only DETERMINISTIC shell writes count. A write whose target is built at runtime claims nothing, and a file the script only READS is never an edit (each open call is tiered on its own). A variable holding a LITERAL is not computed: inlineCalls resolves it one hop to the last literal assignment (#176). The rejected heuristic was inferring a target from 'the one source path mentioned in the script' (5.8% marginal gain, could name a file only read)","a heredoc tag is an arbitrary identifier (EOF, PATCH, TS, MSG) — never re-introduce a tag whitelist, it made whole scripts invisible","a redirect target is an edit even under a read verb, so `cat > x <`/`>>`, `tee`, `sed -i`, or a write call naming a literal path) — because agents do not reliably run `gs` before editing, and an agent told to edit with sed/heredocs/scripts never touches an edit tool at all, so a tool-name gate goes silent for exactly the agent that needs this most. A write to a COMPUTED path claims nothing: the deterministic rules cover 89.2% of real shell writes and inferring the rest from "the one source path in the script" added 5.8% while making it possible to name a file the script only read — measured, rejected. Three rules: the edit list clears on every new user message (`resetEditTrack`, called from the three UserPromptSubmit recall hooks) — **that is the ONLY clearing signal, elapsed time is the wrong shape for a task boundary** (an auto-mode run edits for hours with no prompt, and a session id survives a resume days later; the 7-day sweep is garbage collection, never a boundary); nothing fires until a 2nd DISTINCT file is edited (trivial one-file jobs); then it fires for any file not already edited and not already named. Gate is LOOSE (ratio ≥ 0.3 of the seed's own commits, shared ≥ 2, top 5, tests excluded both sides) because "what am I forgetting" is a RECALL question — the opposite of the precision-tuned cut `gs` renders. Needs NO `init` wiring: PostToolUse is already registered on all three hosts with a match-everything matcher, and Cursor delegates to the Codex handler. Its state is a SEPARATE tmp file from the find-nudge state, which the search detectors and the preguard's deny-key depend on. Reads `cochange.json` directly, MIRRORING `getCacheDir`/`cacheKey` (hooks never import from `dist/`) — `tests/cochange-nudge.test.ts` pins the two together. +- `hooks/cochange-nudge.mjs` — the co-change signal delivered WITHOUT being asked for: detector (7) in the PostToolUse nudge, fired on the EDIT event — an edit TOOL (Edit/Write/MultiEdit/NotebookEdit + Cursor's `SearchReplace`) **or a shell command that writes a file** (`bashEditTargets` in `hooks/evidence.mjs`: `>`/`>>`, `tee`, `sed -i`, or a write call naming a literal path) — because agents do not reliably run `gs` before editing, and an agent told to edit with sed/heredocs/scripts never touches an edit tool at all, so a tool-name gate goes silent for exactly the agent that needs this most. A write to a COMPUTED path claims nothing (a variable holding a literal, `p='x.json'` then `open(p,'w')`, is NOT computed: `inlineCalls` in `hooks/evidence.mjs` resolves it one hop to the last literal assignment and tiers each open call on its own, #176): the deterministic rules cover 89.2% of real shell writes and inferring the rest from "the one source path in the script" added 5.8% while making it possible to name a file the script only read — measured, rejected. Three rules: the edit list clears on every new user message (`resetEditTrack`, called from the three UserPromptSubmit recall hooks) — **that is the ONLY clearing signal, elapsed time is the wrong shape for a task boundary** (an auto-mode run edits for hours with no prompt, and a session id survives a resume days later; the 7-day sweep is garbage collection, never a boundary); nothing fires until a 2nd DISTINCT file is edited (trivial one-file jobs); then it fires for any file not already edited and not already named. Gate is LOOSE (ratio ≥ 0.3 of the seed's own commits, shared ≥ 2, top 5, tests excluded both sides) because "what am I forgetting" is a RECALL question — the opposite of the precision-tuned cut `gs` renders. Needs NO `init` wiring: PostToolUse is already registered on all three hosts with a match-everything matcher, and Cursor delegates to the Codex handler. Its state is a SEPARATE tmp file from the find-nudge state, which the search detectors and the preguard's deny-key depend on. Reads `cochange.json` directly, MIRRORING `getCacheDir`/`cacheKey` (hooks never import from `dist/`) — `tests/cochange-nudge.test.ts` pins the two together. - `src/graph/` — `coldstart graph`: `dump.ts` (index + co-change sidecar + note anchors → one payload; adaptive directory banding, 4 named relation kinds), `view.ts` (bake + write + open), `view-template.ts` (**GENERATED** — edit `scripts/graph-viewer/sphere.template.html` and re-run `scripts/graph-viewer/build-template.mjs`, never hand-edit). Human-only, deliberately NOT an MCP tool, same as `kb view`. The page is dependency-free HTML/CSS/canvas with the payload inlined. `scripts/graph-viewer/build-site-page.mjs` emits `site-astro/src/graph-page.html` (no note edges — the notebook is private), which the Astro route `src/pages/graph/index.astro` inlines; it is a route and not a `public/` file because `astro dev` 404s on `/graph/` when the file only exists at `public/graph/index.html`. - `src/kb/` — notebook: store / raw-log / fold / render / search / write / freshness / notes-index / lint / cli - `src/types.ts`, `src/constants.ts` diff --git a/hooks/evidence.mjs b/hooks/evidence.mjs index f76f60c..0190c53 100644 --- a/hooks/evidence.mjs +++ b/hooks/evidence.mjs @@ -108,18 +108,59 @@ const READ_API_RE = new RegExp( const WRITE_API_RE = new RegExp( [ "writeFileSync", "appendFileSync", "writeFile\\s*\\(", "createWriteStream", "Deno\\.writeTextFile", - "\\bopen\\s*\\([^)]*['\"][wax]", "\\.write_text\\b", "json\\.dump\\s*\\(", + // A mode string AFTER the first argument. The old `[^)]*['"][wax]` also matched + // the first letter of the PATH, so open('assets/x.json') was reported as an edit. + "\\b(?:f?open)\\s*\\([^,)]*,\\s*(?:mode\\s*=\\s*)?['\"][wax]", "\\.write_text\\b", "json\\.dump\\s*\\(", "File\\.write", "os\\.WriteFile", "ioutil\\.WriteFile", "file_put_contents", "Files\\.write", "\\bwriteText\\s*\\(", ].join("|"), ); -// The quoted first argument of a file-open call — WHICH file the script named. -// Detection (above) answers "is this a read"; this answers "a read of what", and -// the two are deliberately separate: the nudge only needs the first, the capture +// A file-open call and the text after its opening paren. Detection (above) answers +// "is this a read"; inlineCalls answers "a read of WHAT, and is THIS call a write", +// and the two are deliberately separate: the nudge only needs the first, the capture // worklist needs the second or it anchors notes to the wrong file. -const API_ARG_RE = - /(?:readFileSync|readFile|createReadStream|readTextFile|writeFileSync|appendFileSync|writeFile|writeTextFile|read_text|write_text|file_get_contents|file_put_contents|fopen|open|load|ReadFile|WriteFile|readString|readAllLines|readAllBytes|readText|writeText|File\.(?:read|readlines|write|foreach)|IO\.read)\s*\(\s*(['"`])([^'"`\n]+)\1/g; +const OPEN_CALL_RE = + /(readFileSync|readFile|createReadStream|readTextFile|writeFileSync|appendFileSync|writeFile|writeTextFile|read_text|write_text|file_get_contents|file_put_contents|fopen|open|load|ReadFile|WriteFile|readString|readAllLines|readAllBytes|readText|writeText|File\.(?:read|readlines|write|foreach)|IO\.read)\s*\(\s*/g; +const WRITE_CALL_NAMES = /^(?:writeFileSync|appendFileSync|writeFile|writeTextFile|write_text|file_put_contents|WriteFile|writeText|File\.write)$/; + +/** The file each open call in an inline script names, with that call's OWN tier. + * + * The path is a quoted literal, OR a bare identifier resolved ONE hop to the last + * literal assignment before the call (`p='a.json'` then `open(p)`) — the shape + * newer models write, 38% of inline opens. That is data flow of a single + * assignment, not a guess. Loops, argv, f-strings, globs and reassignment from a + * computed value stay unattributed: silence is the safe failure. + * + * Tier is per call, not per command: a script that reads a.json and writes b.json + * reads one and edits the other. */ +function inlineCalls(body) { + const out = []; + for (const m of body.matchAll(OPEN_CALL_RE)) { + const rest = body.slice(m.index + m[0].length); + let path = null; + let after = ""; + const lit = rest.match(/^(['"`])([^'"`\n]+)\1/); + if (lit) { + path = lit[2]; + after = rest.slice(lit[0].length); + } else { + const id = rest.match(/^([A-Za-z_]\w*)(?=\s*[,)])/); + if (id) { + const asg = new RegExp(`(?:^|[\\s;(,])(?:const |let |var )?${id[1]}\\s*=\\s*(['"\`])([^'"\`\\n]+)\\1`, "g"); + let last = null; + for (const a of body.slice(0, m.index).matchAll(asg)) last = a; + if (last) { path = last[2]; after = rest.slice(id[0].length); } + } + } + if (!path || path.includes("${")) continue; + const opensWithMode = m[1] === "open" || m[1] === "fopen"; + const mode = opensWithMode ? after.match(/^\s*,\s*(?:mode\s*=\s*)?(['"])([^'"]*)\1/) : null; + const write = WRITE_CALL_NAMES.test(m[1]) || (mode !== null && /^[wax]/.test(mode[2])); + out.push({ path, tier: write ? TIER.edit : TIER.read }); + } + return out; +} /** Files a shell fragment REDIRECTS into. `cat > x < m[2]); - for (const t of targets.slice(0, 12)) out.push({ token: t, tier: scriptTier }); + // A path from argv, a loop or a glob targets nothing we can name, so it + // attributes nothing — correct, not a gap. A variable holding a literal IS + // nameable; inlineCalls resolves that one hop. + const calls = inlineCalls(whole).slice(0, 12); + const targets = calls.map((c) => c.path); + for (const c of calls) out.push({ token: c.path, tier: c.tier }); let sn = 0; for (const m of whole.matchAll(BASH_PATH_RE)) { if (++sn > 12) break; diff --git a/tests/cochange-nudge.test.ts b/tests/cochange-nudge.test.ts index 4d83d6b..9350ebd 100644 --- a/tests/cochange-nudge.test.ts +++ b/tests/cochange-nudge.test.ts @@ -185,10 +185,22 @@ describe('co-change edit nudge', () => { expect(shellEdit('sed -i "" "s/x/y/" src/a.ts')).toBeNull(); }); + it('a write through a variable holding a literal path is an edit of THAT file (#176)', () => { + sidecar({ 'src/a.ts': [['src/c.ts', 4]], 'src/b.ts': [['src/c.ts', 3]] }, { 'src/a.ts': 5, 'src/b.ts': 5 }); + expect(shellEdit('sed -i "" "s/x/y/" src/a.ts')).toBeNull(); + expect(shellEdit(`node -e 'const f="src/b.ts"; writeFileSync(f, s)'`)).toContain('src/c.ts'); + }); + it('a write to a COMPUTED path claims nothing rather than guessing', () => { sidecar({ 'src/a.ts': [['src/c.ts', 4]], 'src/b.ts': [['src/c.ts', 3]] }, { 'src/a.ts': 5, 'src/b.ts': 5 }); expect(shellEdit('sed -i "" "s/x/y/" src/a.ts')).toBeNull(); - // src/b.ts appears only as a string the script reads into a variable. - expect(shellEdit(`node -e 'const f="src/b.ts"; writeFileSync(f, s)'`)).toBeNull(); + // The write target is built at runtime; src/b.ts is only a prefix in the script. + expect(shellEdit(`node -e 'const f="src/b.ts"; const o=dir+"/out"; writeFileSync(o, readFileSync(f))'`)).toBeNull(); + }); + + it('a file the script only READS into a variable is never claimed as an edit', () => { + sidecar({ 'src/a.ts': [['src/c.ts', 4]], 'src/b.ts': [['src/c.ts', 3]] }, { 'src/a.ts': 5, 'src/b.ts': 5 }); + expect(shellEdit('sed -i "" "s/x/y/" src/a.ts')).toBeNull(); + expect(shellEdit(`python3 -c "f='src/b.ts'; s=open(f).read(); open(out,'w').write(s)"`)).toBeNull(); }); }); diff --git a/tests/shell-read-evidence.test.ts b/tests/shell-read-evidence.test.ts index 7d19cb1..09da03a 100644 --- a/tests/shell-read-evidence.test.ts +++ b/tests/shell-read-evidence.test.ts @@ -32,7 +32,7 @@ let root: string; beforeAll(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'coldstart-shellread-')); fs.mkdirSync(path.join(root, 'src'), { recursive: true }); - for (const f of ['src/a.ts', 'src/b.ts', 'src/c.ts', 'package.json', 'lock.json', 'out.json']) + for (const f of ['src/a.ts', 'src/b.ts', 'src/c.ts', 'package.json', 'lock.json', 'out.json', 'app.json']) fs.writeFileSync(path.join(root, f), '{}\n'); }); afterAll(() => fs.rmSync(root, { recursive: true, force: true })); @@ -117,6 +117,42 @@ describe('attribution — which file did the script actually name (capture)', () expect(tiers('grep -rn TIER src/b.ts')['src/b.ts']).toBe('mention'); }); + // #176 — the shape newer models write: `p='x.json'` then `open(p)`. + it('a variable holding a literal path is resolved one hop', () => { + const t = tiers(`python3 - <<'E'\nimport json\np='src/a.ts'\ns=open(p).read()\nopen(p,'w').write(s)\nE`); + expect(t['src/a.ts']).toBe('edit'); + }); + + it('a reassigned variable resolves to the LAST assignment before each call', () => { + const t = tiers(`python3 - <<'E'\np='src/a.ts'\ns=open(p).read()\np='src/b.ts'\nopen(p,'w').write(s)\nE`); + expect(t['src/a.ts']).toBe('read'); + expect(t['src/b.ts']).toBe('edit'); + }); + + it('JS const + readFileSync(p) resolves too', () => { + const t = tiers(`node - <<'EOF'\nimport { readFileSync } from 'node:fs'\nconst p = 'lock.json'\nconsole.log(readFileSync(p, 'utf8'))\nEOF`); + expect(t['lock.json']).toBe('read'); + }); + + it('CONTROL — loops, argv and computed paths still attribute nothing', () => { + expect(tiers(`python3 - <<'E'\nfor p in ['src/a.ts']:\n open(p).read()\nE`)['src/a.ts']).toBe('mention'); + expect(tiers(`python3 - <<'E'\np=sys.argv[1]\nopen(p).read()\nE`)['src/a.ts']).toBeUndefined(); + expect(tiers(`python3 - <<'E'\np='src/'+name\nopen(p).read()\nE`)['src/a.ts']).toBeUndefined(); + }); + + it('a read-only open is a read even when the path starts with w, a or x', () => { + // WRITE_API_RE used to match the quote + first letter of the path: `'a`. + expect(tiers(`python3 -c "print(open('app.json').read())"`)['app.json']).toBe('read'); + expect(tiers(`python3 -c "print(open('app.json', 'r').read())"`)['app.json']).toBe('read'); + expect(tiers(`python3 -c "open('out.json', mode='a').write('x')"`)['out.json']).toBe('edit'); + }); + + it('a script that reads one file and writes another tiers each on its own', () => { + const t = tiers(`python3 - <<'E'\ns=open('src/a.ts').read()\nopen('out.json','w').write(s)\nE`); + expect(t['src/a.ts']).toBe('read'); + expect(t['out.json']).toBe('edit'); + }); + it('a script body full of ; and | is not shattered into shell segments', () => { // The old splitter cut on the script's OWN punctuation, so the fragment // holding the path stopped being the fragment holding readFileSync.