fix(story-mode): a file a python or node script rewrites counts as changed

From review round 5: Claude rewrote textstats.py, the change the user
asked for, through `python3 - <<EOF ... open(p, "w").write(s)`, and
the page listed only the two files the same command wrote with `cat >`.
A script fed to python or node by a here-document is now read for the
files it opens for writing (`open(target, 'w'|'a'|...)`, `Path(...)
.write_text`, `writeFileSync`), with the target a string literal or a
name bound to one (`p = "textstats.py"`); anything computed is left
out, never guessed, and scratch targets are skipped. They are edits
without lines, like the files Claude Code says a command modified,
since what the script changed is not in the command.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 13:18:13 +02:00
1 parent b220d6d6d7
commit c36700a9c0
2 files changed
+84 -4

No files matched your search

+50 -2
View File
@@ -566,8 +566,9 @@ function judgeBashCommand(command: string): { intent: StoryIntent; segment: stri
* Files a command writes through here-documents: `cat > f <<'EOF'`, `cat <<EOF > f`, `tee f <<EOF`
* (and `>>` / `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 - <<EOF`) writes nothing.
* scratch targets (`/tmp/x`, `"$S/x"`) are not project files. A script fed to python or node
* (`python3 - <<EOF`) is read for the files it opens for writing; those are edits without lines,
* since what the script changed is not in the command.
*/
export function heredocWrites(command: string, cwd?: string): StoryEdit[] {
const out: StoryEdit[] = [...echoWrites(command, cwd)];
@@ -600,6 +601,18 @@ export function heredocWrites(command: string, cwd?: string): StoryEdit[] {
append = !!tee[1];
}
}
if (!target && /\b(python3?|node)\b/.test(before)) {
for (const written of scriptWrites(body)) {
if (SCRATCH_TARGET.test(written) || out.some((d) => 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<string, string>();
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.
+34 -2
View File
@@ -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', () => {