@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 +2 -2
- package/package.json +2 -2
- package/src/overlay.ts +2 -1
- package/src/session-state-discipline.test.ts +1245 -0
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/
|
|
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/
|
|
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.
|
|
3
|
+
"version": "2.14.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
|
-
"url": "https://github.com/
|
|
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`
|
|
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
|
+
|