Public: anyone can read this; the crew can change it.

A test proves no code path reachable from the billing webhook removes a forge's directory #11402

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/11392-a-test-proves-no-code-path-reachable-fro → main 4 commits fork 2 threads · 2 unresolved

  • Merged. torque/11392-a-test-proves-no-code-path-reachable-fro was merged into main as 4cb4bc0.

Review

Sign in to review this change.
3 files changed +540 −1
scripts/gate.ts +14 −1
@@ -209,6 +209,19 @@ export function strayTestRefusal(root = "."): string[] {
209209 .map((f) => `src/${f}: a test outside test/ never runs in the gate; move it to test/`);
210210 }
211211
212 /**
213 * No call path from the billing webhook deletes a forge, an app or a child
214 * (#11392): the walk in `src/kernel/destroy_reach.ts`, named here so it runs
215 * in the check's first second as well as in `test/billing-never-deletes.test.ts`.
216 */
217 export function billingDeletesRefusal(root = "."): string[] {
218 const { destroyReachRefusal } = require("../src/kernel/destroy_reach.ts") as typeof import("../src/kernel/destroy_reach.ts");
219 // A fixture repository (the gate's own tests) holds no walk at all and has nothing to check.
outdated

A missing webhook entry returns no findings, so renaming src/http/stripe_hook.ts silently disables the guard. Refuse in the real tree; skip only in fixtures.

220 // A tree that has the walk but not the webhook is the real one after a move: refused, not passed.
221 if (!existsSync(join(root, "src/kernel/destroy_reach.ts"))) return [];
222 return destroyReachRefusal(root);
223 }
224
212225 /** Review findings that repeated (#8888): see `src/kernel/review_rules.ts`. */
213226 export function reviewRefusal(root = "."): string[] {
214227 // Loaded by `require` so `treeGate`, which loads this file alone, never needs it.
@@ -279,7 +292,7 @@ export async function directionRefusal(cwd: string = process.cwd()): Promise<str
279292 // `bun scripts/gate.ts`: the audit alone, first in `forge.json`'s check, so
280293 // a stale exclusion fails in under a second — not after install and tsc.
281294 if (import.meta.main) {
282 const refusal = [...gateRefusal(presentTests()), ...(await pinRefusal()), ...(await tryItRefusal()), ...(await bothDoorsRefusal()), ...(await directionRefusal()), ...strayTestRefusal(), ...reviewRefusal()];
295 const refusal = [...gateRefusal(presentTests()), ...(await pinRefusal()), ...(await tryItRefusal()), ...(await bothDoorsRefusal()), ...(await directionRefusal()), ...strayTestRefusal(), ...reviewRefusal(), ...billingDeletesRefusal()];
283296 for (const line of refusal) console.error(`gate: ${line}`);
284297 process.exit(refusal.length === 0 ? 0 : 2);
285298 }
src/kernel/destroy_reach.ts added +330
@@ -0,0 +1,330 @@
1 /**
2 * What the billing webhook can reach that deletes (#11392, part of #11140:
3 * losing someone's work is never the penalty for not paying).
4 *
5 * `destroyReach(root, entry)` follows calls, not whole files: from every
6 * top-level unit (function, const, class) of the entry file, each identifier
7 * that names a local unit or an imported one pulls that unit in, through
8 * value imports, `import * as`, re-exports and `import()`. Whole-file
9 * reachability would reach all of the forge through `app.ts` and say
10 * nothing. A reachable unit that calls `rm`/`rmSync`/`unlink`/`unlinkSync`/
11 * `rmdir`/`rmdirSync` at all (bare, as a member such as `fs.promises.rm(`, under
12 * an import alias, over several lines, on a path held in a variable), or names
13 * a removal action (`SINKS`), or names the event `repo.deleted`, is a finding:
14 * `file:line: text`, with the chain of units that led there. The path is never
15 * judged: only a unit on `ALLOWED_REMOVALS`, with its reason, may remove.
16 *
17 * Approximation, stated: units are cut at column-0 declarations (a file with
18 * an indented top-level declaration is a finding, see `layoutMisses`); a
19 * method call through an object (`app.x.y()`) is followed by method name
20 * within files the caller imports; a class is one unit. A removal by spawning
21 * a process (a spawn of `rm`) is a finding too. Plain data in, plain
22 * data out.
23 */
24
25 import { existsSync, readFileSync, statSync } from "node:fs";
26 import { dirname, join, normalize } from "node:path";
27
28 /** Removal actions by name: the child-removal action and the directory deleters. */
29 export const SINKS: readonly string[] = ["removeForge", "removeApp", "removeEnv", "deleteRepoDir", "deleteApp", "deleteRepo"];
30
31 /**
32 * Units the walk does not enter, each with why. A stop is not trusted by its
33 * name: when a reachable unit refers to it, that unit must be in `from`
34 * (`file:unit`), so a new path to it from the webhook is a finding.
35 * - `AppsRuntime.start`: the walk matches a method call by name, so a cell's
36 * `.start(` looks like the runtime's. The test reads `settleCell` and checks
37 * it names the cell registry and never the runtime.
38 * - `AppsRuntime.onEvent`: entered by the bus, not by a call. It tears an app
39 * down only on `repo.deleted`; a reachable unit that names that event is a
40 * finding, so the webhook appends none. Nothing may call it directly.
41 * - `AppsRuntime.reconcileApp`: boots a forge's environments and removes those
42 * of closed pulls. Only the units in `from` may reach it, and they act on a
43 * forge the webhook has just made.
44 */
outdated

THEIRS gates every fs removal on the path text of the same line. A variable, a multi-line call or an aliased import slips through. Flag all reachable removals unless allow-listed with a reason.

45 export const STOPS: readonly { unit: string; why: string; from: readonly string[] }[] = [
46 {
47 unit: "AppsRuntime.start",
48 why: "the forge's own boot, and its prune of app data with no row; `cells.start(ledger)` in `settleCell` is `CellChildren.start`, a cell's; `startChildApps` starts the runtime of the child just made, over its own empty data",
49 from: ["src/spawn/host.ts:settleCell", "src/spawn/child-apps.ts:startChildApps"],
50 },
51 { unit: "AppsRuntime.onEvent", why: "entered by the bus; the webhook appends no repo.deleted", from: [] },
52 { unit: "AppsRuntime.reconcileApp", why: "boot tidy of a forge the webhook has just made, which holds no environments", from: [] },
53 ];
54
55 const unitsOn = (why: string, units: string[]): { unit: string; why: string }[] => units.map((unit) => ({ unit, why }));
56
57 /**
58 * Reachable units that may remove a file, each with why. A path is never
59 * judged: a unit is here because a person read it. Most are reached because
60 * making a forge boots it (`makeForge` -> `bootHostedChild`), and boot tidies
61 * its own scratch. A unit that grows a second removal is still allowed here, so
62 * read the whole unit when you add or change one; `test/billing-never-deletes
63 * .test.ts` fails when an entry no longer removes anything (a stale reason).
64 */
65 export const ALLOWED_REMOVALS: readonly { unit: string; why: string }[] = [
66 ...unitsOn("scratch under the checks root, per run or commit (checkouts, caches, locks, the install cache): never a forge's or an app's directory", [
67 "src/checks/service.ts:startChecks",
68 "src/checks/service.ts:removeRunDir",
69 "src/checks/cache.ts:restoreCache",
70 "src/checks/cache.ts:keepCache",
71 "src/checks/impact-store.ts:seedImpact",
72 "src/checks/install.ts:prefillInstallCache",
73 "src/checks/install.ts:holdFile",
74 "src/checks/install_evict.ts:evictInstallCache",
75 "src/apps/checkout.ts:exportTree",
76 ]),
77 { unit: "src/core/repos.ts:rowOrUndo", why: "removes the directory git has just made for the repository being created, when its row is refused" },
78 { unit: "src/git/write.ts:commitFiles", why: "removes its own temporary index file" },
79 { unit: "src/git/repos.ts:detachFork", why: "removes the alternates file of the fork just made" },
80 { unit: "src/git/env.ts:trustedCaBundle", why: "removes the process's own temporary CA bundle at exit" },
81 { unit: "src/apps/contain.ts:openCell", why: "clears the cell's socket directory before the cell opens" },
82 { unit: "src/apps/cgroup.ts:removeDir", why: "removes an empty cgroup directory" },
83 { unit: "src/spawn/own_code.ts:checkout", why: "removes a half-made export of the forge's own code, keyed by commit" },
84 { unit: "src/spawn/own_code.ts:recordGood", why: "removes exports of the forge's own code for other commits than the good one" },
85 { unit: "src/web/asset.ts:prune", why: "removes cached page scripts older than their keep time" },
86 ...unitsOn("the call is inside text the unit returns (a generated app's code), never run by the forge", [
87 "src/apps/scaffold.ts:db",
88 "src/apps/scaffold.ts:committed",
89 "src/apps/scaffold.ts:base",
90 ]),
91 ];
92
93 const REMOVERS = ["rmSync", "rmdirSync", "unlinkSync", "rm", "rmdir", "unlink"];
94 const FS_MODULE = /^(?:node:)?fs(?:\/promises)?$/;
95 /** A removal spawned as a process, or a shell. */
96 const SPAWNED = /\b(?:Bun\.spawn(?:Sync)?\(\s*\[?\s*["'`](?:rm|unlink|rmdir|sh|bash)["'`]|Bun\.\$\s*`\s*rm\b|child_process|execSync|execFileSync)/;
97 const EVENT = /["'`]repo\.deleted["'`]/;
98 const METHOD = /^ {2}(?:(?:public|private|protected|static|async|override|get|set)\s+)*([A-Za-z_$#][\w$]*)\s*[(<]/;
99 const DECL = /^(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function\*?|const|let|class|abstract\s+class)\s+([A-Za-z_$][\w$]*)/;
100
101 interface Unit { file: string; name: string; start: number; text: string; methods?: Unit[] }
102 interface Module {
103 touches: Set<string>;
104 units: Map<string, Unit>;
105 imports: Map<string, { from: string; name: string }>;
106 spaces: Map<string, string>;
107 reexports: string[];
108 /** Local names that mean a file removal in this module (`import { rmSync as r } from "node:fs"`). */
109 removers: Set<string>;
110 /** Every identifier in the file: a class is a candidate for a method call only where it is named. */
111 words: Set<string>;
112 }
113
114 const resolve = (root: string, from: string, spec: string): string | undefined => {
115 const base = normalize(join(dirname(from), spec));
116 for (const c of [base, `${base}.ts`, join(base, "index.ts")]) {
117 const full = join(root, c);
118 if (existsSync(full) && statSync(full).isFile()) return c;
119 }
120 return undefined;
121 };
122
123 /** The text with comments blanked, newlines kept, so a line number still means the same line. */
124 const stripComments = (text: string): string =>
125 text
126 .replace(/\/\*[\s\S]*?\*\//g, (c) => c.replace(/[^\n]/g, " "))
127 .replace(/(?<![:"'`\\])\/\/[^\n]*/g, (c) => " ".repeat(c.length));
128
129 function parse(root: string, file: string): Module {
130 const text = readFileSync(join(root, file), "utf8");
131 const lines = text.split("\n");
132 const units = new Map<string, Unit>();
133 const imports = new Map<string, { from: string; name: string }>();
134 const spaces = new Map<string, string>();
135 const reexports: string[] = [];
136 const touches = new Set<string>();
137 const removers = new Set<string>();
138 for (const m of text.matchAll(/^(import|export)\s+(type\s+)?([^;]*?)\s+from\s+"([^"]+)"/gms)) {
139 const clause = m[3]!.trim();
140 if (!m[4]!.startsWith(".")) {
141 if (FS_MODULE.test(m[4]!))
142 for (const part of (clause.match(/\{([^}]*)\}/s)?.[1] ?? "").split(",")) {
143 const [name, local] = part.trim().split(/\s+as\s+/);
144 if (name && REMOVERS.includes(name)) removers.add((local ?? name).trim());
145 }
146 continue;
147 }
148 const target = resolve(root, file, m[4]!);
149 if (target === undefined) continue;
150 touches.add(target);
151 if (m[2] !== undefined) continue;
152 if (m[1] === "export") reexports.push(target);
153 const ns = clause.match(/\*\s+as\s+([\w$]+)/);
154 if (ns) spaces.set(ns[1]!, target);
155 for (const part of (clause.match(/\{([^}]*)\}/s)?.[1] ?? "").split(",")) {
156 const p = part.trim().replace(/^type\s+.*/, "");
157 if (p === "") continue;
158 const [name, local] = p.split(/\s+as\s+/);
159 imports.set((local ?? name)!.trim(), { from: target, name: name!.trim() });
160 }
161 const def = clause.match(/^([A-Za-z_$][\w$]*)\s*(?:,|$)/);
162 if (def && !clause.startsWith("{")) imports.set(def[1]!, { from: target, name: "default" });
163 }
164 for (const m of text.matchAll(/^export\s+\*\s+from\s+"(\.[^"]+)"/gm)) {
165 const target = resolve(root, file, m[1]!);
166 if (target !== undefined) {
167 touches.add(target);
168 reexports.push(target);
169 }
170 }
171 for (const m of text.matchAll(/import\(\s*"(\.[^"]+)"\s*\)/g)) {
172 const target = resolve(root, file, m[1]!);
173 if (target !== undefined) {
174 touches.add(target);
175 reexports.push(target);
176 }
177 }
178 let open: Unit | undefined;
179 let owner: Unit | undefined;
180 lines.forEach((line, i) => {
181 const d = DECL.exec(line);
182 if (d) {
183 open = { file, name: d[1]!, start: i + 1, text: "" };
184 units.set(open.name, open);
185 owner = /\bclass\s/.test(line) ? open : undefined;
186 }
187 if (/^(import|export\s+(type|interface)|type|interface|export\s+\{)\b/.test(line) && !d) open = owner = undefined;
188 const meth = owner && !d ? METHOD.exec(line) : null;
189 if (owner && meth && meth[1] !== "constructor") {
190 open = { file, name: `${owner.name}.${meth[1]}`, start: i + 1, text: "" };
191 (owner.methods ??= []).push(open);
192 } else if (owner && !d && /^\S/.test(line)) open = owner;
193 if (open) open.text += `${line}\n`;
194 });
195 return { touches, units, imports, spaces, reexports, removers, words: new Set(text.match(/[A-Za-z_$][\w$]*/g) ?? []) };
196 }
197
198 /**
199 * Lines a column-0 cut would hide: a declaration, import or export that is
200 * indented. A file with any is not read right, and the walk says so rather
201 * than pass over its code.
202 */
203 export function layoutMisses(root: string, file: string): string[] {
204 const out: string[] = [];
205 stripComments(readFileSync(join(root, file), "utf8"))
206 .split("\n")
207 .forEach((line, i) => {
208 if (/^[ \t]+(?:export\s+(?:default\s+)?(?:async\s+)?(?:function|const|let|class|\{)|import\s+[\w{*"])/.test(line) && !/^[ \t]*\/?\*/.test(line))
209 out.push(`${file}:${i + 1}: ${line.trim()}`);
210 });
211 return out;
212 }
213
214 /** The units of one file by name, as the walk cut them; for the test that a parse miss is loud. */
215 export function unitsOf(root: string, file: string): { name: string; start: number; text: string }[] {
216 return [...parse(root, file).units.values()].map(({ name, start, text }) => ({ name, start, text }));
217 }
218
219 /**
220 * Every finding reachable from the units of `entry` (repo-relative, under
221 * `root`). `allowed` are the units that may remove a file, each with why.
222 */
223 export function destroyReach(root: string, entry: string, allowed: readonly { unit: string; why: string }[] = ALLOWED_REMOVALS): string[] {
224 const mods = new Map<string, Module>();
225 const mod = (f: string): Module => {
226 let m = mods.get(f);
227 if (!m) mods.set(f, (m = parse(root, f)));
228 return m;
229 };
230 const found = (f: string, name: string, seen = new Set<string>()): Unit | undefined => {
231 if (seen.has(f)) return undefined;
232 seen.add(f);
233 const m = mod(f);
234 const u = m.units.get(name);
235 if (u) return u;
236 for (const r of m.reexports) {
237 const hit = found(r, name, seen);
238 if (hit) return hit;
239 }
240 return undefined;
241 };
242 const out: string[] = [];
243 const via = new Map<Unit, Unit | undefined>();
244 const queue: Unit[] = [];
245 const touchesFile = (from: string, to: string): boolean => mod(from).touches.has(to);
246 const methods = new Map<string, Unit[]>();
247 const wanted = new Map<string, Unit[]>();
248 const chainOf = (u: Unit): string => {
249 const names: string[] = [];
250 for (let c: Unit | undefined = u; c; c = via.get(c)) names.unshift(`${c.file}:${c.name}`);
251 return names.join(" -> ");
252 };
253 /** A method is called from a unit only where its file is the unit's or imported by it, and its class is named there. */
254 const candidate = (caller: Unit, method: Unit): boolean =>
255 (method.file === caller.file || touchesFile(caller.file, method.file)) && mod(caller.file).words.has(method.name.slice(0, method.name.indexOf(".")));
256 const stopped = new Set<string>();
257 const add = (u: Unit | undefined, from: Unit | undefined) => {
258 if (!u) return;
259 const stop = from && STOPS.find((x) => x.unit === u.name);
260 if (stop) {
261 const key = `${from.file}:${from.name}>${u.name}`;
262 if (!stop.from.includes(`${from.file}:${from.name}`) && !stopped.has(key)) {
263 stopped.add(key);
264 out.push(`${from.file}:${from.start}: reaches ${u.name}, which a webhook path may not enter from here (${stop.why}) [${chainOf(from)}]`);
265 }
266 return;
267 }
268 if (via.has(u)) return;
269 via.set(u, from);
270 queue.push(u);
271 for (const m of u.methods ?? []) {
272 const name = m.name.slice(m.name.indexOf(".") + 1);
273 methods.set(name, [...(methods.get(name) ?? []), m]);
274 for (const c of wanted.get(name) ?? []) if (candidate(c, m)) add(m, c);
275 }
276 };
277 const entryMod = mod(entry);
278 if (entryMod.units.size === 0) return [`${entry}: no unit parsed, so nothing was walked`];
279 for (const miss of layoutMisses(root, entry)) out.push(`${miss} [indented top-level code hides from the walk]`);
280 for (const u of entryMod.units.values()) add(u, undefined);
281 const flagged = new Set<string>();
282 while (queue.length) {
283 const u = queue.shift()!;
284 const m = mod(u.file);
285 if (!flagged.has(u.file)) {
286 flagged.add(u.file);
287 if (u.file !== entry) for (const miss of layoutMisses(root, u.file)) out.push(`${miss} [indented top-level code hides from the walk]`);
288 }
289 const code = stripComments(u.text);
290 const mayRemove = allowed.some((x) => x.unit === `${u.file}:${u.name}`);
291 const local = new Set(m.removers);
292 for (const a of code.matchAll(/\b(rmSync|rmdirSync|unlinkSync|rm|rmdir|unlink)\s*:\s*([A-Za-z_$][\w$]*)/g)) local.add(a[2]!);
293 for (const a of code.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:[\w$]+\.)*(rmSync|rmdirSync|unlinkSync|rm|rmdir|unlink)\s*[;,\n)]/g)) local.add(a[1]!);
294 const names = [...new Set([...REMOVERS, ...local])].join("|");
295 const call = new RegExp(`(?<![\\w$.])(?:${names})\\s*\\(|\\.\\s*(?:${REMOVERS.join("|")})\\s*\\(|\\?\\.\\s*(?:${REMOVERS.join("|")})\\s*\\(`, "g");
296 const named = new RegExp(`\\b(?:${SINKS.join("|")})\\b`);
297 const lineOf = (at: number): number => code.slice(0, at).split("\n").length - 1;
298 const report = (at: number, why: string) => {
299 const i = lineOf(at);
300 out.push(`${u.file}:${u.start + i}: ${u.text.split("\n")[i]!.trim()} [${why}; ${chainOf(u)}]`);
301 };
302 if (!mayRemove) for (const c of code.matchAll(call)) report(c.index!, "removes a file");
303 code.split("\n").forEach((line, i) => {
304 if (named.test(line) || SPAWNED.test(line) || EVENT.test(line)) report(code.split("\n").slice(0, i).join("\n").length + (i ? 1 : 0), "names a removal");
305 });
306 for (const id of new Set(u.text.match(/[A-Za-z_$][\w$]*/g) ?? [])) {
307 const localUnit = m.units.get(id);
308 if (localUnit) add(localUnit, u);
309 const imp = m.imports.get(id);
310 if (imp) add(found(imp.from, imp.name), u);
311 }
312 for (const hit of u.text.matchAll(/\.#?([A-Za-z_$][\w$]*)/g)) {
313 wanted.set(hit[1]!, [...(wanted.get(hit[1]!) ?? []), u]);
314 for (const mm of methods.get(hit[1]!) ?? []) if (candidate(u, mm)) add(mm, u);
315 }
316 for (const [ns, target] of m.spaces)
317 for (const hit of u.text.matchAll(new RegExp(`\\b${ns}\\.([\\w$]+)`, "g"))) add(found(target, hit[1]!), u);
318 for (const target of m.reexports) if (u.text.includes("import(")) for (const x of mod(target).units.values()) add(x, u);
319 }
320 return out;
321 }
322
323 /** The webhook route's file: where the walk starts. */
324 export const WEBHOOK_ENTRY = "src/http/stripe_hook.ts";
325
326 /** Gate form: one line per finding; a missing webhook is a finding, never a pass. */
327 export function destroyReachRefusal(root = "."): string[] {
328 if (!existsSync(join(root, WEBHOOK_ENTRY))) return [`${WEBHOOK_ENTRY} is not there: the walk has nothing to start from; if the webhook moved, change WEBHOOK_ENTRY in src/kernel/destroy_reach.ts`];
329 return destroyReach(root, WEBHOOK_ENTRY).map((f) => `the billing webhook reaches a deletion: ${f}`);
330 }
test/billing-never-deletes.test.ts added +196
@@ -0,0 +1,196 @@
1 /**
2 * #11392, part of #11140: losing someone's work is never the penalty for not
3 * paying. No call path from the billing webhook (`POST /hooks/stripe`,
4 * `src/http/stripe_hook.ts`, and what it uses in `src/billing/*`) removes a
5 * forge's or an app's directory, or runs the child-removal action. The walk
6 * is `src/kernel/destroy_reach.ts`; `bun scripts/gate.ts` runs it too.
7 */
8
9 import { describe, expect, test } from "bun:test";
10 import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { readdirSync } from "node:fs";
14 import { billingDeletesRefusal } from "../scripts/gate.ts";
15 import { ALLOWED_REMOVALS, destroyReach, destroyReachRefusal, layoutMisses, STOPS, unitsOf, WEBHOOK_ENTRY } from "../src/kernel/destroy_reach.ts";
16
17 const root = new URL("..", import.meta.url).pathname;
18
19 const tree = (files: Record<string, string>): string => {
20 const dir = mkdtempSync(join(tmpdir(), "reach-"));
21 for (const [path, text] of Object.entries(files)) {
22 mkdirSync(join(dir, path, ".."), { recursive: true });
23 writeFileSync(join(dir, path), text);
24 }
25 return dir;
26 };
27
28 describe("the billing webhook cannot delete", () => {
29 test("today's tree: nothing reachable from the webhook removes a forge, an app or a child", () => {
30 expect(destroyReach(root, WEBHOOK_ENTRY)).toEqual([]);
31 expect(destroyReachRefusal(root)).toEqual([]);
32 });
33
34 test("every stop and every allowance still means something, with a reason", () => {
35 const runtime = readFileSync(join(root, "src/apps/runtime.ts"), "utf8");
36 for (const { unit, why, from } of STOPS) {
37 expect(why.length).toBeGreaterThan(10);
38 expect(runtime).toContain(unit.split(".")[1]!);
39 // Each unit allowed to reach a stop exists in the tree.
40 for (const f of from) expect(unitsOf(root, f.split(":")[0]!).some((u) => u.name === f.split(":")[1])).toBe(true);
41 }
42 // With no allowance the walk finds each allowed unit: an entry that finds nothing is stale.
43 const found = destroyReach(root, WEBHOOK_ENTRY, []).join("\n");
44 for (const { unit, why } of ALLOWED_REMOVALS) {
45 expect(why.length).toBeGreaterThan(10);
46 expect(found).toContain(`${unit}]`);
47 }
48 });
49
50 test("the webhook cannot trigger the runtime's teardown: no reachable unit names repo.deleted, and the runtime's entries are entered only from the units allowed", () => {
51 expect(destroyReach(root, WEBHOOK_ENTRY).filter((f) => f.includes("names a removal") || f.includes("reaches "))).toEqual([]);
52 // The one name collision the walk lets through: `cells.start(` is the cell registry's.
53 const settle = unitsOf(root, "src/spawn/host.ts").find((u) => u.name === "settleCell")!;
54 expect(settle.text).toContain("cellChildren(");
55 expect(settle.text).not.toMatch(/appsRuntime\(|AppsRuntime/);
56 // And the walk does notice a webhook that appends it or boots an existing forge's apps.
57 const dir = tree({
58 "src/http/stripe_hook.ts": 'export function hook(app: any) {\n app.events.append({ kind: "repo.deleted" });\n}\nexport async function boot(rt: any) {\n await rt.reconcileApp(1);\n}\nclass AppsRuntime {\n async reconcileApp(a: number) {}\n}\n',
59 });
60 const bad = destroyReach(dir, WEBHOOK_ENTRY);
61 rmSync(dir, { recursive: true, force: true });
62 expect(bad.some((f) => f.includes("repo.deleted"))).toBe(true);
63 expect(bad.some((f) => f.includes("reaches AppsRuntime.reconcileApp"))).toBe(true);
64 });
65
66 test("a deliberate rmSync on a forge dir added to the real webhook handler is found, with its path", () => {
67 const copy = mkdtempSync(join(tmpdir(), "reach-real-"));
68 cpSync(join(root, "src"), join(copy, "src"), { recursive: true });
69 const hook = join(copy, WEBHOOK_ENTRY);
70 writeFileSync(
71 hook,
72 readFileSync(hook, "utf8") +
73 '\nexport function dropForge(app: App, bodyId: string) {\n rmSync(childDirOf(app, bodyId), { recursive: true, force: true });\n}\n',
74 );
75 const found = destroyReach(copy, WEBHOOK_ENTRY);
76 rmSync(copy, { recursive: true, force: true });
77 expect(found.length).toBe(1);
78 expect(found[0]).toContain(`${WEBHOOK_ENTRY}:`);
79 expect(found[0]).toContain("rmSync(childDirOf(app, bodyId)");
80 });
81
82 test("a removal two imports away, through a re-export and a namespace, is found by chain", () => {
83 const dir = tree({
84 "src/http/stripe_hook.ts": 'import { settle } from "../billing/settle.ts";\nexport function hook() {\n settle();\n}\n',
85 "src/billing/settle.ts": 'import * as gone from "./gone.ts";\nexport function settle() {\n gone.tidy();\n}\n',
86 "src/billing/gone.ts": 'export * from "../apps/data.ts";\n',
87 "src/apps/data.ts": 'import { rmSync } from "node:fs";\nexport function tidy() {\n rmSync(join(appsRoot("d"), "a"), { recursive: true });\n}\n',
88 });
89 const found = destroyReach(dir, WEBHOOK_ENTRY);
90 rmSync(dir, { recursive: true, force: true });
91 expect(found.length).toBe(1);
92 expect(found[0]).toContain("src/apps/data.ts:3:");
93 expect(found[0]).toContain("src/http/stripe_hook.ts:hook -> src/billing/settle.ts:settle -> src/apps/data.ts:tidy");
94 });
95
96 test("the child-removal action by name is found, as a call or a method", () => {
97 const dir = tree({
98 "src/http/stripe_hook.ts": 'export async function hook(app: any) {\n await app.forges.removeForge(app, "x");\n}\n',
99 });
100 const found = destroyReach(dir, WEBHOOK_ENTRY);
101 rmSync(dir, { recursive: true, force: true });
102 expect(found.length).toBe(1);
103 expect(found[0]).toContain("removeForge");
104 });
105
106 test("a removal nobody on the webhook's path calls is no finding; a scratch file is one unless its unit is allowed", () => {
107 const dir = tree({
108 "src/http/stripe_hook.ts": 'import { scratch } from "../billing/s.ts";\nexport function hook() {\n scratch();\n}\n',
109 "src/billing/s.ts": 'export function scratch() {\n rmSync("/tmp/x.tmp", { force: true });\n}\nexport function unrelated() {\n rmSync(childDirOf(a, b), { recursive: true });\n}\n',
110 });
111 const found = destroyReach(dir, WEBHOOK_ENTRY);
112 const allowed = destroyReach(dir, WEBHOOK_ENTRY, [{ unit: "src/billing/s.ts:scratch", why: "scratch file" }]);
113 rmSync(dir, { recursive: true, force: true });
114 expect(found.length).toBe(1);
115 expect(found[0]).toContain("s.ts:2:");
116 expect(allowed).toEqual([]);
117 });
118
119 const shapes: Record<string, string> = {
120 "a path held in a variable": 'import { rmSync } from "node:fs";\nexport function hook() {\n const d = childDirOf(app, id);\n rmSync(d, { recursive: true });\n}\n',
121 "a bare name that says nothing": 'import { rmSync } from "node:fs";\nexport function hook(dir: string) {\n rmSync(dir);\n}\n',
122 "a call over several lines": 'import { rmSync } from "node:fs";\nexport function hook() {\n rmSync(\n join(a, b),\n { recursive: true },\n );\n}\n',
123 "a member of a promises namespace": 'import fs from "node:fs";\nexport async function hook(target: string) {\n await fs.promises.rm(target);\n}\n',
124 "a namespace import": 'import * as fs from "node:fs/promises";\nexport async function hook(t: string) {\n await fs.rm(t);\n}\n',
125 "an aliased import": 'import { rmSync as r } from "node:fs";\nexport function hook(t: string) {\n r(t, { force: true });\n}\n',
126 "a destructured alias": 'import fs from "node:fs";\nexport function hook(t: string) {\n const { rmSync: gone } = fs;\n gone(t);\n}\n',
127 "unlink and rmdir": 'import { unlinkSync, rmdirSync } from "fs";\nexport function hook(t: string) {\n unlinkSync(t);\n rmdirSync(t);\n}\n',
128 };
129 for (const [name, text] of Object.entries(shapes))
130 test(`a removal is found without judging the path: ${name}`, () => {
131 const dir = tree({ "src/http/stripe_hook.ts": text });
132 const found = destroyReach(dir, WEBHOOK_ENTRY);
133 rmSync(dir, { recursive: true, force: true });
134 expect(found.length).toBeGreaterThan(0);
135 expect(found[0]).toContain("removes a file");
136 });
137
138 test("a call in a comment or a string-free name like `farm(` is not a removal", () => {
139 const dir = tree({ "src/http/stripe_hook.ts": '// rmSync(x)\n/* rm(y) */\nexport function hook() {\n farm(1);\n confirm(2);\n}\n' });
140 const found = destroyReach(dir, WEBHOOK_ENTRY);
141 rmSync(dir, { recursive: true, force: true });
142 expect(found).toEqual([]);
143 });
144
145 test("a unit on the allow-list may remove, and only that unit", () => {
146 const dir = tree({
147 "src/http/stripe_hook.ts": 'import { tidy } from "../billing/t.ts";\nexport function hook() {\n tidy();\n}\n',
148 "src/billing/t.ts": 'import { rmSync } from "node:fs";\nexport function tidy() {\n rmSync("/x");\n}\n',
149 });
150 const allowed = [{ unit: "src/billing/t.ts:tidy", why: "scratch" }];
151 expect(destroyReach(dir, WEBHOOK_ENTRY, allowed)).toEqual([]);
152 expect(destroyReach(dir, WEBHOOK_ENTRY, []).length).toBe(1);
153 rmSync(dir, { recursive: true, force: true });
154 });
155
156 test("a webhook that has moved is a refusal in the real tree, and the walk is skipped only in a fixture without it", () => {
157 const moved = tree({ "src/kernel/destroy_reach.ts": "// copy\n" });
158 expect(destroyReachRefusal(moved)[0]).toContain(`${WEBHOOK_ENTRY} is not there`);
159 expect(billingDeletesRefusal(moved)[0]).toContain("is not there");
160 rmSync(moved, { recursive: true, force: true });
161 const fixture = tree({ "README.md": "x" });
162 expect(billingDeletesRefusal(fixture)).toEqual([]);
163 rmSync(fixture, { recursive: true, force: true });
164 expect(billingDeletesRefusal(root)).toEqual([]);
165 });
166
167 test("an entry that parses to no unit, or code at an odd indent, is a finding and never a quiet pass", () => {
168 const empty = tree({ "src/http/stripe_hook.ts": " export function hook() {\n rmSync(x);\n }\n" });
169 const found = destroyReach(empty, WEBHOOK_ENTRY);
170 rmSync(empty, { recursive: true, force: true });
171 expect(found.length).toBeGreaterThan(0);
172 expect(found[0]).toContain("no unit parsed");
173 const odd = tree({
174 "src/http/stripe_hook.ts": 'import { a } from "../billing/a.ts";\nexport function hook() {\n a();\n}\n',
175 "src/billing/a.ts": 'export function a() {}\n export function hidden() {\n rmSync(x);\n }\n',
176 });
177 const miss = destroyReach(odd, WEBHOOK_ENTRY);
178 rmSync(odd, { recursive: true, force: true });
179 expect(miss.some((f) => f.includes("indented top-level code"))).toBe(true);
180 });
181
182 test("every unit of the entry and of src/billing was parsed: each exported name has a non-empty unit", () => {
183 const files = [WEBHOOK_ENTRY, ...readdirSync(join(root, "src/billing")).filter((f) => f.endsWith(".ts")).map((f) => `src/billing/${f}`)];
184 for (const file of files) {
185 expect(layoutMisses(root, file)).toEqual([]);
186 const units = unitsOf(root, file);
187 const text = readFileSync(join(root, file), "utf8");
188 const exported = [...text.matchAll(/^export\s+(?:default\s+)?(?:async\s+)?(?:function\*?|const|let|class|abstract\s+class)\s+([A-Za-z_$][\w$]*)/gm)].map((m) => m[1]!);
189 for (const name of exported) {
190 const unit = units.find((u) => u.name === name);
191 expect(unit, `${file}: ${name} was not parsed`).toBeDefined();
192 expect(unit!.text.trim().length).toBeGreaterThan(name.length);
193 }
194 }
195 });
196 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs