diff --git a/src/story-mode.ts b/src/story-mode.ts index 7b6c8a63..00b7227f 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -566,8 +566,9 @@ function judgeBashCommand(command: string): { intent: StoryIntent; segment: stri * Files a command writes through here-documents: `cat > f <<'EOF'`, `cat < f`, `tee f <>` / `tee -a`, appends). Each becomes a whole-file edit of the body's lines. A relative * target resolves against the transcript line's `cwd` unless the command changed directory first; - * scratch targets (`/tmp/x`, `"$S/x"`) are not project files. A body piped into a program - * (`python3 - < d.file.endsWith(written))) continue; + const file = + written.startsWith('/') || !cwd || moved + ? written + : `${cwd.replace(/\/+$/, '')}/${written.replace(/^\.\//, '')}`; + out.push({ file, added: 0, removed: 0, created: false, hunks: [], truncated: true }); + if (out.length >= 6) break; + } + continue; + } if (!target || SCRATCH_TARGET.test(target)) continue; const file = target.startsWith('/') || !cwd || moved ? target : `${cwd.replace(/\/+$/, '')}/${target.replace(/^\.\//, '')}`; @@ -622,6 +635,41 @@ export function heredocWrites(command: string, cwd?: string): StoryEdit[] { return out; } +/** + * Files a python or node script opens for writing: `open('a.py', 'w')`, `open(p, "w")` after + * `p = "a.py"`, `Path('a.py').write_text(...)`, `fs.writeFileSync('a.js', ...)`. Only string literals + * and names bound to one; anything computed is left out rather than guessed. + */ +function scriptWrites(body: string[]): string[] { + const names = new Map(); + for (const line of body) { + const m = /^\s*(?:(?:const|let|var)\s+)?([A-Za-z_]\w*)\s*=\s*(?:Path\(\s*)?(['"])([^'"\n]+)\2\s*\)?\s*;?\s*$/.exec( + line + ); + if (m) names.set(m[1], m[3]); + } + const target = (lit: string | undefined, name: string | undefined) => lit ?? (name ? names.get(name) : undefined); + const found: Array<{ at: number; file: string }> = []; + const text = body.join('\n'); + const patterns = [ + // open(target, 'w') / open(target, mode="a+") + /\bopen\(\s*(?:(['"])([^'"\n]+)\1|([A-Za-z_]\w*))\s*,\s*(?:mode\s*=\s*)?(['"])([rwaxbt+]*)\4/g, + // Path(target).write_text( / name.write_text( + /(?:\bPath\(\s*(?:(['"])([^'"\n]+)\1|([A-Za-z_]\w*))\s*\)|\b([A-Za-z_]\w*))\.write_(?:text|bytes)\(/g, + // fs.writeFileSync(target / appendFileSync(target + /\b(?:write|append)File(?:Sync)?\(\s*(?:(['"])([^'"\n]+)\1|([A-Za-z_]\w*))/g, + ]; + for (const [k, re] of patterns.entries()) { + for (const m of text.matchAll(re)) { + if (k === 0 && !/[wax+]/.test(m[5])) continue; + const file = k === 1 ? target(m[2], m[3] ?? m[4]) : target(m[2], m[3]); + if (file && !/[*?$`{}]/.test(file)) found.push({ at: m.index ?? 0, file }); + } + } + // In the order the script writes them. + return unique(found.sort((x, y) => x.at - y.at).map((f) => f.file)).slice(0, 6); +} + /** * Whether a linter, formatter or type checker reported problems (`Found 3 errors.`, `1 file would be * reformatted`, `✖ 5 problems`, `error TS2322:`). Read when a trailing command (`; ls`) hid the exit code. diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index e7457e90..5835c28b 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -939,10 +939,42 @@ describe('buildStoryPage', () => { ), end(1000), ]; + (lines[1] as { cwd?: string }).cwd = '/w'; (lines[2] as { cwd?: string }).cwd = '/w'; const page = pageOf(lines); - expect(page.events[1].edits?.map((d) => d.file)).toEqual(['/w/test_a.py']); - expect(page.filesChanged).toBe(1); + // The script's own write and the file Claude Code says changed: both, neither with lines. + expect(page.events[1].edits?.map((d) => [d.file, d.hunks.length])).toEqual([ + ['/w/t.py', 0], + ['/w/test_a.py', 0], + ]); + expect(page.filesChanged).toBe(2); + }); + + it('a python or node script fed by a here-document: the files it opens for writing, never guessed', () => { + const files = (command: string) => heredocWrites(command, '/w').map((d) => d.file); + expect( + files( + [ + "python3 - <<'EOF'", + 'p = "textstats.py"', + 's = open(p).read()', + "s = s.replace('a', 'b')", + 'open(p, "w").write(s)', + "Path('README.md').write_text('# x')", + "with open('notes.txt', mode='a') as f: f.write('x')", + "open('/tmp/scratch.txt', 'w').write('x')", + "open(os.path.join(d, 'x.py'), 'w')", + "open('data.json').read()", + 'EOF', + ].join('\n') + ) + ).toEqual(['/w/textstats.py', '/w/README.md', '/w/notes.txt']); + expect(files("node <<'EOF'\nconst out = 'dist/app.js';\nfs.writeFileSync(out, code);\nEOF")).toEqual([ + '/w/dist/app.js', + ]); + expect(files("python3 - <<'EOF'\nprint(open('a.py').read())\nEOF")).toEqual([]); + const [edit] = heredocWrites("python3 - <<'EOF'\nopen('a.py', 'w').write('x')\nEOF", '/w'); + expect(edit).toMatchObject({ file: '/w/a.py', added: 0, removed: 0, hunks: [], truncated: true }); }); it('a compaction says what it did; ToolSearch is looking; EnterPlanMode says plan mode', () => {