pi-shorthand 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/display.ts +205 -0
- package/index.ts +261 -0
- package/overlay-linux.ts +80 -0
- package/overlay-macos.ts +277 -0
- package/package.json +82 -0
- package/prelude.ts +243 -0
- package/runner.ts +451 -0
- package/skills/shorthand/SKILL.md +20 -0
- package/skills/shorthand/ast-grep.md +75 -0
- package/skills/shorthand/gritql.md +20 -0
- package/skills/shorthand/writing.md +65 -0
package/runner.ts
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs a Bun program against a git repository at its real path. The program's writes go to a
|
|
3
|
+
* copy-on-write overlay; afterwards they're diffed, then applied to the repository or discarded.
|
|
4
|
+
*
|
|
5
|
+
* Usage: echo '<RunOptions as JSON>' | bun runner.ts → prints a RunResult as JSON
|
|
6
|
+
* SIGTERM aborts: the program is killed, the overlay is closed and nothing is applied.
|
|
7
|
+
* Each step is logged to ~/.cache/pi-shorthand/runs.jsonl, so `tail -f` shows what a run is doing.
|
|
8
|
+
*
|
|
9
|
+
* If the program fails:
|
|
10
|
+
* - rollback "all": nothing is applied;
|
|
11
|
+
* - rollback "file": every file it finished writing is applied. Files it (or a subprocess) still
|
|
12
|
+
* had open for writing when it was killed may be half-written, so they're rolled back.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
16
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
17
|
+
import * as fs from "node:fs/promises";
|
|
18
|
+
import { homedir, tmpdir } from "node:os";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { Lang, parse } from "@ast-grep/napi";
|
|
21
|
+
import { $ } from "bun";
|
|
22
|
+
import { structuredPatch } from "diff";
|
|
23
|
+
import { openLinuxOverlay } from "./overlay-linux.ts";
|
|
24
|
+
import { openMacOverlay } from "./overlay-macos.ts";
|
|
25
|
+
|
|
26
|
+
export interface RunOptions {
|
|
27
|
+
runId: string; // identifies this run's events in the log
|
|
28
|
+
cwd: string;
|
|
29
|
+
program: string;
|
|
30
|
+
timeoutMs: number;
|
|
31
|
+
rollback: "all" | "file";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RunResult {
|
|
35
|
+
exitCode: number | null; // null if it was killed
|
|
36
|
+
timedOut: boolean;
|
|
37
|
+
durationMs: number;
|
|
38
|
+
output: string; // stdout and stderr
|
|
39
|
+
warnings: string[]; // likely mistakes spotted in the program before it ran
|
|
40
|
+
changes: FileChange[]; // everything the program changed
|
|
41
|
+
applied: string[]; // the changed files that were applied
|
|
42
|
+
rolledBack: string[]; // rollback "file": changed files left half-written, so not applied
|
|
43
|
+
stillRunning: string[]; // on timeout: commands the program was still running, e.g. "find / -name x (for 58s)"
|
|
44
|
+
lastStep?: string; // on timeout: the last step the program logged, e.g. "$ find / -name x" or "grep (18 ms)"
|
|
45
|
+
errorLine?: string; // on failure: the program's line the error came from, e.g. "line 3: throw new Error(…)"
|
|
46
|
+
timeoutMs: number;
|
|
47
|
+
rollback: RunOptions["rollback"];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface FileChange {
|
|
51
|
+
path: string; // relative to the tool's working directory
|
|
52
|
+
kind: "added" | "modified" | "deleted";
|
|
53
|
+
patch: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A copy-on-write view of the repository at its real path. */
|
|
57
|
+
export interface Overlay {
|
|
58
|
+
originalDir: string; // where the original files are while the overlay is open
|
|
59
|
+
writableDir: string; // writing a file here puts it into the overlay
|
|
60
|
+
gitExcludes: string[]; // extra patterns git should ignore inside the overlay
|
|
61
|
+
wrap(command: string[], cwd: string): string[]; // makes a command run inside the overlay
|
|
62
|
+
changes(): Promise<{ file: string; contents: Uint8Array | null }[]>; // may include files only read
|
|
63
|
+
close(): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A changed file, relative to the repository root, with its new contents (null if deleted). */
|
|
67
|
+
interface Change {
|
|
68
|
+
file: string;
|
|
69
|
+
before: Uint8Array | null;
|
|
70
|
+
after: Uint8Array | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const PROGRAM_FILE = ".pi-shorthand-program.ts";
|
|
74
|
+
const PRELUDE = path.join(import.meta.dir, "prelude.ts");
|
|
75
|
+
// Our dependencies are in the nearest node_modules that has them: our own, or, when npm hoisted them
|
|
76
|
+
// (e.g. Pi's project installs), one further up. Like `npm run`, look in every one from here up.
|
|
77
|
+
const NODE_MODULES = ancestors(import.meta.dir).map((dir) => path.join(dir, "node_modules"));
|
|
78
|
+
const MAX_OUTPUT_CHARS = 1024 * 1024; // a safety cap; index.ts decides how much the model sees
|
|
79
|
+
const LOG_FILE = path.join(homedir(), ".cache", "pi-shorthand", "runs.jsonl");
|
|
80
|
+
let RUN_ID = ""; // set from RunOptions when the runner starts
|
|
81
|
+
|
|
82
|
+
/** Appends one event to the log, e.g. log("program exited", { exitCode: 0 }). */
|
|
83
|
+
function log(event: string, details: Record<string, unknown> = {}) {
|
|
84
|
+
appendFileSync(LOG_FILE, `${JSON.stringify({ time: new Date().toISOString(), run: RUN_ID, event, ...details })}\n`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function run(options: RunOptions, abort: AbortSignal): Promise<RunResult> {
|
|
88
|
+
const startedAt = performance.now();
|
|
89
|
+
const cwd = await fs.realpath(options.cwd);
|
|
90
|
+
const repo = await findRepository(cwd);
|
|
91
|
+
const tempDir = await fs.mkdtemp(path.join(await fs.realpath(tmpdir()), "pi-shorthand-"));
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
log("started", { repo, cwd, timeoutMs: options.timeoutMs, rollback: options.rollback });
|
|
95
|
+
const open = process.platform === "darwin" ? openMacOverlay : openLinuxOverlay;
|
|
96
|
+
const overlay = await open(repo, tempDir);
|
|
97
|
+
log("overlay opened");
|
|
98
|
+
let program: ProgramRun;
|
|
99
|
+
let changes: Change[];
|
|
100
|
+
try {
|
|
101
|
+
program = await runProgram({ ...options, cwd }, abort, overlay, repo, tempDir);
|
|
102
|
+
changes = await findChanges(overlay);
|
|
103
|
+
} finally {
|
|
104
|
+
await overlay.close();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const { applied, rolledBack } = whatToApply(changes, program, options.rollback, abort.aborted);
|
|
108
|
+
await applyChanges(repo, applied);
|
|
109
|
+
log("finished", { changed: changes.map((change) => change.file), applied: applied.map((change) => change.file) });
|
|
110
|
+
|
|
111
|
+
const shown = (file: string) => path.relative(cwd, path.join(repo, file));
|
|
112
|
+
return {
|
|
113
|
+
exitCode: program.exitCode,
|
|
114
|
+
timedOut: program.timedOut,
|
|
115
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
116
|
+
output: program.output,
|
|
117
|
+
warnings: lint(options.program),
|
|
118
|
+
changes: changes.map((change) => describe(shown(change.file), change)),
|
|
119
|
+
applied: applied.map((change) => shown(change.file)),
|
|
120
|
+
rolledBack: rolledBack.map((change) => shown(change.file)),
|
|
121
|
+
stillRunning: program.stillRunning,
|
|
122
|
+
lastStep: program.timedOut ? await lastLoggedStep() : undefined,
|
|
123
|
+
errorLine: program.exitCode !== 0 ? failingLine(options.program, program.output) : undefined,
|
|
124
|
+
timeoutMs: options.timeoutMs,
|
|
125
|
+
rollback: options.rollback,
|
|
126
|
+
};
|
|
127
|
+
} finally {
|
|
128
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Nothing if aborted. Everything if the program succeeded. If it failed, nothing, unless rollback is "file". */
|
|
133
|
+
function whatToApply(changes: Change[], program: ProgramRun, rollback: RunOptions["rollback"], aborted: boolean) {
|
|
134
|
+
if (aborted) return { applied: [], rolledBack: [] };
|
|
135
|
+
if (program.exitCode === 0) return { applied: changes, rolledBack: [] };
|
|
136
|
+
if (rollback === "all") return { applied: [], rolledBack: [] };
|
|
137
|
+
|
|
138
|
+
const halfWritten = (change: Change) => program.openForWriting.includes(change.file);
|
|
139
|
+
return { applied: changes.filter((change) => !halfWritten(change)), rolledBack: changes.filter(halfWritten) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A `$` command that isn't awaited never runs: Bun's shell starts a command when it's awaited. */
|
|
143
|
+
const UNAWAITED_SHELL = {
|
|
144
|
+
rule: {
|
|
145
|
+
kind: "call_expression",
|
|
146
|
+
has: { field: "function", regex: "^\\$$" },
|
|
147
|
+
not: {
|
|
148
|
+
inside: {
|
|
149
|
+
any: [{ kind: "await_expression" }, { kind: "return_statement" }],
|
|
150
|
+
stopBy: { kind: "statement_block" },
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** Likely mistakes in the program, found without running it. */
|
|
157
|
+
function lint(program: string): string[] {
|
|
158
|
+
const root = parse(Lang.TypeScript, program).root();
|
|
159
|
+
return root.findAll(UNAWAITED_SHELL).map((node) => {
|
|
160
|
+
const line = node.range().start.line + 1;
|
|
161
|
+
return `line ${line}: ${node.text().split("\n")[0]} isn't awaited, so the command may not have run`;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function findRepository(cwd: string): Promise<string> {
|
|
166
|
+
const result = await $`git rev-parse --show-toplevel`.cwd(cwd).nothrow().quiet();
|
|
167
|
+
if (result.exitCode !== 0) throw new Error("The code tool only works inside a git repository.");
|
|
168
|
+
return fs.realpath(result.text().trim());
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Running the program ───────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
interface ProgramRun {
|
|
174
|
+
exitCode: number | null;
|
|
175
|
+
timedOut: boolean;
|
|
176
|
+
output: string;
|
|
177
|
+
openForWriting: string[]; // on timeout: files it still had open for writing
|
|
178
|
+
stillRunning: string[]; // on timeout: the commands it was still running
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function runProgram(
|
|
182
|
+
options: RunOptions,
|
|
183
|
+
abort: AbortSignal,
|
|
184
|
+
overlay: Overlay,
|
|
185
|
+
repo: string,
|
|
186
|
+
tempDir: string,
|
|
187
|
+
): Promise<ProgramRun> {
|
|
188
|
+
// The program file goes into the working directory, so its relative imports resolve as usual.
|
|
189
|
+
const programPath = path.join(options.cwd, PROGRAM_FILE);
|
|
190
|
+
const programFile = path.join(overlay.writableDir, path.relative(repo, programPath));
|
|
191
|
+
await Bun.write(programFile, options.program);
|
|
192
|
+
|
|
193
|
+
const excludesFile = path.join(tempDir, "exclude");
|
|
194
|
+
await Bun.write(excludesFile, [PROGRAM_FILE, ...overlay.gitExcludes, await globalGitExcludes()].join("\n"));
|
|
195
|
+
|
|
196
|
+
// Output goes to a file rather than a pipe, so a process the program leaves running can't hold it open.
|
|
197
|
+
// detached: the program gets its own process group, so killing the group kills anything it started too.
|
|
198
|
+
const outputFile = path.join(tempDir, "output");
|
|
199
|
+
const output = await fs.open(outputFile, "w");
|
|
200
|
+
const [command, ...args] = overlay.wrap([process.execPath, "--preload", PRELUDE, programPath], options.cwd);
|
|
201
|
+
log("program started", { output: outputFile, timeoutMs: options.timeoutMs });
|
|
202
|
+
const child = spawn(command, args, {
|
|
203
|
+
cwd: options.cwd,
|
|
204
|
+
detached: true,
|
|
205
|
+
stdio: ["ignore", output.fd, output.fd],
|
|
206
|
+
env: { ...process.env, ...programEnvironment(excludesFile) },
|
|
207
|
+
});
|
|
208
|
+
const killAll = () => killGroup(child);
|
|
209
|
+
abort.addEventListener("abort", killAll);
|
|
210
|
+
|
|
211
|
+
const { exitCode, timedOut, openForWriting, stillRunning } = await waitWithTimeout(child, options.timeoutMs, repo);
|
|
212
|
+
log("program exited", { exitCode, timedOut, aborted: abort.aborted, stillRunning });
|
|
213
|
+
killAll(); // anything it left running
|
|
214
|
+
abort.removeEventListener("abort", killAll);
|
|
215
|
+
await output.close();
|
|
216
|
+
await fs.rm(programFile, { force: true });
|
|
217
|
+
|
|
218
|
+
// Keep the tail, where errors are. Show stack traces as "program.ts:3:11", and drop Bun's version footer.
|
|
219
|
+
let text = await Bun.file(outputFile).text();
|
|
220
|
+
if (text.length > MAX_OUTPUT_CHARS) {
|
|
221
|
+
text = `[${text.length - MAX_OUTPUT_CHARS} earlier characters dropped]\n${text.slice(-MAX_OUTPUT_CHARS)}`;
|
|
222
|
+
}
|
|
223
|
+
text = text.replaceAll(programPath, "program.ts").replace(/\nBun v[\d.]+ \([^)]*\)\n?$/, "\n");
|
|
224
|
+
|
|
225
|
+
return { exitCode, timedOut, output: text, openForWriting, stillRunning };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Waits for the program to exit, or kills it after timeoutMs. Before killing it, notes which files
|
|
230
|
+
* it (or anything it started) still has open for writing, since they may be half-written, and which
|
|
231
|
+
* commands it was still running, since one of them is probably why it timed out.
|
|
232
|
+
*/
|
|
233
|
+
async function waitWithTimeout(child: ChildProcess, timeoutMs: number, repo: string) {
|
|
234
|
+
const exited = new Promise<number | null>((resolve) => child.on("exit", (code) => resolve(code)));
|
|
235
|
+
let timer: Timer | undefined;
|
|
236
|
+
const timeout = new Promise<"timeout">((resolve) => {
|
|
237
|
+
timer = setTimeout(() => resolve("timeout"), timeoutMs);
|
|
238
|
+
});
|
|
239
|
+
const winner = await Promise.race([exited, timeout]);
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
if (winner !== "timeout") return { exitCode: winner, timedOut: false, openForWriting: [], stillRunning: [] };
|
|
242
|
+
|
|
243
|
+
const [openForWriting, stillRunning] = await Promise.all([
|
|
244
|
+
filesOpenForWriting(repo, child.pid!),
|
|
245
|
+
commandsRunning(child.pid!),
|
|
246
|
+
]);
|
|
247
|
+
killGroup(child);
|
|
248
|
+
await exited;
|
|
249
|
+
return { exitCode: null, timedOut: true, openForWriting, stillRunning };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The commands running in a process group, with how long each has been running, e.g.
|
|
254
|
+
* "find / -name x (running 58s)". Leaves out the program itself and bubblewrap, which wraps it.
|
|
255
|
+
*/
|
|
256
|
+
async function commandsRunning(processGroup: number): Promise<string[]> {
|
|
257
|
+
const pids = (await $`pgrep -g ${processGroup}`.nothrow().quiet().text()).split("\n").filter(Boolean);
|
|
258
|
+
if (pids.length === 0) return [];
|
|
259
|
+
const output = await $`ps -o etime=,command= -p ${pids.join(",")}`.nothrow().quiet().text();
|
|
260
|
+
|
|
261
|
+
const commands: string[] = [];
|
|
262
|
+
for (const line of output.split("\n")) {
|
|
263
|
+
const match = line.trim().match(/^(\S+)\s+(.+)$/);
|
|
264
|
+
if (!match) continue;
|
|
265
|
+
const [, elapsed, command] = match;
|
|
266
|
+
if (command.includes(PROGRAM_FILE) || /^\S*bwrap /.test(command)) continue;
|
|
267
|
+
commands.push(`${command.slice(0, 200)} (for ${seconds(elapsed)}s)`);
|
|
268
|
+
}
|
|
269
|
+
return commands;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The line of the program an error came from, found from Bun's "at program.ts:3:11" rather than its
|
|
274
|
+
* source excerpt, which leaves out long lines and then mislabels the ones around them.
|
|
275
|
+
*/
|
|
276
|
+
function failingLine(program: string, output: string): string | undefined {
|
|
277
|
+
const location = output.match(/\bprogram\.ts:(\d+):\d+/);
|
|
278
|
+
if (!location) return undefined;
|
|
279
|
+
const text = program.split("\n")[Number(location[1]) - 1]?.trim();
|
|
280
|
+
return text ? `line ${location[1]}: ${text}`.slice(0, 160) : undefined;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** ps's elapsed time, "[[dd-]hh:]mm:ss", in seconds. */
|
|
284
|
+
function seconds(elapsed: string): number {
|
|
285
|
+
const [days, clock] = elapsed.includes("-") ? elapsed.split("-") : ["0", elapsed];
|
|
286
|
+
const parts = clock.split(":").map(Number);
|
|
287
|
+
const [hours, minutes, secs] = [0, 0, ...parts].slice(-3);
|
|
288
|
+
return Number(days) * 86400 + hours * 3600 + minutes * 60 + secs;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The last command or helper call this run's program logged, e.g. "$ find / -name x". */
|
|
292
|
+
async function lastLoggedStep(): Promise<string | undefined> {
|
|
293
|
+
const lines = (await Bun.file(LOG_FILE).text()).trimEnd().split("\n").slice(-500);
|
|
294
|
+
for (const line of lines.toReversed()) {
|
|
295
|
+
const event = JSON.parse(line);
|
|
296
|
+
if (event.run !== RUN_ID) continue;
|
|
297
|
+
if (event.event === "command") return `$ ${event.command}`;
|
|
298
|
+
if (event.event === "helper") return `${event.helper}(${event.args.slice(1, -1)}) (${event.ms} ms)`;
|
|
299
|
+
}
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function killGroup(child: ChildProcess) {
|
|
304
|
+
try {
|
|
305
|
+
process.kill(-child.pid!, "SIGKILL");
|
|
306
|
+
} catch {
|
|
307
|
+
// already gone
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Adds our git excludes on top of any GIT_CONFIG_* the user already set. core.excludesFile
|
|
313
|
+
* replaces the user's global excludes file, so globalGitExcludes() copies that file's patterns in.
|
|
314
|
+
*/
|
|
315
|
+
function programEnvironment(excludesFile: string) {
|
|
316
|
+
const count = Number(process.env.GIT_CONFIG_COUNT ?? 0);
|
|
317
|
+
return {
|
|
318
|
+
PATH: [...NODE_MODULES.map((dir) => path.join(dir, ".bin")), process.env.PATH].join(path.delimiter),
|
|
319
|
+
NO_COLOR: "1",
|
|
320
|
+
// So programs can import the extension's own packages, e.g. "@ast-grep/napi". A repository's own
|
|
321
|
+
// node_modules still wins: NODE_PATH is only a fallback.
|
|
322
|
+
NODE_PATH: [...NODE_MODULES, process.env.NODE_PATH].filter(Boolean).join(path.delimiter),
|
|
323
|
+
PI_SHORTHAND_LOG: LOG_FILE, // the prelude logs each command and helper call here
|
|
324
|
+
PI_SHORTHAND_RUN: RUN_ID,
|
|
325
|
+
GIT_OPTIONAL_LOCKS: "0", // on macOS .git is the real one: don't let `git status` write to it
|
|
326
|
+
GIT_CONFIG_COUNT: String(count + 1),
|
|
327
|
+
[`GIT_CONFIG_KEY_${count}`]: "core.excludesFile",
|
|
328
|
+
[`GIT_CONFIG_VALUE_${count}`]: excludesFile,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The patterns in the user's global git excludes file, if they have one. */
|
|
333
|
+
async function globalGitExcludes(): Promise<string> {
|
|
334
|
+
const configured = (await $`git config --global --path --get core.excludesFile`.nothrow().quiet().text()).trim();
|
|
335
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config");
|
|
336
|
+
const file = Bun.file(configured || path.join(xdgConfig, "git", "ignore"));
|
|
337
|
+
return (await file.exists()) ? file.text() : "";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Files under dir that any process in a process group has open for writing, relative to dir. */
|
|
341
|
+
async function filesOpenForWriting(dir: string, processGroup: number): Promise<string[]> {
|
|
342
|
+
// -F an: one field per line. "a" is the access mode (r, w, or u for read/write), "n" the file name.
|
|
343
|
+
const output = await $`lsof -n -P -F an -g ${processGroup}`.nothrow().quiet().text();
|
|
344
|
+
|
|
345
|
+
const files: string[] = [];
|
|
346
|
+
let access = "";
|
|
347
|
+
for (const line of output.split("\n")) {
|
|
348
|
+
if (line.startsWith("a")) access = line.slice(1);
|
|
349
|
+
if (line.startsWith(`n${dir}/`) && access !== "r") files.push(path.relative(dir, line.slice(1)));
|
|
350
|
+
}
|
|
351
|
+
return files;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── Finding, describing and applying changes ──────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
/** Compares what the overlay reports with the originals. Only files git sees count. */
|
|
357
|
+
async function findChanges(overlay: Overlay): Promise<Change[]> {
|
|
358
|
+
const candidates = (await overlay.changes()).filter(({ file }) => path.basename(file) !== PROGRAM_FILE);
|
|
359
|
+
const ignored = await gitIgnored(
|
|
360
|
+
overlay.originalDir,
|
|
361
|
+
candidates.map(({ file }) => file),
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const changes: Change[] = [];
|
|
365
|
+
const seen = new Set<string>(); // an overlay may report a file twice, e.g. a deleted directory and the files in it
|
|
366
|
+
for (const { file, contents: after } of candidates) {
|
|
367
|
+
if (ignored.has(file) || seen.has(file)) continue;
|
|
368
|
+
seen.add(file);
|
|
369
|
+
const before = await readFile(path.join(overlay.originalDir, file));
|
|
370
|
+
if (!before && !after) continue;
|
|
371
|
+
if (before && after && Buffer.from(before).equals(after)) continue; // read, not changed
|
|
372
|
+
changes.push({ file, before, after });
|
|
373
|
+
}
|
|
374
|
+
return changes.toSorted((a, b) => a.file.localeCompare(b.file));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function gitIgnored(dir: string, files: string[]): Promise<Set<string>> {
|
|
378
|
+
if (files.length === 0) return new Set();
|
|
379
|
+
const input = new Response(`${files.join("\0")}\0`);
|
|
380
|
+
const output = await $`git check-ignore -z --stdin < ${input}`.cwd(dir).nothrow().quiet().text();
|
|
381
|
+
return new Set(output.split("\0").filter(Boolean));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function applyChanges(repo: string, changes: Change[]) {
|
|
385
|
+
for (const { file, after } of changes) {
|
|
386
|
+
const target = path.join(repo, file);
|
|
387
|
+
if (!after) {
|
|
388
|
+
await fs.rm(target, { force: true });
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
// Write next to the target, then rename, so each file is replaced atomically.
|
|
392
|
+
const temp = `${target}.pi-shorthand.tmp`;
|
|
393
|
+
await Bun.write(temp, after);
|
|
394
|
+
const original = await fs.stat(target).catch(() => null);
|
|
395
|
+
if (original) await fs.chmod(temp, original.mode);
|
|
396
|
+
await fs.rename(temp, target);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** A change as a git-style patch. */
|
|
401
|
+
function describe(file: string, { before, after }: Change): FileChange {
|
|
402
|
+
const kind = !before ? "added" : !after ? "deleted" : "modified";
|
|
403
|
+
const lines = [`diff --git a/${file} b/${file}`];
|
|
404
|
+
if (kind !== "modified") lines.push(`${kind === "added" ? "new" : "deleted"} file`);
|
|
405
|
+
|
|
406
|
+
const oldBytes = before ?? new Uint8Array();
|
|
407
|
+
const newBytes = after ?? new Uint8Array();
|
|
408
|
+
if (isBinary(oldBytes) || isBinary(newBytes)) {
|
|
409
|
+
lines.push("Binary file changed");
|
|
410
|
+
return { path: file, kind, patch: lines.join("\n") };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
lines.push(kind === "added" ? "--- /dev/null" : `--- a/${file}`);
|
|
414
|
+
lines.push(kind === "deleted" ? "+++ /dev/null" : `+++ b/${file}`);
|
|
415
|
+
const oldText = new TextDecoder().decode(oldBytes);
|
|
416
|
+
const newText = new TextDecoder().decode(newBytes);
|
|
417
|
+
for (const hunk of structuredPatch(file, file, oldText, newText, "", "", { context: 3 }).hunks) {
|
|
418
|
+
lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`, ...hunk.lines);
|
|
419
|
+
}
|
|
420
|
+
return { path: file, kind, patch: lines.join("\n") };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function isBinary(bytes: Uint8Array): boolean {
|
|
424
|
+
return bytes.subarray(0, 8000).includes(0);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** dir and each directory above it, up to the root. */
|
|
428
|
+
function ancestors(dir: string): string[] {
|
|
429
|
+
const parent = path.dirname(dir);
|
|
430
|
+
return parent === dir ? [dir] : [dir, ...ancestors(parent)];
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** A regular file's contents, or null if there's no regular file there. */
|
|
434
|
+
async function readFile(file: string): Promise<Uint8Array | null> {
|
|
435
|
+
const stats = await fs.lstat(file).catch(() => null);
|
|
436
|
+
return stats?.isFile() ? Bun.file(file).bytes() : null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (import.meta.main) {
|
|
440
|
+
mkdirSync(path.dirname(LOG_FILE), { recursive: true });
|
|
441
|
+
const abort = new AbortController();
|
|
442
|
+
process.on("SIGTERM", () => abort.abort());
|
|
443
|
+
const options: RunOptions = await Bun.stdin.json();
|
|
444
|
+
RUN_ID = options.runId;
|
|
445
|
+
try {
|
|
446
|
+
console.log(JSON.stringify(await run(options, abort.signal)));
|
|
447
|
+
} catch (error) {
|
|
448
|
+
log("failed", { error: String(error) });
|
|
449
|
+
throw error;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: shorthand
|
|
3
|
+
description: How to write a whole change as one code program, with Bun's file and shell APIs, checks, and ast-grep or GritQL rewrites. Use when a code program edits several files, rewrites code by its structure, or checks its own change.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Shorthand
|
|
7
|
+
|
|
8
|
+
A `code` program is a transaction: Bun runs it against the repository, and its writes are applied only
|
|
9
|
+
if it exits successfully. So write the whole change as one program: find what to change, change it,
|
|
10
|
+
check it, and print a short summary.
|
|
11
|
+
|
|
12
|
+
- [writing.md](writing.md): the everyday part. The environment, Bun's file and shell APIs, and the
|
|
13
|
+
shape of a program that edits and checks many files.
|
|
14
|
+
- [ast-grep.md](ast-grep.md): when a text replace isn't safe. Structural search and rewrite, rule
|
|
15
|
+
objects, and ast-grep's own API.
|
|
16
|
+
- [gritql.md](gritql.md): GritQL rewrites, including other languages.
|
|
17
|
+
|
|
18
|
+
A failed program changes nothing (unless you passed `rollback: "file"`), so fix it and run it again
|
|
19
|
+
without asking. It's done when its own checks pass (no leftover matches, and `tsc` or the relevant
|
|
20
|
+
tests where the change could break them) and the diff shows what you meant.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# ast-grep patterns
|
|
2
|
+
|
|
3
|
+
A pattern is ordinary code with metavariables. It has to parse on its own, so match a whole
|
|
4
|
+
expression or statement (`oldApi($$$A)`, `const $X = $Y`), not a fragment.
|
|
5
|
+
|
|
6
|
+
- `$X` matches one node and captures its text, as `m.X` (or `m.vars.X`).
|
|
7
|
+
- `$$$X` matches zero or more nodes, e.g. all the arguments, keeping the original text, commas
|
|
8
|
+
included.
|
|
9
|
+
- `$_` matches one node without capturing it.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
sg.find("oldApi($$$ARGS)", "src"); // [{ file, line, text, vars, node }]
|
|
13
|
+
sg.rewrite("oldApi($$$ARGS)", "newApi($$$ARGS)", "src"); // template
|
|
14
|
+
sg.rewrite("oldApi($A)", (m) => m.A !== "0" && `newApi(${m.A})`, "src"); // function
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
A function returns the new text; returning anything else (`undefined`, `null`, `false`) leaves that
|
|
18
|
+
match alone.
|
|
19
|
+
|
|
20
|
+
## Renaming a name
|
|
21
|
+
|
|
22
|
+
A bare name as the pattern (`sg.rewrite("oldName", "newName")`) only matches plain identifiers, not
|
|
23
|
+
property names (`obj.oldName`, `{ oldName: 1 }`, interface fields). To rename a name everywhere it
|
|
24
|
+
appears in code (but not in strings), match it by kind:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
const anyName = [
|
|
28
|
+
"identifier",
|
|
29
|
+
"property_identifier",
|
|
30
|
+
"shorthand_property_identifier",
|
|
31
|
+
"shorthand_property_identifier_pattern",
|
|
32
|
+
"type_identifier",
|
|
33
|
+
];
|
|
34
|
+
sg.rewrite({ rule: { regex: "^oldName$", any: anyName.map((kind) => ({ kind })) } }, "newName", "src");
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Rule objects
|
|
38
|
+
|
|
39
|
+
When a pattern alone can't say it, pass a rule instead:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
sg.find({ rule: { kind: "import_statement" } }, "src");
|
|
43
|
+
sg.find({ rule: { pattern: "console.log($$$A)", inside: { kind: "function_declaration", stopBy: "end" } } }, "src");
|
|
44
|
+
sg.find(
|
|
45
|
+
{ rule: { pattern: "console.log($$$A)", not: { inside: { kind: "function_declaration", stopBy: "end" } } } },
|
|
46
|
+
"src",
|
|
47
|
+
);
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`kind` names come from tree-sitter (`function_declaration`, `call_expression`, `import_statement`,
|
|
51
|
+
…). `stopBy: "end"` searches all ancestors, not just the parent.
|
|
52
|
+
|
|
53
|
+
## ast-grep's own API
|
|
54
|
+
|
|
55
|
+
When an edit depends on context a pattern can't express, use ast-grep's JavaScript API directly.
|
|
56
|
+
It's on `sg` under its usual names, and `import { parse, Lang } from "@ast-grep/napi"` works too:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
const file = "src/app.ts";
|
|
60
|
+
const root = sg.parse(sg.Lang.TypeScript, await Bun.file(file).text()).root();
|
|
61
|
+
const edits = root.findAll("app.get($PATH, $$$HANDLERS)").map((node) => {
|
|
62
|
+
const path = node.getMatch("PATH")!; // edit just the captured node
|
|
63
|
+
return path.replace(path.text().toLowerCase());
|
|
64
|
+
});
|
|
65
|
+
await Bun.write(file, root.commitEdits(edits));
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Other languages
|
|
69
|
+
|
|
70
|
+
`sg.find` and `sg.rewrite` handle JS, TS, TSX, HTML and CSS. For anything else, use the CLI:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
const matches = await $`ast-grep run -p 'print($A)' -l python --json=compact src`.json();
|
|
74
|
+
await $`ast-grep run -p 'print($A)' -r 'log($A)' -l python -U src`; // -U applies the rewrite
|
|
75
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# GritQL patterns
|
|
2
|
+
|
|
3
|
+
Code goes in backticks. `$x` captures a node, `=>` rewrites, and `where` adds conditions.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
grit("`oldApi($args)`", "src", { dryRun: true }); // find: [{ file, matches }]
|
|
7
|
+
grit("`console.log($m)` => `logger.info($m)`", "src"); // rewrite in place
|
|
8
|
+
grit("`$f($x)` where { $f <: `oldApi` }", "src", { dryRun: true }); // conditions
|
|
9
|
+
grit("`print($x)` => `log($x)`", "src", { lang: "python" }); // other languages
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
It defaults to JavaScript/TypeScript. Other languages include python, go, rust, java, ruby, css,
|
|
13
|
+
json and yaml.
|
|
14
|
+
|
|
15
|
+
Worth knowing:
|
|
16
|
+
|
|
17
|
+
- Each `grit` call takes about a second to start, so pass a longer `timeout` to the code tool when
|
|
18
|
+
a program calls it.
|
|
19
|
+
- A rewrite can drop a statement's trailing semicolon. Check the diff, or use `sg.rewrite` for
|
|
20
|
+
simple JS/TS rewrites.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Writing a shorthand program
|
|
2
|
+
|
|
3
|
+
## The environment
|
|
4
|
+
|
|
5
|
+
TypeScript, run by Bun in the repository's working directory, at its usual path. Top-level `await`
|
|
6
|
+
works. `glob`, `grep`, `sg` and `grit` are globals and synchronous (no `await`); Bun's `$` is async.
|
|
7
|
+
|
|
8
|
+
## Files
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
const source = await Bun.file("src/app.ts").text(); // .json() for JSON
|
|
12
|
+
await Bun.write("src/app.ts", source.replace("a", "b")); // creates missing directories
|
|
13
|
+
await Bun.file("src/old.ts").exists();
|
|
14
|
+
await Bun.file("src/old.ts").delete();
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`node:fs` works too, including its sync API (`readFileSync`, `writeFileSync`, `renameSync`, `rmSync`).
|
|
18
|
+
|
|
19
|
+
## Finding what to change
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
glob("src/**/*.ts"); // files git sees, sorted
|
|
23
|
+
grep("oldApi(", "src"); // [{ file, line, text }]; pass a RegExp for a pattern
|
|
24
|
+
sg.find("oldApi($$$ARGS)", "src"); // by syntax, when matching text isn't enough
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Work out the files to change here, in the program, rather than copying a list from earlier output:
|
|
28
|
+
then it can't miss one.
|
|
29
|
+
|
|
30
|
+
## Commands
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
await $`bun test src/api.test.ts`; // throws if it fails, so a failing check fails the program
|
|
34
|
+
const count = await $`git grep -c oldApi`.nothrow().text(); // .nothrow(): check the exit code yourself
|
|
35
|
+
await $`npx tsc --noEmit -p .`.quiet(); // .quiet(): keep its output out of yours
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Interpolated values become single, safely quoted arguments; an array becomes several.
|
|
39
|
+
|
|
40
|
+
## The shape of a program
|
|
41
|
+
|
|
42
|
+
1. Find what to change.
|
|
43
|
+
2. Change it: read, transform in memory, write.
|
|
44
|
+
3. Check it: look for leftovers, run the type-checker or the tests that cover it, and throw if
|
|
45
|
+
something's wrong. Then nothing is applied, and you get the error.
|
|
46
|
+
4. Print a summary (counts, anything surprising), not whole files. The diff comes back anyway.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
const files = [...new Set(grep("oldApi", "src").map((match) => match.file))];
|
|
50
|
+
for (const file of files) {
|
|
51
|
+
const source = await Bun.file(file).text();
|
|
52
|
+
await Bun.write(file, source.replaceAll("oldApi", "newApi"));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const left = grep("oldApi", "src");
|
|
56
|
+
if (left.length > 0) throw new Error(`oldApi is still used at ${left.map((m) => `${m.file}:${m.line}`).join(", ")}`);
|
|
57
|
+
await $`npx tsc --noEmit -p .`.quiet();
|
|
58
|
+
console.log(`updated ${files.length} files`);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Options
|
|
62
|
+
|
|
63
|
+
- `timeout`: 2 seconds unless you pass more. Pass more when the program runs `tsc` or tests.
|
|
64
|
+
- `rollback: "file"`: keep the files the program finished writing, even if it then fails. Useful when
|
|
65
|
+
each file's change stands on its own; otherwise keep the default, which applies nothing on failure.
|