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.
@@ -0,0 +1,277 @@
1
+ /**
2
+ * macOS has no mount namespaces, so for the length of a run:
3
+ * 1. the repository is renamed to <repo>.pi-base, and an empty directory takes its place;
4
+ * 2. .git and large ignored directories (node_modules, …) are moved to <repo>.pi-shared and
5
+ * symlinked back, so reading them bypasses the overlay (AgentFS copies every file it opens);
6
+ * 3. AgentFS serves an overlay of the base over NFS, mounted at the repository's path;
7
+ * 4. the program runs under sandbox-exec, which stops it writing to the base or to .git.
8
+ * close() puts everything back. <repo>.pi-shared doubles as a lock: another run on the same repository
9
+ * waits for this one, and if a run crashed part-way, the next one puts everything back first.
10
+ */
11
+
12
+ import * as fs from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import * as path from "node:path";
15
+ import { $ } from "bun";
16
+ import type { Overlay } from "./runner.ts";
17
+
18
+ interface Dirs {
19
+ repo: string;
20
+ base: string; // the real files, while the overlay is mounted
21
+ shared: string; // .git and large ignored directories, while the overlay is mounted
22
+ }
23
+
24
+ export async function openMacOverlay(repo: string, tempDir: string): Promise<Overlay> {
25
+ const agentfs = process.env.AGENTFS_BIN ?? Bun.which("agentfs");
26
+ if (!agentfs) throw new Error("The code tool needs AgentFS: curl -fsSL https://agentfs.ai/install | bash");
27
+
28
+ const dirs = { repo, base: `${repo}.pi-base`, shared: `${repo}.pi-shared` };
29
+ await takeLock(dirs);
30
+ try {
31
+ const shared = await moveAside(dirs);
32
+ const database = await createDatabase(agentfs, dirs.base, tempDir);
33
+ await serveAndMount(agentfs, database, dirs);
34
+
35
+ return {
36
+ originalDir: dirs.base,
37
+ writableDir: repo,
38
+ // The shared entries are symlinks now, which patterns like "node_modules/" don't match.
39
+ // ._* are the AppleDouble files macOS writes on NFS, where it can't store extended attributes.
40
+ gitExcludes: ["._*", ...shared.map((entry) => `/${entry}`)],
41
+ wrap: (command) => ["/usr/bin/sandbox-exec", "-p", sandboxProfile(dirs), ...command],
42
+ changes: () => changesInDatabase(agentfs, database, dirs),
43
+ close: () => restore(dirs),
44
+ };
45
+ } catch (error) {
46
+ await restore(dirs);
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Creating a directory is atomic, so whoever creates <repo>.pi-shared owns the repository until
53
+ * restore() removes it. Waits while another run is alive; repairs a run that crashed.
54
+ */
55
+ async function takeLock(dirs: Dirs) {
56
+ for (let attempt = 0; attempt < 3000; attempt++) {
57
+ try {
58
+ await fs.mkdir(dirs.shared);
59
+ await writeState(dirs, { runnerPid: process.pid, shared: [] });
60
+ return;
61
+ } catch (error) {
62
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
63
+ }
64
+ const state = await readState(dirs).catch(() => null);
65
+ const crashed = state ? !isAlive(state.runnerPid) : await olderThan(dirs.shared, 1000);
66
+ if (crashed) await restore(dirs);
67
+ else await Bun.sleep(20);
68
+ }
69
+ throw new Error("Another code run on this repository didn't finish within a minute.");
70
+ }
71
+
72
+ /** Steps 1 and 2. Records what it did in <repo>.pi-shared/state.json, so restore() can undo it. */
73
+ async function moveAside(dirs: Dirs): Promise<string[]> {
74
+ const shared = await listSharedEntries(dirs.repo);
75
+ await writeState(dirs, { ...(await readState(dirs)), shared });
76
+
77
+ await fs.rename(dirs.repo, dirs.base);
78
+ await fs.mkdir(dirs.repo);
79
+ for (const entry of shared) {
80
+ await fs.mkdir(path.dirname(path.join(dirs.shared, entry)), { recursive: true });
81
+ await fs.rename(path.join(dirs.base, entry), path.join(dirs.shared, entry));
82
+ await fs.symlink(path.join(dirs.shared, entry), path.join(dirs.base, entry));
83
+ }
84
+ return shared;
85
+ }
86
+
87
+ /** .git, plus ignored directories (the top-most ones) that don't contain tracked files. */
88
+ async function listSharedEntries(repo: string): Promise<string[]> {
89
+ const [ignoredOutput, trackedOutput] = await Promise.all([
90
+ $`git ls-files -z --others --ignored --exclude-standard --directory`.cwd(repo).text(),
91
+ $`git ls-files -z`.cwd(repo).text(),
92
+ ]);
93
+ const ignoredDirs = ignoredOutput.split("\0").filter((entry) => entry.endsWith("/"));
94
+ const tracked = trackedOutput.split("\0");
95
+
96
+ const shared = [".git"];
97
+ for (const dir of ignoredDirs.map((entry) => entry.slice(0, -1)).toSorted()) {
98
+ const insideShared = shared.some((entry) => dir.startsWith(`${entry}/`));
99
+ const containsTracked = tracked.some((file) => file.startsWith(`${dir}/`));
100
+ if (!insideShared && !containsTracked) shared.push(dir);
101
+ }
102
+ return shared;
103
+ }
104
+
105
+ /**
106
+ * An empty overlay database. `agentfs init` takes ~150 ms, so it runs once per repository to make
107
+ * a template, and each run gets an instant copy-on-write clone of that.
108
+ */
109
+ async function createDatabase(agentfs: string, base: string, tempDir: string): Promise<string> {
110
+ const templateDir = path.join(homedir(), ".cache", "pi-shorthand", Bun.hash(base).toString(16));
111
+ const templateFiles = path.join(templateDir, ".agentfs"); // where `agentfs init` puts them
112
+ if (!(await Bun.file(path.join(templateFiles, "template.db")).exists())) {
113
+ await fs.mkdir(templateDir, { recursive: true });
114
+ await $`${agentfs} init template --base ${base}`.cwd(templateDir).quiet();
115
+ }
116
+
117
+ await cloneDatabase(templateFiles, "template.db", tempDir, "run.db");
118
+ return path.join(tempDir, "run.db");
119
+ }
120
+
121
+ /** Step 3. */
122
+ async function serveAndMount(agentfs: string, database: string, dirs: Dirs) {
123
+ const port = freePort();
124
+ const server = Bun.spawn([agentfs, "nfs", database, "--port", String(port)], { stdout: "ignore", stderr: "ignore" });
125
+ await writeState(dirs, { ...(await readState(dirs)), serverPid: server.pid });
126
+ await waitForPort(port);
127
+
128
+ const options = `locallocks,vers=3,tcp,port=${port},mountport=${port},soft,timeo=100,retrans=5`;
129
+ await $`/sbin/mount_nfs -o ${options} 127.0.0.1:/ ${dirs.repo}`.quiet();
130
+ }
131
+
132
+ /** Step 4: allow everything except writing to the original files or to .git. */
133
+ function sandboxProfile(dirs: Dirs): string {
134
+ return [
135
+ "(version 1)",
136
+ "(allow default)",
137
+ `(deny file-write* (subpath ${JSON.stringify(dirs.base)}))`,
138
+ `(deny file-write* (subpath ${JSON.stringify(path.join(dirs.shared, ".git"))}))`,
139
+ ].join("\n");
140
+ }
141
+
142
+ /**
143
+ * `agentfs diff` lists what's in the overlay (including files that were only read). The server
144
+ * keeps the database locked, so this diffs a copy-on-write snapshot of it.
145
+ */
146
+ async function changesInDatabase(agentfs: string, database: string, dirs: Dirs) {
147
+ const snapshotDir = path.join(path.dirname(database), "snapshot");
148
+ await cloneDatabase(path.dirname(database), "run.db", snapshotDir, "run.db");
149
+ const output = await $`${agentfs} diff ${path.join(snapshotDir, "run.db")}`.quiet().text();
150
+
151
+ const changes: { file: string; contents: Uint8Array | null }[] = [];
152
+ for (const line of output.split("\n")) {
153
+ // e.g. "M f /src/a.ts", "A f /src/new.ts", "D ? /src/old.ts"
154
+ const match = line.match(/^([AMD]) (\S) \/(.+)$/);
155
+ if (!match || path.basename(match[3]).startsWith("._")) continue; // AppleDouble files
156
+ const [, change, type, file] = match;
157
+
158
+ if (change !== "D" && type === "f") changes.push({ file, contents: await readFile(path.join(dirs.repo, file)) });
159
+ if (change === "D") {
160
+ for (const deleted of await filesUnder(path.join(dirs.base, file))) {
161
+ changes.push({ file: path.join(file, deleted), contents: null });
162
+ }
163
+ }
164
+ }
165
+ return changes;
166
+ }
167
+
168
+ /** The files under a path relative to it: [""] for a file, everything inside for a directory. */
169
+ async function filesUnder(original: string): Promise<string[]> {
170
+ const stats = await fs.lstat(original).catch(() => null);
171
+ if (!stats?.isDirectory()) return [""];
172
+ return fs.readdir(original, { recursive: true });
173
+ }
174
+
175
+ /** Unmounts, stops the server, renames everything back and releases the lock. */
176
+ async function restore(dirs: Dirs) {
177
+ if (!(await exists(dirs.shared))) return;
178
+ const state = await readState(dirs).catch((): State => ({ runnerPid: 0, shared: [] }));
179
+
180
+ await $`umount -f ${dirs.repo}`.nothrow().quiet(); // -f: a leftover subprocess may still have files open
181
+ if (state.serverPid) {
182
+ try {
183
+ process.kill(state.serverPid);
184
+ } catch {
185
+ // already stopped
186
+ }
187
+ }
188
+
189
+ if (await exists(dirs.base)) {
190
+ for (const entry of state.shared) {
191
+ if (!(await exists(path.join(dirs.shared, entry)))) continue;
192
+ await fs.rm(path.join(dirs.base, entry), { force: true }); // the symlink
193
+ await fs.rename(path.join(dirs.shared, entry), path.join(dirs.base, entry));
194
+ }
195
+ await fs.rmdir(dirs.repo).catch(() => {}); // the empty mountpoint
196
+ await fs.rename(dirs.base, dirs.repo);
197
+ }
198
+
199
+ // Never delete <repo>.pi-shared recursively: during a run it holds the real node_modules and .git.
200
+ await fs.rm(path.join(dirs.shared, "state.json"), { force: true });
201
+ await removeEmptyDirectories(dirs.shared);
202
+ }
203
+
204
+ // ── Small helpers ─────────────────────────────────────────────────────────────────
205
+
206
+ interface State {
207
+ runnerPid: number;
208
+ shared: string[];
209
+ serverPid?: number;
210
+ }
211
+
212
+ function readState(dirs: Dirs): Promise<State> {
213
+ return Bun.file(path.join(dirs.shared, "state.json")).json();
214
+ }
215
+
216
+ async function writeState(dirs: Dirs, state: State) {
217
+ await Bun.write(path.join(dirs.shared, "state.json"), JSON.stringify(state));
218
+ }
219
+
220
+ /** Copy-on-write clones a database with its -wal/-shm files, renaming it. */
221
+ async function cloneDatabase(fromDir: string, fromName: string, toDir: string, toName: string) {
222
+ await fs.mkdir(toDir, { recursive: true });
223
+ for (const file of await fs.readdir(fromDir)) {
224
+ if (!file.startsWith(fromName)) continue;
225
+ await Bun.write(path.join(toDir, file.replace(fromName, toName)), Bun.file(path.join(fromDir, file)));
226
+ }
227
+ }
228
+
229
+ async function removeEmptyDirectories(dir: string) {
230
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
231
+ if (entry.isDirectory()) await removeEmptyDirectories(path.join(dir, entry.name));
232
+ }
233
+ await fs.rmdir(dir); // fails, leaving everything, if it isn't empty
234
+ }
235
+
236
+ /** A regular file's contents, or null if there's no regular file there. */
237
+ async function readFile(file: string): Promise<Uint8Array | null> {
238
+ const stats = await fs.lstat(file).catch(() => null);
239
+ return stats?.isFile() ? Bun.file(file).bytes() : null;
240
+ }
241
+
242
+ function isAlive(pid: number): boolean {
243
+ try {
244
+ process.kill(pid, 0); // signal 0 only checks the process exists
245
+ return true;
246
+ } catch {
247
+ return false;
248
+ }
249
+ }
250
+
251
+ async function olderThan(file: string, ms: number): Promise<boolean> {
252
+ const stats = await fs.stat(file).catch(() => null);
253
+ return !stats || Date.now() - stats.mtimeMs > ms;
254
+ }
255
+
256
+ async function exists(file: string): Promise<boolean> {
257
+ return (await fs.lstat(file).catch(() => null)) !== null;
258
+ }
259
+
260
+ function freePort(): number {
261
+ const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
262
+ listener.stop(true);
263
+ return listener.port;
264
+ }
265
+
266
+ async function waitForPort(port: number) {
267
+ for (let attempt = 0; attempt < 1000; attempt++) {
268
+ try {
269
+ const socket = await Bun.connect({ hostname: "127.0.0.1", port, socket: { data() {} } });
270
+ socket.end();
271
+ return;
272
+ } catch {
273
+ await Bun.sleep(2); // not listening yet
274
+ }
275
+ }
276
+ throw new Error("AgentFS's NFS server didn't start.");
277
+ }
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "pi-shorthand",
3
+ "version": "0.1.0",
4
+ "description": "Pi tool for token-efficient writes: the model makes a whole change with one Bun program, whose writes are held in an overlay and applied only if it succeeds.",
5
+ "keywords": [
6
+ "ast-grep",
7
+ "bun",
8
+ "code-mode",
9
+ "gritql",
10
+ "overlay",
11
+ "pi",
12
+ "pi-extension",
13
+ "pi-package"
14
+ ],
15
+ "homepage": "https://github.com/sebinsua/pi-shorthand#readme",
16
+ "bugs": "https://github.com/sebinsua/pi-shorthand/issues",
17
+ "license": "MIT",
18
+ "author": "Seb Insua",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/sebinsua/pi-shorthand.git"
22
+ },
23
+ "files": [
24
+ "*.ts",
25
+ "skills",
26
+ "README.md"
27
+ ],
28
+ "type": "module",
29
+ "scripts": {
30
+ "test": "bun test",
31
+ "typecheck": "tsc -p .",
32
+ "lint": "oxlint --deny-warnings",
33
+ "format": "oxfmt",
34
+ "check": "tsc -p . && oxlint --deny-warnings && oxfmt --check",
35
+ "prepare": "git config core.hooksPath .githooks 2>/dev/null || true"
36
+ },
37
+ "dependencies": {
38
+ "@ast-grep/cli": "^0.45.3",
39
+ "@ast-grep/napi": "^0.45.3",
40
+ "@getgrit/cli": "0.1.0-alpha.1743007075",
41
+ "diff": "^9.0.0"
42
+ },
43
+ "devDependencies": {
44
+ "@earendil-works/pi-ai": "^0.85.1",
45
+ "@earendil-works/pi-coding-agent": "^0.85.1",
46
+ "@earendil-works/pi-tui": "^0.85.1",
47
+ "@types/bun": "^1.4.2",
48
+ "@types/node": "^26.6.1",
49
+ "oxfmt": "^0.68.0",
50
+ "oxlint": "^1.83.0",
51
+ "typebox": "^1.3.7",
52
+ "typescript": "^7.0.2"
53
+ },
54
+ "peerDependencies": {
55
+ "@earendil-works/pi-ai": "*",
56
+ "@earendil-works/pi-coding-agent": "*",
57
+ "@earendil-works/pi-tui": "*",
58
+ "typebox": "*"
59
+ },
60
+ "peerDependenciesMeta": {
61
+ "@earendil-works/pi-ai": {
62
+ "optional": true
63
+ },
64
+ "@earendil-works/pi-coding-agent": {
65
+ "optional": true
66
+ },
67
+ "@earendil-works/pi-tui": {
68
+ "optional": true
69
+ },
70
+ "typebox": {
71
+ "optional": true
72
+ }
73
+ },
74
+ "pi": {
75
+ "extensions": [
76
+ "./index.ts"
77
+ ],
78
+ "skills": [
79
+ "./skills"
80
+ ]
81
+ }
82
+ }
package/prelude.ts ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Preloaded into every `code` program. On top of ordinary Bun and Node it adds these globals:
3
+ * $ (Bun shell), glob, grep, sg (ast-grep) and grit (GritQL).
4
+ *
5
+ * sg is ast-grep's own JavaScript API (@ast-grep/napi: sg.parse, sg.Lang, sg.findInFiles, …) plus two
6
+ * shortcuts, sg.find and sg.rewrite. Programs can also import "@ast-grep/napi" directly.
7
+ *
8
+ * Everything except $ is synchronous: models often call helpers like these without await.
9
+ * File lists come from git (tracked, plus untracked files that aren't ignored), so node_modules
10
+ * and build output are left out on every platform.
11
+ *
12
+ * Each $ command and helper call is logged to the runner's log (~/.cache/pi-shorthand/runs.jsonl) as it
13
+ * happens, so `tail -f` shows what a program is doing, including which command it's stuck on.
14
+ */
15
+
16
+ import { appendFileSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
17
+ import * as astGrep from "@ast-grep/napi";
18
+ import { Lang, type NapiConfig, parse, type SgNode } from "@ast-grep/napi";
19
+ import { $ as bunShell, Glob } from "bun";
20
+
21
+ function log(event: string, details: Record<string, unknown>) {
22
+ const { PI_SHORTHAND_LOG, PI_SHORTHAND_RUN } = process.env;
23
+ if (!PI_SHORTHAND_LOG) return;
24
+ appendFileSync(
25
+ PI_SHORTHAND_LOG,
26
+ `${JSON.stringify({ time: new Date().toISOString(), run: PI_SHORTHAND_RUN, event, ...details })}\n`,
27
+ );
28
+ }
29
+
30
+ /** Runs a helper, logging how long it took and how many results it returned. */
31
+ function logged<T>(helper: string, args: unknown[], run: () => T): T {
32
+ const startedAt = performance.now();
33
+ const result = run();
34
+ const results = Array.isArray(result) ? result.length : result;
35
+ log("helper", {
36
+ helper,
37
+ args: JSON.stringify(args).slice(0, 200),
38
+ ms: Math.round(performance.now() - startedAt),
39
+ results,
40
+ });
41
+ return result;
42
+ }
43
+
44
+ /** Bun's shell, logging each command as it starts. */
45
+ const $ = new Proxy(bunShell, {
46
+ apply(target, thisArg, args: Parameters<typeof bunShell>) {
47
+ const [strings, ...values] = args;
48
+ log("command", { command: String.raw({ raw: strings.raw }, ...values).slice(0, 200) });
49
+ return Reflect.apply(target, thisArg, args);
50
+ },
51
+ });
52
+
53
+ /**
54
+ * Files git sees under a directory that match a glob pattern, relative to the working directory,
55
+ * sorted. The directory can also be given as { cwd }, as with Bun's Glob.
56
+ */
57
+ function glob(pattern: string, where: string | { cwd?: string } = "."): string[] {
58
+ const dir = typeof where === "string" ? where : (where.cwd ?? ".");
59
+ const output = git(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", dir]);
60
+ const matcher = new Glob(dir === "." ? pattern : `${dir.replace(/\/$/, "")}/${pattern}`);
61
+ // git still lists a tracked file the program has deleted, so check it's there.
62
+ const files = [...new Set(output.split("\0"))].filter((file) => file && matcher.match(file) && existsSync(file));
63
+ return files.toSorted();
64
+ }
65
+
66
+ /**
67
+ * Search files git sees. A string is matched literally; a RegExp is matched as a Perl-compatible
68
+ * regular expression, which is close to JavaScript's syntax.
69
+ */
70
+ function grep(pattern: string | RegExp, paths: string | string[] = ".") {
71
+ let flags: string[];
72
+ if (typeof pattern === "string") {
73
+ flags = ["-F", "-e", pattern];
74
+ } else {
75
+ flags = ["-P", "-e", pattern.source];
76
+ if (pattern.flags.includes("i")) flags.push("-i");
77
+ }
78
+ const output = git(["grep", "-n", "--null", "--untracked", "-I", ...flags, "--", ...[paths].flat()]);
79
+
80
+ const matches = [];
81
+ for (const line of output.split("\n")) {
82
+ if (line === "") continue;
83
+ const [file, lineNumber, text] = line.split("\0");
84
+ matches.push({ file, line: Number(lineNumber), text });
85
+ }
86
+ return matches;
87
+ }
88
+
89
+ function git(args: string[]): string {
90
+ return Bun.spawnSync(["git", ...args], { stderr: "ignore" }).stdout.toString();
91
+ }
92
+
93
+ // ── ast-grep ──────────────────────────────────────────────────────────────────────
94
+ // Patterns use ast-grep syntax: $X matches one node, $$$X matches zero or more.
95
+
96
+ const LANGUAGES: Record<string, Lang> = {
97
+ ts: Lang.TypeScript,
98
+ mts: Lang.TypeScript,
99
+ cts: Lang.TypeScript,
100
+ tsx: Lang.Tsx,
101
+ jsx: Lang.Tsx,
102
+ js: Lang.JavaScript,
103
+ mjs: Lang.JavaScript,
104
+ cjs: Lang.JavaScript,
105
+ html: Lang.Html,
106
+ css: Lang.Css,
107
+ };
108
+
109
+ /**
110
+ * A match. Its captures are in `vars` and also directly on it (capture names are uppercase, so they
111
+ * can't clash with the other fields): both `m.vars.ARGS` and `m.ARGS` work.
112
+ */
113
+ type SgMatch = {
114
+ file: string;
115
+ line: number;
116
+ text: string;
117
+ vars: Record<string, string>; // captured metavariables, e.g. vars.ARGS for $$$ARGS
118
+ node: SgNode;
119
+ } & Record<string, unknown>;
120
+
121
+ /**
122
+ * The JS/TS files to search. `files` is a file, a directory (the JS/TS files in it), a glob, or a list
123
+ * of any of those. Warns if there are none, since that's almost always a mistake.
124
+ */
125
+ function sourceFiles(helper: string, files: string | string[]): string[] {
126
+ const found = [files].flat().flatMap((entry) => {
127
+ const stats = statSync(entry, { throwIfNoEntry: false });
128
+ if (stats?.isFile()) return [entry];
129
+ if (stats?.isDirectory()) return glob("**/*.{ts,mts,cts,tsx,js,mjs,cjs,jsx}", entry);
130
+ return glob(entry);
131
+ });
132
+ const parseable = found.filter((file) => LANGUAGES[file.split(".").pop()!]);
133
+ if (parseable.length === 0) console.error(`warning: ${helper} found no JS/TS files in ${JSON.stringify(files)}`);
134
+ return parseable;
135
+ }
136
+
137
+ function find(pattern: string | NapiConfig, files: string | string[] = "."): SgMatch[] {
138
+ const matches: SgMatch[] = [];
139
+ for (const file of sourceFiles("sg.find", files)) {
140
+ const parsed = parseFile(file);
141
+ if (!parsed) continue;
142
+ for (const node of parsed.root.findAll(pattern)) matches.push(toMatch(file, node, parsed.source, pattern));
143
+ }
144
+ return matches;
145
+ }
146
+
147
+ /**
148
+ * Rewrite matches in place. `replacement` is either a template using the same $X / $$$X
149
+ * metavariables, or a function returning the new text. A function returning anything other than a
150
+ * string (undefined, null, false) leaves that match alone, so `(m) => cond && \`…\`` works.
151
+ * Returns the number of matches rewritten.
152
+ */
153
+ function rewrite(
154
+ pattern: string | NapiConfig,
155
+ replacement: string | ((match: SgMatch) => unknown),
156
+ files: string | string[] = ".",
157
+ ): number {
158
+ let count = 0;
159
+ for (const file of sourceFiles("sg.rewrite", files)) {
160
+ const parsed = parseFile(file);
161
+ if (!parsed) continue;
162
+
163
+ const edits = [];
164
+ for (const node of parsed.root.findAll(pattern)) {
165
+ const match = toMatch(file, node, parsed.source, pattern);
166
+ const newText =
167
+ typeof replacement === "function"
168
+ ? replacement(match)
169
+ : replacement.replace(/(\$\$\$|\$)([A-Z_][A-Z0-9_]*)/g, (text, _, name) => match.vars[name] ?? text);
170
+ if (typeof newText === "string") edits.push(node.replace(newText)); // anything else (undefined, null, false) leaves it
171
+ }
172
+ if (edits.length === 0) continue;
173
+
174
+ writeFileSync(file, parsed.root.commitEdits(edits));
175
+ count += edits.length;
176
+ }
177
+ // Almost always a mistake, e.g. a bare name as the pattern only matches plain identifiers, not properties.
178
+ if (count === 0) console.error(`warning: sg.rewrite matched nothing for ${JSON.stringify(pattern)}`);
179
+ return count;
180
+ }
181
+
182
+ /** A JS/TS/HTML/CSS file's source and syntax tree, or null for other files. */
183
+ function parseFile(file: string) {
184
+ const lang = LANGUAGES[file.split(".").pop()!];
185
+ if (!lang) return null;
186
+ const source = readFileSync(file, "utf8");
187
+ return { source, root: parse(lang, source).root() };
188
+ }
189
+
190
+ function toMatch(file: string, node: SgNode, source: string, pattern: string | NapiConfig): SgMatch {
191
+ const vars: Record<string, string> = {};
192
+ for (const [, dollars, name] of JSON.stringify(pattern).matchAll(/(\$\$\$|\$)([A-Z_][A-Z0-9_]*)/g)) {
193
+ if (dollars === "$$$") {
194
+ // Slice the original source so separators and formatting are kept ("a, b" rather than "a,b").
195
+ const nodes = node.getMultipleMatches(name);
196
+ const first = nodes[0];
197
+ const last = nodes[nodes.length - 1];
198
+ vars[name] = first && last ? source.slice(first.range().start.index, last.range().end.index) : "";
199
+ } else {
200
+ const captured = node.getMatch(name);
201
+ if (captured) vars[name] = captured.text();
202
+ }
203
+ }
204
+ return { ...vars, file, line: node.range().start.line + 1, text: node.text(), vars, node };
205
+ }
206
+
207
+ // ── GritQL ────────────────────────────────────────────────────────────────────────
208
+
209
+ /**
210
+ * Apply a GritQL pattern in place (or only match it, with dryRun). Returns the files it matched.
211
+ * e.g. grit("`console.log($x)` => `logger.info($x)`", "src")
212
+ */
213
+ function grit(pattern: string, paths: string | string[] = ".", options: { lang?: string; dryRun?: boolean } = {}) {
214
+ const flags = ["--force", "--jsonl"];
215
+ if (options.dryRun) flags.push("--dry-run");
216
+ if (options.lang) flags.push("--language", options.lang);
217
+
218
+ const result = Bun.spawnSync(["grit", "apply", ...flags, pattern, ...[paths].flat()]);
219
+
220
+ const files = [];
221
+ for (const line of result.stdout.toString().split("\n")) {
222
+ if (!line.startsWith("{")) continue;
223
+ const record = JSON.parse(line);
224
+ const matched = record.original ?? record; // rewrites nest the match under "original"
225
+ if (matched.sourceFile) files.push({ file: matched.sourceFile, matches: matched.ranges.length });
226
+ }
227
+ if (result.exitCode !== 0 && files.length === 0) {
228
+ throw new Error(`grit failed: ${result.stderr.toString().trim()}`);
229
+ }
230
+ return files;
231
+ }
232
+
233
+ Object.assign(globalThis, {
234
+ $,
235
+ glob: (...args: Parameters<typeof glob>) => logged("glob", args, () => glob(...args)),
236
+ grep: (...args: Parameters<typeof grep>) => logged("grep", args, () => grep(...args)),
237
+ sg: {
238
+ ...astGrep,
239
+ find: (...args: Parameters<typeof find>) => logged("sg.find", args, () => find(...args)),
240
+ rewrite: (...args: Parameters<typeof rewrite>) => logged("sg.rewrite", args, () => rewrite(...args)),
241
+ },
242
+ grit: (...args: Parameters<typeof grit>) => logged("grit", args, () => grit(...args)),
243
+ });