@pi-archimedes/core 2.12.0 → 2.14.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/README.md CHANGED
@@ -34,6 +34,6 @@ After installing Pi, choose one installation command above, then `cd` into your
34
34
 
35
35
  ## Part of the suite
36
36
 
37
- In [pi-archimedes](https://github.com/danielcherubini/pi-archimedes), core is always registered — it isn't one of the `/plugins` toggles — and it underpins what the other components share: the channel vocabulary that carries subagent todos to the task board and subagent questions to the ask UI, and the bridge that reaches the Archimedes Client. The diff renderer is standalone and does not depend on core's chrome — it only shares the suite when it loads.
37
+ In [pi-archimedes](https://github.com/archimedes-codes/pi-archimedes), core is always registered — it isn't one of the `/plugins` toggles — and it underpins what the other components share: the channel vocabulary that carries subagent todos to the task board and subagent questions to the ask UI, and the bridge that reaches the Archimedes Client. The diff renderer is standalone and does not depend on core's chrome — it only shares the suite when it loads.
38
38
 
39
- ← [Back to pi-archimedes](https://github.com/danielcherubini/pi-archimedes)
39
+ ← [Back to pi-archimedes](https://github.com/archimedes-codes/pi-archimedes)
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@pi-archimedes/core",
3
- "version": "2.12.0",
3
+ "version": "2.14.0",
4
4
  "repository": {
5
5
  "type": "git",
6
- "url": "https://github.com/danielcherubini/pi-archimedes.git"
6
+ "url": "https://github.com/archimedes-codes/pi-archimedes.git"
7
7
  },
8
8
  "type": "module",
9
9
  "keywords": [
package/src/overlay.ts CHANGED
@@ -6,7 +6,8 @@
6
6
  *
7
7
  * All terminal-width maths here is delegated to `@earendil-works/pi-tui`:
8
8
  * `visibleWidth` for measurement, `truncateToWidth` for cutting and padding,
9
- * `wrapTextWithAnsi` for word wrapping. `docs/tui.md` asks for exactly that,
9
+ * `wrapTextWithAnsi` for word wrapping. pi's own `docs/tui.md` — shipped in
10
+ * `@earendil-works/pi-coding-agent`, not this repo — asks for exactly that,
10
11
  * and a local re-implementation cannot be right: measuring `.length` after
11
12
  * stripping only `\x1b[…m` counts pi's `CURSOR_MARKER` (an APC sequence) as
12
13
  * seven drawn columns, counts an OSC 8 hyperlink's URL as drawn text, and
@@ -0,0 +1,1245 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
3
+ import { dirname, join, normalize, relative } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ // ── The rule (issue #156, docs/features/session-state.md) ───────────────────
7
+ //
8
+ // Pi caches an extension MODULE per process and re-invokes its factory with a
9
+ // fresh `pi` for every session. A `let` at module scope therefore belongs to the
10
+ // PROCESS, not to a session — and a subordinate in-process session (a subagent
11
+ // child, bound with no uiContext) running its `session_shutdown` clears the LIVE
12
+ // session's state through it, silently. That is #156: `image-paste`, `ui` and
13
+ // `meta` all shipped the shape, and no test caught it — `packages/subagent`
14
+ // forks a separate process, so the suite's own package cannot reproduce it.
15
+ //
16
+ // The rule is NOT "no `let` at module scope". Plenty of module state is honestly
17
+ // process-wide and must not be flagged: the footer's git/jj/token-stat caches,
18
+ // `core/src/bridge/channel.ts` (one socket per process), `diff/src/shiki.ts`,
19
+ // `diff/src/ansi/codes.ts`, `diff/src/render/shared.ts`, `ui/src/thinking/theme.ts`.
20
+ // What makes a module binding a bug is that a SESSION HANDLER can write it —
21
+ // `session_start`, `session_shutdown`, `session_before_switch` or `turn_start`
22
+ // are the handlers pi fires per session, so anything they assign is per-session
23
+ // state wearing process clothing.
24
+ //
25
+ // So the rule is ASSIGNMENT REACHABILITY over three hops:
26
+ // hop 1 a module-scope `let`/`var` assigned inside such a handler body, or
27
+ // hop 2 assigned inside the body of a function that a handler body calls
28
+ // directly, IN THE SAME FILE, or
29
+ // hop 3 assigned in ANOTHER FILE, inside the body of a function the handler
30
+ // body calls through a NAMED import (see blind spot 1 for what that
31
+ // excludes).
32
+ // Hop 2 is mandatory, not thoroughness: #156's actual assignment was
33
+ // `_queue = null` inside `shutdownQueue()`/`initImagePasteSession()`, one call
34
+ // away from the handler. A one-hop rule reports green over the bug that shipped,
35
+ // which is the exact failure this file exists to prevent. The anti-vacuity
36
+ // fixture below is that shape and nothing weaker.
37
+ //
38
+ // The escape hatch is the one Decision 2 chose: key the state by session id in a
39
+ // `Map` and mutate it through `.set()`/`.delete()`. The contents change, the
40
+ // binding does not, so nothing is assigned and the guard stays green — the
41
+ // liveness test pins the `let`-Map form specifically, because a `const` Map
42
+ // would pass for the wrong reason (the collector never looks at `const` at all).
43
+ //
44
+ // ── Blind spots, stated plainly: this is a BLIND SPOT, NOT COVERAGE ─────────
45
+ //
46
+ // 1. ONE IMPORT HOP, NAMED IMPORTS ONLY. The rule follows a handler body across
47
+ // exactly ONE module boundary: a function the handler calls by name that the
48
+ // file IMPORTS by name (`import { setConfigGetter } from "./shiki.js"`, with
49
+ // `as` aliases honoured and `import type` ignored), resolved through a barrel
50
+ // (`export { x } from "./other.js"`, also with renames, bounded so a cycle
51
+ // cannot hang the test) to the file that declares it. Inside that TARGET file
52
+ // the walk is transitive — the entry function plus anything it calls there.
53
+ // The hit is reported against the TARGET file and the target's declaration
54
+ // line, so an allow-list entry still pins `file + exact trimmed declaration`.
55
+ // Two walls remain, and both are deliberate:
56
+ // - NAMESPACE IMPORTS are not followed. `CALLS` cannot match `events.onSessionStart(ctx)`,
57
+ // and `import * as events` hands the resolver no symbol to look up, so
58
+ // `core/src/bridge/index.ts:160` → `core/src/bridge/events.ts:38`
59
+ // (`sessionCtx`) stays invisible. Following it would newly flag the five
60
+ // honest one-socket-per-process bindings in `core/src/bridge/` with no
61
+ // exemption category to put them in.
62
+ // - DYNAMIC `import()` is not followed: `meta/src/index.ts:162` reaching
63
+ // `@pi-archimedes/diff` from its own `session_start` stays invisible.
64
+ // The `NAMESPACE_IMPORT_NOT_FOLLOWED` fixture pins the first wall so widening
65
+ // it is an intentional edit rather than an accident. What this hop buys is
66
+ // concrete: `ui/src/index.ts:183`'s `session_shutdown` → `stopStartupListing()`
67
+ // at `:215` → `frameTimer`/`revealTimer` at `ui/src/startup/index.ts:171-172`
68
+ // is now a REAL hit that needs a REAL entry (it has two), not a green the
69
+ // guard grants because it cannot see.
70
+ // 2. An assignment inside a closure a handler passes ELSEWHERE is not followed
71
+ // either — the callback is not a `pi.on` handler body. image-paste's
72
+ // `registerShortcut` callback assigned `_pasting` pre-fix and would not be
73
+ // reached by this rule on its own. That is precisely why the anti-vacuity
74
+ // fixture reproduces the shape that IS followed (a named function a handler
75
+ // calls) rather than the one that is not. Hop 3 respects this wall too: it
76
+ // collects the handler body's call surface with nested closures blanked, so a
77
+ // call made inside `ctx.ui.setFooter((…) => { render() { … } })` never starts
78
+ // one — which is what keeps the footer's git/jj/token-stat caches, named
79
+ // above as honest process state, out of the report.
80
+ // 3. The assignment test is `name = …`. Compound and logical assignment
81
+ // (`count += 1`, `cache ||= …`) is not matched, and neither is a handler
82
+ // registered through anything other than a literal `pi.on("…")` call.
83
+ //
84
+ // Widening any of these is a change to the rule, not to this file's allow-list.
85
+
86
+ const SESSION_EVENTS = ["session_start", "session_shutdown", "session_before_switch", "turn_start"];
87
+
88
+ interface ViolationRule {
89
+ /** What shape of per-session state at module scope this catches. */
90
+ readonly label: string;
91
+ readonly pattern: RegExp;
92
+ }
93
+
94
+ /**
95
+ * The collector: module-scope mutable bindings. Anchored at column 0 (with an
96
+ * optional `export`), which is what "module scope" means in a tree that indents
97
+ * every function body — a `let` inside `register()` is per-registration and is
98
+ * exactly what the rule asks for, so it must not be collected. `const` is out of
99
+ * scope by design: a `const` binding cannot be assigned, and a session-keyed
100
+ * `const` Map is the prescribed fix.
101
+ */
102
+ const COLLECTOR: ViolationRule = {
103
+ label: "module-scope binding assigned by a session handler",
104
+ pattern: /^(?:export\s+)?(?:let|var)\s+(\w+)/m,
105
+ };
106
+
107
+ const RULES: readonly ViolationRule[] = [COLLECTOR];
108
+
109
+ /**
110
+ * Known violations, each with the one-line reason that keeps it, and the
111
+ * removal point. Matched on the file plus the EXACT TRIMMED LINE — the guard
112
+ * reports the DECLARATION line of the offending binding, so these are
113
+ * declaration lines, not assignment sites — so a *new* violation in an exempt
114
+ * file still fails the guard.
115
+ */
116
+ interface AllowEntry {
117
+ readonly file: string;
118
+ readonly line: string;
119
+ readonly reason: string;
120
+ }
121
+
122
+ const ALLOW_LIST: readonly AllowEntry[] = [
123
+ {
124
+ file: "packages/notify/src/index.ts",
125
+ line: "let notifyTimer: ReturnType<typeof setTimeout> | null = null;",
126
+ reason:
127
+ "The circuit breaker's pending timer. A foreign session's shutdown runs cancelPending() and cancels the LIVE session's delayed notification. Minor (a missed alert, not a dead feature), so deferred rather than widened into the rule. follow-up: tsk_9a0926e5-2d94-43bd-941a-ccf9ffe06bd7",
128
+ },
129
+ {
130
+ file: "packages/notify/src/index.ts",
131
+ line: "let pendingTrigger: TriggerType | null = null;",
132
+ reason:
133
+ "The trigger the pending timer will fire, same mechanism and same deferral as notifyTimer: a foreign shutdown clears it and the live session's notification is lost. follow-up: tsk_9a0926e5-2d94-43bd-941a-ccf9ffe06bd7",
134
+ },
135
+ {
136
+ file: "packages/ui/src/startup/index.ts",
137
+ line: "let frameTimer: ReturnType<typeof setInterval> | undefined;",
138
+ reason:
139
+ "The splash's frame timer, cleared by `stopStartupListing()`; reached from `packages/ui/src/index.ts`'s `session_shutdown` one module away and ownership-gated by `weInstalledTheSplash`, so a foreign session's shutdown cannot reach it — worst case a cut-short splash reveal. Deferred, not fixed. follow-up: tsk_00a5ad67-8bc1-42e4-b999-d75196ec37c2",
140
+ },
141
+ {
142
+ file: "packages/ui/src/startup/index.ts",
143
+ line: "let revealTimer: ReturnType<typeof setTimeout> | undefined;",
144
+ reason:
145
+ "The splash's reveal timer, same mechanism as frameTimer: cleared by `stopStartupListing()`, reached from `packages/ui/src/index.ts`'s `session_shutdown` one module away, gated by `weInstalledTheSplash`. Worst case a cut-short splash reveal. Deferred, not fixed. follow-up: tsk_00a5ad67-8bc1-42e4-b999-d75196ec37c2",
146
+ },
147
+ ];
148
+
149
+ // ── Walking the tree ────────────────────────────────────────────────────────
150
+
151
+ /** Repo root, resolved from this file (`packages/core/src` → repo root). */
152
+ const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
153
+
154
+ // Every `packages/<name>/src` directory, plus `meta/src`.
155
+ function sourceDirs(): string[] {
156
+ const dirs: string[] = [];
157
+ for (const entry of readdirSync(join(REPO_ROOT, "packages"), { withFileTypes: true })) {
158
+ if (!entry.isDirectory()) continue;
159
+ const src = join(REPO_ROOT, "packages", entry.name, "src");
160
+ if (existsSync(src)) dirs.push(src);
161
+ }
162
+ const metaSrc = join(REPO_ROOT, "meta", "src");
163
+ if (existsSync(metaSrc)) dirs.push(metaSrc);
164
+ return dirs;
165
+ }
166
+
167
+ /** Source files under those trees, excluding tests (which fake sessions). */
168
+ function collectSourceFiles(): string[] {
169
+ const out: string[] = [];
170
+ const walk = (dir: string): void => {
171
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
172
+ const full = join(dir, entry.name);
173
+ if (entry.isDirectory()) {
174
+ if (entry.name !== "node_modules") walk(full);
175
+ } else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) {
176
+ out.push(relative(REPO_ROOT, full).split(/[\\/]/).join("/"));
177
+ }
178
+ }
179
+ };
180
+ for (const dir of sourceDirs()) walk(dir);
181
+ return out.sort();
182
+ }
183
+
184
+ // ── The matcher ─────────────────────────────────────────────────────────────
185
+
186
+ export interface Hit {
187
+ file: string;
188
+ line: number;
189
+ rule: string;
190
+ text: string;
191
+ }
192
+
193
+ /** Index just past the closing quote of the string literal starting at `open`. */
194
+ function skipQuoted(src: string, open: number): number {
195
+ const quote = src[open];
196
+ for (let i = open + 1; i < src.length; i++) {
197
+ if (src[i] === "\\") i += 1;
198
+ else if (src[i] === quote) return i + 1;
199
+ }
200
+ return src.length;
201
+ }
202
+
203
+ /** Index just past the closing backtick of the template literal at `open`. */
204
+ function skipTemplate(src: string, open: number): number {
205
+ for (let i = open + 1; i < src.length; i++) {
206
+ if (src[i] === "\\") i += 1;
207
+ else if (src[i] === "`") return i + 1;
208
+ else if (src[i] === "$" && src[i + 1] === "{") {
209
+ const past = matchBracket(src, i + 1);
210
+ i = past === -1 ? src.length : past - 1;
211
+ }
212
+ }
213
+ return src.length;
214
+ }
215
+
216
+ /** Step over a comment, returning the next index to examine. */
217
+ function skipComment(src: string, i: number): number {
218
+ if (src[i + 1] === "/") {
219
+ const nl = src.indexOf("\n", i);
220
+ return nl === -1 ? src.length : nl;
221
+ }
222
+ if (src[i + 1] === "*") {
223
+ const close = src.indexOf("*/", i + 2);
224
+ return close === -1 ? src.length : close + 2;
225
+ }
226
+ return i + 1;
227
+ }
228
+
229
+ /** Index just past the bracket matching the opener at `open`, or -1 if unclosed. */
230
+ function matchBracket(src: string, open: number): number {
231
+ const stack: string[] = [];
232
+ let i = open;
233
+ while (i < src.length) {
234
+ const c = src[i];
235
+ if (c === "/" && (src[i + 1] === "/" || src[i + 1] === "*")) {
236
+ i = skipComment(src, i);
237
+ continue;
238
+ }
239
+ if (c === '"' || c === "'") {
240
+ i = skipQuoted(src, i);
241
+ continue;
242
+ }
243
+ if (c === "`") {
244
+ i = skipTemplate(src, i);
245
+ continue;
246
+ }
247
+ if (c === "(" || c === "[" || c === "{") {
248
+ stack.push(c === "(" ? ")" : c === "[" ? "]" : "}");
249
+ i += 1;
250
+ continue;
251
+ }
252
+ if (c === ")" || c === "]" || c === "}") {
253
+ if (stack.pop() !== c) return -1;
254
+ i += 1;
255
+ if (stack.length === 0) return i;
256
+ continue;
257
+ }
258
+ i += 1;
259
+ }
260
+ return -1;
261
+ }
262
+
263
+ /** Split `src[start, end)` on its top-level commas. */
264
+ function splitTopLevel(src: string, start: number, end: number): Array<[number, number]> {
265
+ const spans: Array<[number, number]> = [];
266
+ let depth = 0;
267
+ let from = start;
268
+ let i = start;
269
+ while (i < end) {
270
+ const c = src[i];
271
+ if (c === "/" && (src[i + 1] === "/" || src[i + 1] === "*")) {
272
+ i = skipComment(src, i);
273
+ continue;
274
+ }
275
+ if (c === '"' || c === "'") {
276
+ i = skipQuoted(src, i);
277
+ continue;
278
+ }
279
+ if (c === "`") {
280
+ i = skipTemplate(src, i);
281
+ continue;
282
+ }
283
+ if (c === "(" || c === "[" || c === "{") depth += 1;
284
+ else if (c === ")" || c === "]" || c === "}") depth -= 1;
285
+ else if (c === "," && depth === 0) {
286
+ spans.push([from, i]);
287
+ from = i + 1;
288
+ }
289
+ i += 1;
290
+ }
291
+ spans.push([from, end]);
292
+ return spans;
293
+ }
294
+
295
+ /** First index of `needle` in `src[start, end)`, or -1. */
296
+ function indexOfIn(src: string, needle: string, start: number, end: number): number {
297
+ const at = src.indexOf(needle, start);
298
+ return at >= end ? -1 : at;
299
+ }
300
+
301
+ /** First whole-word occurrence of `word` in `src[start, end)`, or -1. */
302
+ function indexOfWord(src: string, word: string, start: number, end: number): number {
303
+ const re = new RegExp(`\\b${word}\\b`, "g");
304
+ re.lastIndex = start;
305
+ for (let m = re.exec(src); m !== null; m = re.exec(src)) {
306
+ if (m.index >= end) break;
307
+ return m.index;
308
+ }
309
+ return -1;
310
+ }
311
+
312
+ /**
313
+ * The body of the function expression written in `src[start, end)`: an arrow
314
+ * (`(x) => { … }` or `(x) => expr`), a `function` expression, or an `async`
315
+ * variant of either. Arrow bodies without braces are the whole rest of the
316
+ * expression, which is what `pi.on("input", () => cancelPending())` means.
317
+ */
318
+ function lambdaBody(src: string, start: number, end: number): string | undefined {
319
+ const fnIdx = indexOfWord(src, "function", start, end);
320
+ const arrowIdx = indexOfIn(src, "=>", start, end);
321
+
322
+ if (fnIdx !== -1 && (arrowIdx === -1 || fnIdx < arrowIdx)) {
323
+ const params = src.indexOf("(", fnIdx);
324
+ if (params === -1 || params >= end) return undefined;
325
+ const pastParams = matchBracket(src, params);
326
+ if (pastParams === -1 || pastParams > end) return undefined;
327
+ const brace = src.indexOf("{", pastParams);
328
+ if (brace === -1 || brace >= end) return undefined;
329
+ const pastBody = matchBracket(src, brace);
330
+ if (pastBody === -1) return undefined;
331
+ return src.slice(brace + 1, pastBody - 1);
332
+ }
333
+
334
+ if (arrowIdx === -1) return undefined;
335
+ let i = arrowIdx + 2;
336
+ while (i < end && (src[i] === " " || src[i] === "\t" || src[i] === "\n" || src[i] === "\r")) i += 1;
337
+ if (src.startsWith("async", i)) {
338
+ i += 5;
339
+ while (i < end && /\s/.test(src[i] ?? "")) i += 1;
340
+ }
341
+ if (src[i] === "{") {
342
+ const pastBody = matchBracket(src, i);
343
+ return pastBody === -1 ? undefined : src.slice(i + 1, pastBody - 1);
344
+ }
345
+ return src.slice(i, end);
346
+ }
347
+
348
+ /** The body text of every `pi.on("<session event>", …)` handler in `src`. */
349
+ function handlerBodies(src: string): string[] {
350
+ const call = new RegExp(`\\bpi\\.on\\(\\s*["'](?:${SESSION_EVENTS.join("|")})["']`, "g");
351
+ const bodies: string[] = [];
352
+ for (let m = call.exec(src); m !== null; m = call.exec(src)) {
353
+ const open = src.indexOf("(", m.index);
354
+ const end = matchBracket(src, open);
355
+ if (end === -1) continue;
356
+ const args = splitTopLevel(src, open + 1, end - 1);
357
+ const handler = args[1];
358
+ if (!handler) continue;
359
+ const body = lambdaBody(src, handler[0], handler[1]);
360
+ if (body !== undefined) bodies.push(body);
361
+ }
362
+ return bodies;
363
+ }
364
+
365
+ /** Index of the assignment `=` of a declaration whose name starts at `from`. */
366
+ function assignmentAt(src: string, from: number): number {
367
+ let depth = 0;
368
+ let i = from;
369
+ while (i < src.length) {
370
+ const c = src[i];
371
+ if (c === "/" && (src[i + 1] === "/" || src[i + 1] === "*")) {
372
+ i = skipComment(src, i);
373
+ continue;
374
+ }
375
+ if (c === '"' || c === "'") {
376
+ i = skipQuoted(src, i);
377
+ continue;
378
+ }
379
+ if (c === "`") {
380
+ i = skipTemplate(src, i);
381
+ continue;
382
+ }
383
+ if (c === "(" || c === "[" || c === "{") depth += 1;
384
+ else if (c === ")" || c === "]" || c === "}") {
385
+ if (depth === 0) return -1;
386
+ depth -= 1;
387
+ } else if (c === ";" && depth === 0) return -1;
388
+ else if (c === "=" && depth === 0) {
389
+ const prev = src.slice(0, i).trimEnd().slice(-1);
390
+ if (src[i + 1] === "=" || src[i - 1] === "=") {
391
+ i += 1;
392
+ continue;
393
+ }
394
+ if (src[i + 1] === ">" || "+-*/%&|^!<>".includes(prev)) {
395
+ i += 1;
396
+ continue;
397
+ }
398
+ return i;
399
+ }
400
+ i += 1;
401
+ }
402
+ return -1;
403
+ }
404
+
405
+ /**
406
+ * Named functions this file can resolve a call to: every `function name(…)`,
407
+ * plus every module-scope binding initialised from a function expression. This
408
+ * is hop 2's lookup table — a call a handler body makes is only followed when it
409
+ * names one of these, in this file.
410
+ */
411
+ function functionBodies(src: string): Map<string, string> {
412
+ const bodies = new Map<string, string>();
413
+
414
+ for (const m of src.matchAll(/^(?:\s*)(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm)) {
415
+ const name = m[1];
416
+ if (!name) continue;
417
+ const keyword = src.indexOf("function", m.index);
418
+ const body = lambdaBody(src, keyword, src.length);
419
+ if (body !== undefined && !bodies.has(name)) bodies.set(name, body);
420
+ }
421
+
422
+ for (const m of src.matchAll(/^(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)/gm)) {
423
+ const name = m[1];
424
+ if (!name) continue;
425
+ const equal = assignmentAt(src, m.index + m[0].length);
426
+ if (equal === -1) continue;
427
+ const body = lambdaBody(src, equal + 1, statementEnd(src, equal));
428
+ if (body !== undefined && !bodies.has(name)) bodies.set(name, body);
429
+ }
430
+
431
+ return bodies;
432
+ }
433
+
434
+ /** End of the statement containing `at` (the next top-level `;`, else EOF). */
435
+ function statementEnd(src: string, at: number): number {
436
+ let depth = 0;
437
+ let i = at;
438
+ while (i < src.length) {
439
+ const c = src[i];
440
+ if (c === "/" && (src[i + 1] === "/" || src[i + 1] === "*")) {
441
+ i = skipComment(src, i);
442
+ continue;
443
+ }
444
+ if (c === '"' || c === "'") {
445
+ i = skipQuoted(src, i);
446
+ continue;
447
+ }
448
+ if (c === "`") {
449
+ i = skipTemplate(src, i);
450
+ continue;
451
+ }
452
+ if (c === "(" || c === "[" || c === "{") depth += 1;
453
+ else if (c === ")" || c === "]" || c === "}") depth -= 1;
454
+ else if (c === ";" && depth === 0) return i;
455
+ i += 1;
456
+ }
457
+ return src.length;
458
+ }
459
+
460
+ /** Names a body calls as a plain function (not `obj.method()`, not keywords). */
461
+ const CALLS = /(?<![\w.])([A-Za-z_$][\w$]*)\s*\(/g;
462
+ const NON_FUNCTIONS = new Set([
463
+ "if", "for", "while", "switch", "catch", "return", "typeof", "function", "await", "new", "do", "else",
464
+ ]);
465
+
466
+ function calledNames(body: string): string[] {
467
+ const names: string[] = [];
468
+ const re = new RegExp(CALLS.source, "g");
469
+ for (let m = re.exec(body); m !== null; m = re.exec(body)) {
470
+ const name = m[1];
471
+ if (name && !NON_FUNCTIONS.has(name)) names.push(name);
472
+ }
473
+ return names;
474
+ }
475
+
476
+ // ── The cross-module hop ────────────────────────────────────────────────────
477
+ //
478
+ // How far a name resolves, and how many boundaries the walk is allowed to cross.
479
+ // The hop is ONE boundary deep (the handler's file → the file it imports from),
480
+ // plus transitive WITHIN that target file. The depth bound below is not that
481
+ // boundary count — it is the barrel chain the resolver may follow to find where
482
+ // an imported symbol is actually declared, so a re-export cycle cannot hang the
483
+ // test.
484
+
485
+ const MAX_BARREL_HOPS = 4;
486
+
487
+ /** Where a local name came from: the symbol it is, and the specifier it came in on. */
488
+ interface ImportRef {
489
+ readonly symbol: string;
490
+ readonly spec: string;
491
+ }
492
+
493
+ const IDENT = "([A-Za-z_$][\\w$]*)";
494
+
495
+ function escapeName(name: string): string {
496
+ return name.replace(/\$/g, "\\$");
497
+ }
498
+
499
+ /**
500
+ * `localName → { symbol, spec }` for every NAMED import in `src`, `as` aliases
501
+ * included. `import type { … }` and `type`-marked specifiers are skipped — they
502
+ * name types, so a call site can never reach them — and so are default and
503
+ * `import * as ns` forms: a namespace import gives the guard no symbol to look
504
+ * up, which is the wall blind spot 1 still has.
505
+ */
506
+ function parseImports(src: string): Map<string, ImportRef> {
507
+ const imports = new Map<string, ImportRef>();
508
+ for (const m of src.matchAll(/^import\s+([\s\S]*?)\s+from\s*["']([^"']+)["']/gm)) {
509
+ const clause = m[1] ?? "";
510
+ const spec = m[2] ?? "";
511
+ if (/^\s*type\b/.test(clause)) continue;
512
+ const open = clause.indexOf("{");
513
+ const close = clause.lastIndexOf("}");
514
+ if (open === -1 || close <= open) continue;
515
+ for (const part of clause.slice(open + 1, close).split(",")) {
516
+ const text = part.trim();
517
+ if (text.length === 0 || /^type\s/.test(text)) continue;
518
+ const m = new RegExp(`^${IDENT}(?:\\s+as\\s+${IDENT})?$`).exec(text);
519
+ if (m === null) continue;
520
+ const symbol = m[1];
521
+ const local = m[2] ?? m[1];
522
+ if (symbol && local) imports.set(local, { symbol, spec });
523
+ }
524
+ }
525
+ return imports;
526
+ }
527
+
528
+ /** A path the tree can read: `.js` entry points are `.ts` sources in this repo. */
529
+ function tsVariants(path: string): string[] {
530
+ return /\.(?:js|mjs|cjs)$/.test(path) ? [path.replace(/\.(js|mjs|cjs)$/, ".ts"), path] : [path];
531
+ }
532
+
533
+ function firstReadable(paths: readonly string[], tree: SourceTree): string | undefined {
534
+ for (const path of paths) {
535
+ if (tree.read(path) !== undefined) return path;
536
+ }
537
+ return undefined;
538
+ }
539
+
540
+ /** `packages/diff/src/index.ts` + `./render/index.js` → `packages/diff/src/render/index.ts`. */
541
+ function resolveRelative(fromFile: string, spec: string, tree: SourceTree): string | undefined {
542
+ const base = normalize(join(dirname(fromFile), spec));
543
+ return firstReadable(tsVariants(base).flatMap((p) => [p, `${p}/index.ts`]), tree);
544
+ }
545
+
546
+ /**
547
+ * `@pi-archimedes/<pkg>[/<sub>]` → the file that package's `exports` map points
548
+ * at, falling back to `packages/<pkg>/src/<sub>.ts`. Anything else — the
549
+ * `@earendil-works/*` host packages, `node:*`, bare npm names — resolves to
550
+ * nothing: the guard does not read outside this repo.
551
+ */
552
+ function resolveWorkspacePackage(pkg: string | undefined, sub: string | undefined, tree: SourceTree): string | undefined {
553
+ if (!pkg) return undefined;
554
+ const manifest = tree.read(`packages/${pkg}/package.json`);
555
+ if (manifest !== undefined) {
556
+ try {
557
+ const exportsField: unknown = (JSON.parse(manifest) as { exports?: unknown }).exports;
558
+ if (exportsField !== null && typeof exportsField === "object" && !Array.isArray(exportsField)) {
559
+ const value = (exportsField as Record<string, unknown>)[sub === undefined ? "." : `./${sub}`];
560
+ if (typeof value === "string") {
561
+ const target = firstReadable(tsVariants(normalize(`packages/${pkg}/${value.replace(/^\.\//, "")}`)), tree);
562
+ if (target !== undefined) return target;
563
+ }
564
+ }
565
+ } catch {
566
+ // An unreadable manifest is simply not a hop.
567
+ }
568
+ }
569
+ return firstReadable(tsVariants(sub === undefined ? `packages/${pkg}/src/index.ts` : `packages/${pkg}/src/${sub}.ts`), tree);
570
+ }
571
+
572
+ function resolveSpec(fromFile: string, spec: string, tree: SourceTree): string | undefined {
573
+ if (spec.startsWith("./") || spec.startsWith("../")) return resolveRelative(fromFile, spec, tree);
574
+ const pkg = /^@pi-archimedes\/([^/]+)(?:\/(.+))?$/.exec(spec);
575
+ if (pkg) return resolveWorkspacePackage(pkg[1], pkg[2], tree);
576
+ return undefined;
577
+ }
578
+
579
+ /**
580
+ * Where `symbol` is actually declared, following `export { symbol } from "./other.js"`
581
+ * barrels (honouring `as` renames) until a real `export function` / `const` /
582
+ * `let` shows up. Returns the file plus the name it is declared under there, or
583
+ * undefined when the chain runs out or cycles past `MAX_BARREL_HOPS`.
584
+ */
585
+ function resolveDeclaration(
586
+ file: string,
587
+ symbol: string,
588
+ tree: SourceTree,
589
+ depth = 0,
590
+ ): { file: string; symbol: string } | undefined {
591
+ const src = tree.read(file);
592
+ if (src === undefined) return undefined;
593
+ const declares = new RegExp(`^export\\s+(?:async\\s+)?(?:function|const|let|var)\\s+${escapeName(symbol)}\\b`, "m");
594
+ if (declares.test(src)) return { file, symbol };
595
+ if (depth >= MAX_BARREL_HOPS) return undefined;
596
+
597
+ for (const m of src.matchAll(/^export\s+(?!type\s)\{([^}]*)\}\s*from\s*["']([^"']+)["']/gm)) {
598
+ const spec = m[2] ?? "";
599
+ for (const part of (m[1] ?? "").split(",")) {
600
+ const m2 = new RegExp(`^${IDENT}(?:\\s+as\\s+${IDENT})?$`).exec(part.trim());
601
+ if (m2 === null) continue;
602
+ const declared = m2[1];
603
+ const exported = m2[2] ?? m2[1];
604
+ if (!declared || exported !== symbol) continue;
605
+ const next = resolveSpec(file, spec, tree);
606
+ if (next === undefined || next === file) continue;
607
+ const found = resolveDeclaration(next, declared, tree, depth + 1);
608
+ if (found !== undefined) return found;
609
+ }
610
+ }
611
+ return undefined;
612
+ }
613
+
614
+ /** Module-scope bindings `src` declares, with their declaration line and text. */
615
+ function moduleDeclarations(src: string): Array<{ name: string; line: number; text: string }> {
616
+ const declarations: Array<{ name: string; line: number; text: string }> = [];
617
+ src.split("\n").forEach((text, i) => {
618
+ for (const rule of RULES) {
619
+ const m = rule.pattern.exec(text);
620
+ const name = m?.[1];
621
+ if (name) declarations.push({ name, line: i + 1, text: text.trim() });
622
+ }
623
+ });
624
+ return declarations;
625
+ }
626
+
627
+ /** Matches an assignment (never a comparison, never a compound form) to `name`. */
628
+ function assignmentPattern(name: string): RegExp {
629
+ return new RegExp(`(?<![\\w.])${escapeName(name)}\\s*=[^=]`);
630
+ }
631
+
632
+ /**
633
+ * Every body in `src` the entry function can reach by calling other functions of
634
+ * the SAME file, transitively — the target half of the cross-module hop. Bounded
635
+ * by the `seen` set, so a call cycle terminates.
636
+ */
637
+ function bodiesReachedFrom(entry: string, scopes: Map<string, string>): Set<string> {
638
+ const bodies = new Set<string>();
639
+ const seen = new Set<string>();
640
+ const queue: string[] = [entry];
641
+ while (queue.length > 0) {
642
+ const name = queue.shift();
643
+ if (name === undefined || seen.has(name)) continue;
644
+ seen.add(name);
645
+ const body = scopes.get(name);
646
+ if (body === undefined) continue;
647
+ bodies.add(body);
648
+ queue.push(...calledNames(body));
649
+ }
650
+ return bodies;
651
+ }
652
+
653
+ /**
654
+ * Hop 3's target side: the bindings THIS file declares that the entry function
655
+ * (or anything it calls, in this file) assigns. Reported against this file and
656
+ * this file's declaration line, so the allow-list keeps matching
657
+ * `file + exact trimmed declaration line`.
658
+ */
659
+ function violationsAtTarget(file: string, entry: string, tree: SourceTree): Hit[] {
660
+ const src = tree.read(file);
661
+ if (src === undefined) return [];
662
+ const declarations = moduleDeclarations(src);
663
+ if (declarations.length === 0) return [];
664
+ const bodies = bodiesReachedFrom(entry, functionBodies(src));
665
+ if (bodies.size === 0) return [];
666
+ return declarations
667
+ .filter((decl) => {
668
+ const assigned = assignmentPattern(decl.name);
669
+ return [...bodies].some((body) => assigned.test(body));
670
+ })
671
+ .map((decl) => ({ file, line: decl.line, rule: COLLECTOR.label, text: decl.text }));
672
+ }
673
+
674
+ /** End of an arrow's expression body: the next TOP-LEVEL `,` `)` `]` `;` or `}`.
675
+ * Only a closing bracket lowers the depth — a `,` or `;` nested inside a call,
676
+ * as in `() => format(a, b) + touch()`, belongs to that call's argument list,
677
+ * not to the arrow's own expression, and ending there would leave everything
678
+ * after the comma unmasked. */
679
+ function expressionEnd(src: string, from: number): number {
680
+ let depth = 0;
681
+ let i = from;
682
+ while (i < src.length) {
683
+ const c = src[i];
684
+ if (c === "/" && (src[i + 1] === "/" || src[i + 1] === "*")) {
685
+ i = skipComment(src, i);
686
+ continue;
687
+ }
688
+ if (c === '"' || c === "'") {
689
+ i = skipQuoted(src, i);
690
+ continue;
691
+ }
692
+ if (c === "`") {
693
+ i = skipTemplate(src, i);
694
+ continue;
695
+ }
696
+ if (c === "(" || c === "[" || c === "{") {
697
+ depth += 1;
698
+ } else if (c === ")" || c === "]" || c === "}") {
699
+ if (depth === 0) return i;
700
+ depth -= 1;
701
+ } else if ((c === ";" || c === ",") && depth === 0) {
702
+ return i;
703
+ }
704
+ i += 1;
705
+ }
706
+ return src.length;
707
+ }
708
+
709
+ /**
710
+ * `src` with the bodies of every nested function/arrow BLANKED (each non-newline
711
+ * character replaced by a space, so line offsets survive). A closure written
712
+ * inside a handler body is not the handler speaking — it runs when whatever
713
+ * received it decides to, which is blind spot 2.
714
+ */
715
+ function maskNestedLambdas(src: string): string {
716
+ const chars = src.split("");
717
+ const blank = (from: number, to: number): void => {
718
+ for (let i = from; i < to && i < chars.length; i++) if (chars[i] !== "\n") chars[i] = " ";
719
+ };
720
+ const isSpace = (at: number): boolean => at >= src.length || /\s/.test(src[at] ?? "");
721
+
722
+ let i = 0;
723
+ while (i < src.length) {
724
+ const c = src[i];
725
+ if (c === "/" && (src[i + 1] === "/" || src[i + 1] === "*")) {
726
+ i = skipComment(src, i);
727
+ continue;
728
+ }
729
+ if (c === '"' || c === "'") {
730
+ i = skipQuoted(src, i);
731
+ continue;
732
+ }
733
+ if (c === "`") {
734
+ i = skipTemplate(src, i);
735
+ continue;
736
+ }
737
+ if (src.startsWith("function", i) && !/[\w$.]/.test(src[i - 1] ?? "") && !/[\w$]/.test(src[i + 8] ?? "")) {
738
+ const params = src.indexOf("(", i + 8);
739
+ const pastParams = params === -1 ? -1 : matchBracket(src, params);
740
+ const brace = pastParams === -1 ? -1 : src.indexOf("{", pastParams);
741
+ const pastBody = brace === -1 ? -1 : matchBracket(src, brace);
742
+ if (pastBody === -1) {
743
+ i += 8;
744
+ continue;
745
+ }
746
+ blank(i, pastBody);
747
+ i = pastBody;
748
+ continue;
749
+ }
750
+ if (c === "=" && src[i + 1] === ">") {
751
+ let j = i + 2;
752
+ while (j < src.length && /\s/.test(src[j] ?? "")) j += 1;
753
+ if (src.startsWith("async", j)) {
754
+ j += 5;
755
+ while (j < src.length && /\s/.test(src[j] ?? "")) j += 1;
756
+ }
757
+ if (src[j] === "{") {
758
+ const pastBody = matchBracket(src, j);
759
+ if (pastBody === -1) {
760
+ i = j + 1;
761
+ continue;
762
+ }
763
+ blank(j, pastBody);
764
+ i = pastBody;
765
+ continue;
766
+ }
767
+ const end = isSpace(j) ? j : expressionEnd(src, j);
768
+ blank(j, end);
769
+ i = end;
770
+ continue;
771
+ }
772
+ i += 1;
773
+ }
774
+ return chars.join("");
775
+ }
776
+
777
+ /**
778
+ * The names a handler body calls ITSELF. Hop 3 uses this narrower surface rather
779
+ * than the flat `calledNames`: a call made inside a closure the handler hands to
780
+ * `ctx.ui.setFooter()`/`setHeader()` is not a call the handler makes, and the
781
+ * header's blind spot 2 says so. Following it would flag the footer's git/jj/
782
+ * token-stat caches — state the header names as honest process-wide caching, and
783
+ * which the rule must NOT flag — on the strength of a `render()` callback.
784
+ */
785
+ function calledNamesAtHandlerLevel(bodies: readonly string[]): Set<string> {
786
+ const names = new Set<string>();
787
+ for (const body of bodies) {
788
+ for (const name of calledNames(maskNestedLambdas(body))) names.add(name);
789
+ }
790
+ return names;
791
+ }
792
+
793
+ /** Apply the rule to one source file's text. Reports the DECLARATION line. */
794
+ function findViolationsInSource(src: string, file: string, tree: SourceTree = REPO_TREE): Hit[] {
795
+ // A file with no session handler cannot start an assignment path at all — but
796
+ // a file with no binding of its own CAN still be the origin of a cross-module
797
+ // hit, so the declaration check stays inside the intra-file hops.
798
+ const bodies = handlerBodies(src);
799
+ if (bodies.length === 0) return [];
800
+
801
+ const declarations = moduleDeclarations(src);
802
+ const scopes = functionBodies(src);
803
+ const reached = new Set<string>();
804
+ for (const body of bodies) {
805
+ for (const name of calledNames(body)) {
806
+ if (scopes.has(name)) reached.add(name);
807
+ }
808
+ }
809
+ // Hop 3's narrower surface: calls the handler body makes directly (see
810
+ // `calledNamesAtHandlerLevel`). Hops 1 and 2 are unchanged.
811
+ const handlerLevelCalls = calledNamesAtHandlerLevel(bodies);
812
+
813
+ const hits: Hit[] = [];
814
+ const seen = new Set<string>();
815
+ const record = (hit: Hit): void => {
816
+ const key = format(hit);
817
+ if (seen.has(key)) return;
818
+ seen.add(key);
819
+ hits.push(hit);
820
+ };
821
+
822
+ for (const decl of declarations) {
823
+ const assigned = assignmentPattern(decl.name);
824
+ // Hop 1: the handler body writes it itself.
825
+ if (bodies.some((body) => assigned.test(body))) {
826
+ record({ file, line: decl.line, rule: COLLECTOR.label, text: decl.text });
827
+ continue;
828
+ }
829
+ // Hop 2: a function the handler body calls directly writes it.
830
+ for (const name of reached) {
831
+ if (assigned.test(scopes.get(name) ?? "")) {
832
+ record({ file, line: decl.line, rule: COLLECTOR.label, text: decl.text });
833
+ break;
834
+ }
835
+ }
836
+ }
837
+
838
+ // Hop 3: the handler calls a function it IMPORTED from another file, and the
839
+ // write happens over there. Named imports only — a namespace import never
840
+ // enters `parseImports`, and `CALLS` cannot match `ns.fn(` anyway, so
841
+ // `core/src/bridge/index.ts` → `events.ts` stays invisible by design.
842
+ const imports = parseImports(src);
843
+ for (const name of handlerLevelCalls) {
844
+ if (scopes.has(name)) continue;
845
+ const imported = imports.get(name);
846
+ if (imported === undefined) continue;
847
+ const entryFile = resolveSpec(file, imported.spec, tree);
848
+ if (entryFile === undefined) continue;
849
+ const target = resolveDeclaration(entryFile, imported.symbol, tree);
850
+ if (target === undefined || target.file === file) continue;
851
+ for (const hit of violationsAtTarget(target.file, target.symbol, tree)) record(hit);
852
+ }
853
+ return hits;
854
+ }
855
+
856
+ /**
857
+ * A file tree the matcher can read without touching disk: fixture sources keyed
858
+ * by path, plus the repo tree behind them. `read` is what the cross-module hop
859
+ * uses to fetch an import target, and every read is cached — the guard walks over
860
+ * 150 files and the hop adds one read per distinct import target.
861
+ */
862
+ interface SourceTree {
863
+ readonly read: (file: string) => string | undefined;
864
+ }
865
+
866
+ /** The repo tree, with contents cached on first read. */
867
+ function repoTree(): SourceTree {
868
+ const cache = new Map<string, string>();
869
+ return {
870
+ read(file) {
871
+ const cached = cache.get(file);
872
+ if (cached !== undefined) return cached;
873
+ try {
874
+ const src = readFileSync(join(REPO_ROOT, file), "utf8");
875
+ cache.set(file, src);
876
+ return src;
877
+ } catch {
878
+ return undefined;
879
+ }
880
+ },
881
+ };
882
+ }
883
+
884
+ /** A fixture tree: the map's own files, nothing on disk. */
885
+ function memoryTree(sources: ReadonlyMap<string, string>): SourceTree {
886
+ const cache = new Map(sources);
887
+ return { read: (file) => cache.get(file) };
888
+ }
889
+
890
+ const REPO_TREE = repoTree();
891
+
892
+ function findViolations(files: readonly string[]): Hit[] {
893
+ const hits: Hit[] = [];
894
+ for (const file of files) {
895
+ hits.push(...findViolationsInSource(readFileSync(join(REPO_ROOT, file), "utf8"), file, REPO_TREE));
896
+ }
897
+ return hits;
898
+ }
899
+
900
+ /**
901
+ * Test seam for multi-file fixtures: the same matcher, fed an in-memory tree
902
+ * instead of the repo, so a fixture never touches the working copy.
903
+ */
904
+ export function findViolationsInSources(sources: ReadonlyMap<string, string>): Hit[] {
905
+ const tree = memoryTree(sources);
906
+ const hits: Hit[] = [];
907
+ for (const [file, src] of sources) {
908
+ hits.push(...findViolationsInSource(src, file, tree));
909
+ }
910
+ return hits;
911
+ }
912
+
913
+ function isAllowed(hit: Hit): boolean {
914
+ return ALLOW_LIST.some((e) => e.file === hit.file && e.line === hit.text);
915
+ }
916
+
917
+ function format(hit: Hit): string {
918
+ return `${hit.file}:${hit.line} [${hit.rule}] ${hit.text}`;
919
+ }
920
+
921
+ const FILES = collectSourceFiles();
922
+ const HITS = findViolations(FILES);
923
+ const VIOLATIONS = HITS.filter((h) => !isAllowed(h));
924
+ const EXCUSED = HITS.filter((h) => isAllowed(h));
925
+
926
+ // ── Fixtures ────────────────────────────────────────────────────────────────
927
+
928
+ /**
929
+ * The shape #156 shipped with: the queue lives at module scope, the handler
930
+ * calls a named function, and that function assigns it. Two hops — a one-hop
931
+ * rule reads this as clean.
932
+ */
933
+ const SHIPPED_SHAPE = `
934
+ let _queue: ImageQueue | null = null;
935
+
936
+ export default function register(pi: ExtensionAPI): void {
937
+ pi.on("session_start", async (_event, ctx) => {
938
+ initQueue(ctx);
939
+ });
940
+ pi.on("session_shutdown", (_event, _ctx) => {
941
+ shutdownQueue();
942
+ });
943
+ }
944
+
945
+ function initQueue(ctx: ExtensionContext): void {
946
+ _queue = createQueue();
947
+ }
948
+
949
+ function shutdownQueue(): void {
950
+ _queue = null;
951
+ }
952
+ `;
953
+
954
+ /** Hop 1 on its own: the handler body assigns the binding itself. */
955
+ const DIRECT_ASSIGNMENT = `
956
+ let pasting = false;
957
+
958
+ export function register(pi: ExtensionAPI): void {
959
+ pi.on("turn_start", async () => {
960
+ pasting = false;
961
+ });
962
+ }
963
+ `;
964
+
965
+ /**
966
+ * Decision 2's escape, in the shape meta actually shipped: the binding is a
967
+ * `let` (so the collector DOES see it — this is what proves the guard tests
968
+ * assignment and not declaration) and every session reaches it through
969
+ * `.set()`/`.delete()`. Contents change, the binding does not.
970
+ */
971
+ const SESSION_KEYED_MAP = `
972
+ let sessions = new Map<string, ImageQueue>();
973
+
974
+ export function register(pi: ExtensionAPI): void {
975
+ pi.on("session_start", (_event, ctx) => {
976
+ sessions.set(sessionKey(ctx), createQueue());
977
+ });
978
+ pi.on("session_shutdown", (_event, ctx) => {
979
+ sessions.get(sessionKey(ctx))?.clear();
980
+ sessions.delete(sessionKey(ctx));
981
+ });
982
+ }
983
+ `;
984
+
985
+ /**
986
+ * The target half of the cross-module fixtures: the binding lives here, and the
987
+ * only function that writes it is reached from ANOTHER file's handler. This is
988
+ * the shape `packages/diff` shipped (`setShikiConfig`/`setRenderConfig`), and the
989
+ * shape `ui` still ships (`stopStartupListing` writes `frameTimer`/`revealTimer`).
990
+ */
991
+ const CONFIG_TARGET = `let _cfg: (() => Cfg) | undefined;
992
+
993
+ export function setConfigGetter(fn: () => Cfg): void {
994
+ _cfg = fn;
995
+ }
996
+ `;
997
+
998
+ /**
999
+ * The cross-module hop: a handler calls a function it IMPORTED under an `as`
1000
+ * alias, and the assignment happens one module away. A one-file rule reads this
1001
+ * as clean — which is exactly how three lifecycle-reachable bindings shipped in
1002
+ * `packages/diff`.
1003
+ */
1004
+ const CROSS_MODULE_HOP = new Map([
1005
+ [
1006
+ "index.ts",
1007
+ `
1008
+ import { setConfigGetter as setCfg } from "./shiki.js";
1009
+
1010
+ export function register(pi: ExtensionAPI): void {
1011
+ pi.on("session_start", (_event, ctx) => {
1012
+ setCfg(() => buildConfig(ctx));
1013
+ });
1014
+ }
1015
+ `,
1016
+ ],
1017
+ ["shiki.ts", CONFIG_TARGET],
1018
+ ]);
1019
+
1020
+ /**
1021
+ * The same shape behind a NAMESPACE import, which the hop deliberately does not
1022
+ * follow: `CALLS` cannot match `ns.fn(`, and following it would flag the five
1023
+ * honest one-socket-per-process bindings in `core/src/bridge/` with nowhere to
1024
+ * put them. Pinning it here makes widening the hop a deliberate edit.
1025
+ */
1026
+ const NAMESPACE_IMPORT_NOT_FOLLOWED = new Map([
1027
+ [
1028
+ "index.ts",
1029
+ `
1030
+ import * as shiki from "./shiki.js";
1031
+
1032
+ export function register(pi: ExtensionAPI): void {
1033
+ pi.on("session_start", (_event, ctx) => {
1034
+ shiki.setConfigGetter(() => buildConfig(ctx));
1035
+ });
1036
+ }
1037
+ `,
1038
+ ],
1039
+ ["shiki.ts", CONFIG_TARGET],
1040
+ ]);
1041
+
1042
+ /**
1043
+ * Blind spot 2's wall, in the shape that leaks if the mask is drawn wrong: the
1044
+ * handler hands over a callback whose EXPRESSION body is a multi-argument call
1045
+ * followed by another call. A `,` INSIDE `format(ctx, theme)` is not the end of
1046
+ * that expression, so the mask has to swallow the whole thing — stop it at the
1047
+ * comma and `touch(` survives the blanking, hop 3 reads it as a call the handler
1048
+ * makes itself, and the unrelated module state behind `touch` gets reported on
1049
+ * the strength of a footer callback.
1050
+ */
1051
+ const CALLBACK_ARG_TAIL_NOT_FOLLOWED = new Map([
1052
+ [
1053
+ "index.ts",
1054
+ `
1055
+ import { touch } from "./other.js";
1056
+
1057
+ export function register(pi: ExtensionAPI): void {
1058
+ pi.on("session_start", (_event, ctx) => {
1059
+ ctx.ui.setFooter(() => format(ctx, theme) + touch());
1060
+ });
1061
+ }
1062
+ `,
1063
+ ],
1064
+ [
1065
+ "other.ts",
1066
+ `let _hidden: number | undefined;
1067
+
1068
+ export function touch(): void {
1069
+ _hidden = 1;
1070
+ }
1071
+ `,
1072
+ ],
1073
+ ]);
1074
+
1075
+ /** Honest process state: a module `let`, written only outside any handler. */
1076
+ const WRITTEN_OUTSIDE_HANDLERS = `
1077
+ let cache: Map<string, string> | undefined;
1078
+
1079
+ export function primeCache(reader: () => Map<string, string>): void {
1080
+ cache = reader();
1081
+ }
1082
+
1083
+ export function register(pi: ExtensionAPI): void {
1084
+ pi.on("session_start", (_event, ctx) => {
1085
+ report(ctx);
1086
+ });
1087
+ }
1088
+
1089
+ function report(ctx: ExtensionContext): void {
1090
+ ctx.ui.notify(String(cache?.size ?? 0));
1091
+ }
1092
+ `;
1093
+
1094
+ // ── Tests ───────────────────────────────────────────────────────────────────
1095
+
1096
+ describe("session-state discipline: no per-session state at module scope", () => {
1097
+ it("walks the whole suite, not an empty file set", () => {
1098
+ expect(existsSync(join(REPO_ROOT, "pnpm-workspace.yaml"))).toBe(true);
1099
+ expect(FILES.length).toBeGreaterThan(50);
1100
+ // The three trees the guard is supposed to cover, including the two whose
1101
+ // assignment paths cross a boundary the hop does not follow (header: blind
1102
+ // spot 1) — they must still be scanned, or a future same-file violation
1103
+ // escapes silently.
1104
+ for (const expected of [
1105
+ "packages/image-paste/src/index.ts",
1106
+ "packages/ui/src/index.ts",
1107
+ "packages/ui/src/startup/index.ts",
1108
+ "packages/core/src/bridge/events.ts",
1109
+ "meta/src/index.ts",
1110
+ ]) {
1111
+ expect(FILES).toContain(expected);
1112
+ }
1113
+ expect(FILES.filter((f) => f.endsWith(".test.ts"))).toEqual([]);
1114
+ });
1115
+
1116
+ it("reports no module-scope session state outside the allow-list", () => {
1117
+ expect(VIOLATIONS.map(format)).toEqual([]);
1118
+ });
1119
+
1120
+ it("follows an assignment one call away from a handler, and leaves a keyed Map alone", () => {
1121
+ // The bug that shipped: module `let`, handler → named function → assignment.
1122
+ expect(findViolationsInSource(SHIPPED_SHAPE, "fixture.ts").map(format)).toEqual([
1123
+ "fixture.ts:2 [module-scope binding assigned by a session handler] let _queue: ImageQueue | null = null;",
1124
+ ]);
1125
+
1126
+ // Hop 1 on its own is alive too (a rule that only ever fired on hop 2 would
1127
+ // pass these fixtures by accident).
1128
+ expect(findViolationsInSource(DIRECT_ASSIGNMENT, "fixture.ts").map((h) => h.line)).toEqual([2]);
1129
+
1130
+ // Decision 2's escape, and the honest cache, are both clean. The Map is a
1131
+ // `let` deliberately: a `const` Map would also be clean, but only because
1132
+ // `const` is outside the collector's class, which says nothing about the
1133
+ // assignment test. A `let` Map mutated through `.set()` is the version that
1134
+ // proves "contents change, binding doesn't" is a real escape.
1135
+ expect(findViolationsInSource(SESSION_KEYED_MAP, "fixture.ts").map(format)).toEqual([]);
1136
+ expect(findViolationsInSource(WRITTEN_OUTSIDE_HANDLERS, "fixture.ts").map(format)).toEqual([]);
1137
+
1138
+ // And the collector itself: module scope only, `let`/`var` only. A
1139
+ // registration-closure `let` is the prescribed shape and must not be
1140
+ // collected, and neither may a `const` — the fix is a `const` Map.
1141
+ const collected = (src: string): string[] =>
1142
+ src
1143
+ .split("\n")
1144
+ .map((text) => COLLECTOR.pattern.exec(text)?.[1])
1145
+ .filter((name): name is string => name !== undefined);
1146
+ expect(
1147
+ collected(
1148
+ [
1149
+ "let moduleLevel = 1;",
1150
+ "export var alsoModule = 2;",
1151
+ "const keyed = new Map();",
1152
+ " let insideAFunction = 3;",
1153
+ "\tlet tabIndented = 4;",
1154
+ ].join("\n"),
1155
+ ),
1156
+ ).toEqual(["moduleLevel", "alsoModule"]);
1157
+ });
1158
+
1159
+ it("follows one cross-module import hop for named imports only", () => {
1160
+ // The hop: `index.ts`'s handler calls an imported function under an `as`
1161
+ // alias, `shiki.ts` is where the binding is written. The hit is reported
1162
+ // against the TARGET file and the target's declaration line, because that is
1163
+ // the pair the allow-list matches on.
1164
+ expect(findViolationsInSources(CROSS_MODULE_HOP).map(format)).toEqual([
1165
+ "shiki.ts:1 [module-scope binding assigned by a session handler] let _cfg: (() => Cfg) | undefined;",
1166
+ ]);
1167
+
1168
+ // The wall, pinned on purpose: a namespace import is not followed (see the
1169
+ // header's blind spot 1), so the same shape behind `import * as ns` stays
1170
+ // invisible. Widening this must be a deliberate edit to this assertion.
1171
+ expect(findViolationsInSources(NAMESPACE_IMPORT_NOT_FOLLOWED).map(format)).toEqual([]);
1172
+
1173
+ // Blind spot 2's wall, and the mask that draws it: a call made inside a
1174
+ // callback the handler passes elsewhere never starts a hop — including when
1175
+ // the callback's expression body holds a multi-argument call, where a `,`
1176
+ // must NOT be read as the end of that expression.
1177
+ expect(findViolationsInSources(CALLBACK_ARG_TAIL_NOT_FOLLOWED).map(format)).toEqual([]);
1178
+
1179
+ // Positive control for the fixture above, so the green is not vacuous: the
1180
+ // SAME two files, with `touch()` called at handler level instead of inside
1181
+ // the passed callback, do report the target's binding.
1182
+ const atHandlerLevel = new Map([
1183
+ [
1184
+ "index.ts",
1185
+ `
1186
+ import { touch } from "./other.js";
1187
+
1188
+ export function register(pi: ExtensionAPI): void {
1189
+ pi.on("session_start", (_event, ctx) => {
1190
+ touch();
1191
+ });
1192
+ }
1193
+ `,
1194
+ ],
1195
+ ["other.ts", CALLBACK_ARG_TAIL_NOT_FOLLOWED.get("other.ts")!],
1196
+ ]);
1197
+ expect(findViolationsInSources(atHandlerLevel).map(format)).toEqual([
1198
+ "other.ts:1 [module-scope binding assigned by a session handler] let _hidden: number | undefined;",
1199
+ ]);
1200
+ });
1201
+
1202
+ it("keeps the allow-list as small as the work that removes it", () => {
1203
+ expect(EXCUSED.map(format).sort()).toEqual(
1204
+ [
1205
+ "packages/notify/src/index.ts:197 [module-scope binding assigned by a session handler] let notifyTimer: ReturnType<typeof setTimeout> | null = null;",
1206
+ "packages/notify/src/index.ts:198 [module-scope binding assigned by a session handler] let pendingTrigger: TriggerType | null = null;",
1207
+ "packages/ui/src/startup/index.ts:171 [module-scope binding assigned by a session handler] let frameTimer: ReturnType<typeof setInterval> | undefined;",
1208
+ "packages/ui/src/startup/index.ts:172 [module-scope binding assigned by a session handler] let revealTimer: ReturnType<typeof setTimeout> | undefined;",
1209
+ ].sort(),
1210
+ );
1211
+
1212
+ for (const entry of ALLOW_LIST) {
1213
+ expect(FILES, `allow-listed file is no longer scanned: ${entry.file}`).toContain(entry.file);
1214
+ expect(entry.reason.length).toBeGreaterThan(0);
1215
+ // An exemption without a removal point is how a guard quietly becomes
1216
+ // permanent, so the reason has to name the card that retires it.
1217
+ expect(entry.reason, `allow-list entry needs a follow-up: ${entry.file}`).toMatch(/follow-up: tsk_/);
1218
+ // Entries pin a DECLARATION line (that is what the guard reports), matched
1219
+ // exactly, so a second violation in an exempt file still fails the guard.
1220
+ const actual = readFileSync(join(REPO_ROOT, entry.file), "utf8").split("\n");
1221
+ expect(
1222
+ actual.some((l) => l.trim() === entry.line),
1223
+ `allow-list entry no longer matches ${entry.file}: ${entry.line}`,
1224
+ ).toBe(true);
1225
+ expect(/^(?:export\s+)?(?:let|var)\s/.test(entry.line), `allow-list entry is not a declaration: ${entry.line}`).toBe(true);
1226
+ }
1227
+
1228
+ // The one assignment path that crosses a module boundary the hop does NOT
1229
+ // follow is excused by the rule's shape, not by an entry — pin that, so a
1230
+ // future edit that makes it visible is noticed rather than absorbed. It
1231
+ // stays invisible because `bridge/index.ts` reaches it through a NAMESPACE
1232
+ // import (`import * as events` → `events.onSessionStart(ctx)`), which gives
1233
+ // the resolver no symbol to look up and which `CALLS` cannot even match.
1234
+ for (const escaped of ["packages/core/src/bridge/events.ts"]) {
1235
+ expect(ALLOW_LIST.some((e) => e.file === escaped), `${escaped} must not need an entry`).toBe(false);
1236
+ expect(VIOLATIONS.some((v) => v.file === escaped) || EXCUSED.some((v) => v.file === escaped)).toBe(false);
1237
+ }
1238
+
1239
+ // `packages/ui/src/startup/index.ts` used to sit in that list. The hop sees it
1240
+ // now, so it is excused by an entry like anything else — pin the flip so a
1241
+ // quiet narrowing of the hop cannot put it back behind the wall.
1242
+ expect(EXCUSED.filter((h) => h.file === "packages/ui/src/startup/index.ts")).toHaveLength(2);
1243
+ });
1244
+ });
1245
+