staysfixed 0.3.1 → 0.6.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +159 -3
  2. package/README.md +611 -402
  3. package/package.json +8 -3
  4. package/src/cli/index.js +14 -0
  5. package/src/v2/adapters/android-driver.js +1705 -0
  6. package/src/v2/adapters/android.js +1117 -0
  7. package/src/v2/adapters/contract.js +643 -0
  8. package/src/v2/adapters/electron.js +1594 -0
  9. package/src/v2/adapters/http.js +734 -0
  10. package/src/v2/adapters/ios-driver.js +1551 -0
  11. package/src/v2/adapters/ios.js +989 -0
  12. package/src/v2/adapters/isolate.js +739 -0
  13. package/src/v2/adapters/process.js +931 -0
  14. package/src/v2/adapters/source.js +1292 -0
  15. package/src/v2/adapters/web-driver.js +1532 -0
  16. package/src/v2/adapters/web.js +1009 -0
  17. package/src/v2/adapters/windows.js +1329 -0
  18. package/src/v2/browsers.js +1203 -0
  19. package/src/v2/cause.js +371 -0
  20. package/src/v2/check.js +1429 -0
  21. package/src/v2/ci.js +1209 -0
  22. package/src/v2/cli.js +670 -0
  23. package/src/v2/cluster.js +372 -0
  24. package/src/v2/coverage.js +1124 -0
  25. package/src/v2/detect.js +1199 -0
  26. package/src/v2/doctor.js +1702 -0
  27. package/src/v2/escalate.js +679 -0
  28. package/src/v2/init.js +1394 -0
  29. package/src/v2/intent.js +659 -0
  30. package/src/v2/journeys/from-routes.js +500 -0
  31. package/src/v2/journeys/from-suite.js +988 -0
  32. package/src/v2/journeys/index.js +651 -0
  33. package/src/v2/journeys/record.js +516 -0
  34. package/src/v2/mcp/server.js +374 -0
  35. package/src/v2/mcp/tools.js +1571 -0
  36. package/src/v2/normalise.js +783 -0
  37. package/src/v2/observation.js +938 -0
  38. package/src/v2/rank.js +672 -0
  39. package/src/v2/reference.js +1051 -0
  40. package/src/v2/remote.js +910 -0
  41. package/src/v2/run.js +1080 -0
  42. package/src/v2/sealed.js +568 -0
  43. package/src/v2/selfcheck.js +729 -0
  44. package/src/v2/ship.js +684 -0
  45. package/src/v2/store.js +703 -0
  46. package/src/v2/types.js +509 -0
  47. package/src/v2/waiver.js +511 -0
  48. package/src/v2/watch/focus.js +215 -0
@@ -0,0 +1,1292 @@
1
+ /**
2
+ * The contract, read straight out of the code. Nothing runs.
3
+ *
4
+ * This is the cheapest and the most exact of the seven channels, and it is the one no
5
+ * screenshot tool has ever had. A picture can only show you a door somebody happened to
6
+ * open. The source shows you every door there is: every IPC channel the desktop app
7
+ * answers on, every route the server serves, every function the library exports, every
8
+ * command the CLI accepts, every environment variable it reads. Delete one by accident and
9
+ * this channel says so in milliseconds, without booting anything.
10
+ *
11
+ * HOW IT READS. Not with a regular expression over raw text — that counts the word
12
+ * `ipcMain` inside a comment, inside a string, and inside a block of code somebody
13
+ * commented out three months ago. It runs a small lexer that knows what a comment is, what
14
+ * a string is and what a regular expression is, and then matches patterns over the TOKENS.
15
+ * The difference is not academic: on Terminal Deck it changes the answer, and it resolves
16
+ * the hundred-odd registrations whose channel name sits on the next line or behind a
17
+ * constant, which a line-based search cannot see at all.
18
+ *
19
+ * WHAT IT STILL CANNOT SEE, measured rather than guessed — see `report` on every reading:
20
+ * - a channel whose name is built while the program runs. Counted and reported as a door
21
+ * with no readable name, never silently dropped.
22
+ * - a registration made through somebody's own wrapper function.
23
+ * - routes a framework builds out of the filesystem, unless it is one of the two layouts
24
+ * this file knows (Next.js app and pages routes are; a bespoke one is not).
25
+ * - dead code. If it is written, it is counted, because "is this reachable" is a question
26
+ * only running it can answer, and this file never runs anything.
27
+ */
28
+
29
+ import fs from 'node:fs';
30
+ import fsp from 'node:fs/promises';
31
+ import path from 'node:path';
32
+ import nodeModule from 'node:module';
33
+ import { defineAdapter, joinPath, notCovered, observation } from './contract.js';
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // The lexer
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * @typedef {object} Token
41
+ * @property {'name'|'punct'|'string'|'template'|'number'|'regex'} t
42
+ * @property {string} v For a string, the text it holds. For everything else, the source.
43
+ * @property {number} line 1-based.
44
+ * @property {boolean} [built] A template with a substitution in it: part of this value is
45
+ * worked out while the program runs, so `v` is not the whole
46
+ * story and must never be treated as a name. Carried as a flag
47
+ * rather than as a marker inside `v`, because a marker inside the
48
+ * text is a marker some real string will eventually collide with.
49
+ */
50
+
51
+ /** Words after which a slash starts a regular expression rather than a division. */
52
+ const REGEX_MAY_FOLLOW = new Set([
53
+ 'return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', 'do', 'else',
54
+ 'yield', 'await', 'case', 'throw',
55
+ ]);
56
+
57
+ const PUNCT3 = ['...', '===', '!==', '**=', '<<=', '>>=', '&&=', '||=', '??=', '>>>'];
58
+ const PUNCT2 = [
59
+ '=>', '==', '!=', '<=', '>=', '&&', '||', '??', '?.', '++', '--', '+=', '-=', '*=', '/=',
60
+ '%=', '&=', '|=', '^=', '<<', '>>', '**',
61
+ ];
62
+
63
+ /** The one-letter escapes, written this way so the table itself stays readable. */
64
+ const SIMPLE_ESCAPES = /** @type {Record<string, string>} */ ({
65
+ n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', v: '\v', '0': '\0',
66
+ });
67
+
68
+ /**
69
+ * Turn source text into tokens, throwing away comments.
70
+ *
71
+ * Two deliberate safety valves, both there because this lexer is pointed at TypeScript and
72
+ * at JSX, neither of which it fully understands:
73
+ *
74
+ * - a quoted string that reaches the end of its line without closing is not a string. It
75
+ * is almost always an apostrophe in JSX text ("don't"), so the quote is emitted as
76
+ * punctuation and lexing carries on from the next character. Without this one rule a
77
+ * single apostrophe swallows the rest of the file.
78
+ * - the same for a regular expression, which also cannot legally contain a newline. That
79
+ * is what stops a JSX closing tag being read as the start of one.
80
+ *
81
+ * Both recoveries are counted, and the count is reported, because a file that needed twenty
82
+ * of them was probably not read properly and you deserve to know.
83
+ *
84
+ * @param {string} text
85
+ * @returns {{tokens: Token[], recoveries: number}}
86
+ */
87
+ export function lex(text) {
88
+ /** @type {Token[]} */
89
+ const tokens = [];
90
+ let i = 0;
91
+ let line = 1;
92
+ let recoveries = 0;
93
+ const n = text.length;
94
+
95
+ /** Whether a slash here opens a regular expression or divides. */
96
+ const regexCanStart = () => {
97
+ const prev = tokens[tokens.length - 1];
98
+ if (!prev) return true;
99
+ if (prev.t === 'name') return REGEX_MAY_FOLLOW.has(prev.v);
100
+ if (prev.t === 'number' || prev.t === 'string' || prev.t === 'template' || prev.t === 'regex') return false;
101
+ // Punctuation: a closing bracket usually ends a value, so a slash after it divides.
102
+ return !(prev.v === ')' || prev.v === ']');
103
+ };
104
+
105
+ while (i < n) {
106
+ const c = text[i];
107
+
108
+ if (c === '\n') { line++; i++; continue; }
109
+ if (c === ' ' || c === '\t' || c === '\r') { i++; continue; }
110
+
111
+ // Comments — dropped entirely. This is most of the reason to lex at all.
112
+ if (c === '/' && text[i + 1] === '/') {
113
+ while (i < n && text[i] !== '\n') i++;
114
+ continue;
115
+ }
116
+ if (c === '/' && text[i + 1] === '*') {
117
+ i += 2;
118
+ while (i < n && !(text[i] === '*' && text[i + 1] === '/')) { if (text[i] === '\n') line++; i++; }
119
+ i += 2;
120
+ continue;
121
+ }
122
+
123
+ // Strings.
124
+ if (c === '"' || c === "'") {
125
+ const start = i;
126
+ const startLine = line;
127
+ let out = '';
128
+ let j = i + 1;
129
+ let closed = false;
130
+ while (j < n) {
131
+ const d = text[j];
132
+ if (d === '\\') { out += readEscape(text, j); j += escapeLength(text, j); continue; }
133
+ if (d === '\n') break; // not a string after all — see the note above
134
+ if (d === c) { closed = true; j++; break; }
135
+ out += d;
136
+ j++;
137
+ }
138
+ if (!closed) { recoveries++; tokens.push({ t: 'punct', v: c, line: startLine }); i = start + 1; continue; }
139
+ tokens.push({ t: 'string', v: out, line: startLine });
140
+ i = j;
141
+ continue;
142
+ }
143
+
144
+ // Template literals. A template with a substitution in it is marked as such rather than
145
+ // guessed at, so a channel name built at run time reads as "there is a door here, we
146
+ // cannot name it" instead of as a channel called nothing.
147
+ if (c === '`') {
148
+ const startLine = line;
149
+ let j = i + 1;
150
+ let out = '';
151
+ let simple = true;
152
+ let depth = 0;
153
+ while (j < n) {
154
+ const d = text[j];
155
+ if (d === '\\') { out += readEscape(text, j); j += escapeLength(text, j); continue; }
156
+ if (d === '\n') { line++; if (depth === 0) out += d; j++; continue; }
157
+ if (d === '$' && text[j + 1] === '{') { simple = false; depth++; j += 2; continue; }
158
+ if (depth > 0) {
159
+ if (d === '{') depth++;
160
+ else if (d === '}') depth--;
161
+ j++;
162
+ continue;
163
+ }
164
+ if (d === '`') { j++; break; }
165
+ out += d;
166
+ j++;
167
+ }
168
+ tokens.push(simple
169
+ ? { t: 'template', v: out, line: startLine }
170
+ : { t: 'template', v: out, line: startLine, built: true });
171
+ i = j;
172
+ continue;
173
+ }
174
+
175
+ // Regular expressions.
176
+ if (c === '/' && regexCanStart()) {
177
+ const start = i;
178
+ const startLine = line;
179
+ let j = i + 1;
180
+ let inClass = false;
181
+ let closed = false;
182
+ while (j < n) {
183
+ const d = text[j];
184
+ if (d === '\\') { j += 2; continue; }
185
+ if (d === '\n') break; // cannot happen in a real regex — recover
186
+ if (d === '[') inClass = true;
187
+ else if (d === ']') inClass = false;
188
+ else if (d === '/' && !inClass) { closed = true; j++; break; }
189
+ j++;
190
+ }
191
+ if (!closed) { recoveries++; tokens.push({ t: 'punct', v: '/', line: startLine }); i = start + 1; continue; }
192
+ while (j < n && /[a-z]/.test(text[j])) j++;
193
+ tokens.push({ t: 'regex', v: text.slice(start, j), line: startLine });
194
+ i = j;
195
+ continue;
196
+ }
197
+
198
+ // Names, including keywords.
199
+ if (/[A-Za-z_$]/.test(c)) {
200
+ let j = i + 1;
201
+ while (j < n && /[A-Za-z0-9_$]/.test(text[j])) j++;
202
+ tokens.push({ t: 'name', v: text.slice(i, j), line });
203
+ i = j;
204
+ continue;
205
+ }
206
+
207
+ // Numbers, roughly. Nothing here depends on reading them precisely.
208
+ if (/[0-9]/.test(c)) {
209
+ let j = i + 1;
210
+ while (j < n && /[0-9a-fA-FxXoObBnE._]/.test(text[j])) j++;
211
+ tokens.push({ t: 'number', v: text.slice(i, j), line });
212
+ i = j;
213
+ continue;
214
+ }
215
+
216
+ const three = text.slice(i, i + 3);
217
+ if (PUNCT3.includes(three)) { tokens.push({ t: 'punct', v: three, line }); i += 3; continue; }
218
+ const two = text.slice(i, i + 2);
219
+ if (PUNCT2.includes(two)) { tokens.push({ t: 'punct', v: two, line }); i += 2; continue; }
220
+ tokens.push({ t: 'punct', v: c, line });
221
+ i++;
222
+ }
223
+
224
+ return { tokens, recoveries };
225
+ }
226
+
227
+ /** @param {string} text @param {number} at */
228
+ function escapeLength(text, at) {
229
+ const next = text[at + 1];
230
+ if (next === 'x') return 4;
231
+ if (next === 'u') {
232
+ if (text[at + 2] === '{') {
233
+ const close = text.indexOf('}', at);
234
+ return close === -1 ? 2 : close - at + 1;
235
+ }
236
+ return 6;
237
+ }
238
+ return 2;
239
+ }
240
+
241
+ /** @param {string} text @param {number} at */
242
+ function readEscape(text, at) {
243
+ const next = text[at + 1];
244
+ if (next !== undefined && next in SIMPLE_ESCAPES) return SIMPLE_ESCAPES[next];
245
+ if (next === 'x') return String.fromCharCode(parseInt(text.slice(at + 2, at + 4), 16) || 0);
246
+ if (next === 'u') {
247
+ if (text[at + 2] === '{') {
248
+ const close = text.indexOf('}', at);
249
+ if (close === -1) return '';
250
+ return String.fromCodePoint(parseInt(text.slice(at + 3, close), 16) || 0);
251
+ }
252
+ return String.fromCharCode(parseInt(text.slice(at + 2, at + 6), 16) || 0);
253
+ }
254
+ return next ?? '';
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // What a project's code can hold
259
+ // ---------------------------------------------------------------------------
260
+
261
+ /**
262
+ * @typedef {object} Door
263
+ * @property {'ipc'|'route'|'export'|'command'|'env'} kind
264
+ * @property {string} name The channel, the route, the exported name.
265
+ * @property {string} detail 'answers with a value', 'GET', 'a function taking (a, b)'.
266
+ * @property {string} file Relative to the project root.
267
+ * @property {number} line
268
+ * @property {boolean} inTest Found in a test file. A test's fake registration is not
269
+ * a door the product answers on, so these are counted
270
+ * separately and left out by default.
271
+ * @property {boolean} named False when the name is built while the program runs and
272
+ * all we know is that a door is there.
273
+ * @property {string} via How the name was worked out: 'literal', 'a constant', …
274
+ */
275
+
276
+ /**
277
+ * @typedef {object} ReadingReport
278
+ * @property {number} filesRead
279
+ * @property {number} filesSkipped
280
+ * @property {number} testFiles
281
+ * @property {number} lexRecoveries Times the lexer had to back out of a string or a regex.
282
+ * @property {number} typesStripped Files whose TypeScript types Node stripped for us.
283
+ * @property {number} unnamed Doors that exist but whose name is built at run time.
284
+ * @property {number} viaConstant Names that came from a constant rather than a literal.
285
+ * @property {number} duplicates Doors registered more than once where that is a bug —
286
+ * a second `ipcMain.handle` on one channel, or two routes
287
+ * on one verb and path. Legal repeats are not counted.
288
+ * @property {string[]} problems Files that could not be read, one line each.
289
+ * @property {Record<string, number>} counts Doors by kind, product code only.
290
+ */
291
+
292
+ /**
293
+ * @typedef {object} ContractReading
294
+ * @property {Door[]} doors
295
+ * @property {ReadingReport} report
296
+ */
297
+
298
+ const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.mts', '.cts']);
299
+
300
+ /** Folders never worth reading. Build output is a copy of the source with worse names. */
301
+ const SKIP_DIRS = new Set([
302
+ 'node_modules', '.git', 'dist', 'build', 'out', 'release', 'coverage', '.next', '.turbo',
303
+ '.staysfixed', '.cache', 'vendor', '__snapshots__', '.venv', 'venv',
304
+ ]);
305
+
306
+ /** The folders a project's own code normally lives in. */
307
+ const SOURCE_FOLDERS = ['src', 'lib', 'app', 'bin', 'server', 'pages', 'api', 'electron', 'main', 'packages'];
308
+
309
+ /** Everything Electron answers a renderer on. */
310
+ const IPC_METHODS = new Set(['handle', 'on', 'handleOnce', 'once', 'addListener']);
311
+
312
+ /** The verbs a web framework hangs a route off. */
313
+ const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'all']);
314
+
315
+ /**
316
+ * Receiver names accepted as a router without proof. Anything else has to have been
317
+ * assigned from a framework factory somewhere in the same file.
318
+ */
319
+ const ROUTER_NAMES = new Set(['app', 'router', 'server', 'fastify', 'api', 'routes']);
320
+
321
+ /** @param {string} file */
322
+ export function looksLikeATest(file) {
323
+ const normalised = file.split(path.sep).join('/');
324
+ return (
325
+ /\.(test|spec)\.[cm]?[jt]sx?$/.test(normalised) ||
326
+ /(^|\/)(__tests__|__mocks__|tests?|e2e|fixtures)\//.test(normalised)
327
+ );
328
+ }
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // Reading one file
332
+ // ---------------------------------------------------------------------------
333
+
334
+ /**
335
+ * A name that pointed at a constant which may live in another file.
336
+ * @typedef {{unresolved: string}} Pending
337
+ */
338
+
339
+ /** @typedef {Omit<Door, 'name'> & {name: string|Pending}} RawDoor */
340
+
341
+ /**
342
+ * @typedef {object} FileReading
343
+ * @property {RawDoor[]} doors
344
+ * @property {Map<string, string>} constants String constants this file exports, for the
345
+ * cross-file pass. Only exported ones travel.
346
+ * @property {number} recoveries
347
+ * @property {boolean} typesStripped
348
+ */
349
+
350
+ /**
351
+ * Read one file's doors. A name pointing at a constant defined elsewhere comes back
352
+ * pending, and {@link readContract} fills it in once every file has been read.
353
+ *
354
+ * @param {string} relFile Path relative to the project root, for reporting.
355
+ * @param {string} text
356
+ * @returns {FileReading}
357
+ */
358
+ export function readFile(relFile, text) {
359
+ const extension = path.extname(relFile);
360
+ let source = text;
361
+ let typesStripped = false;
362
+ // Node can strip TypeScript types for us, which takes generics and annotations out of the
363
+ // way and gives the lexer a cleaner run at deciding what a slash means. It cannot handle
364
+ // JSX, so .tsx keeps its types and takes its chances — the lexer copes, it just works
365
+ // harder, and the recovery count says how hard.
366
+ const stripper = nodeModule.stripTypeScriptTypes;
367
+ if (typeof stripper === 'function' && (extension === '.ts' || extension === '.mts' || extension === '.cts')) {
368
+ // Which modes Node accepts has changed between releases, so try each and take the first
369
+ // that works rather than pinning to one and silently getting nothing.
370
+ for (const mode of /** @type {const} */ (['strip', 'transform'])) {
371
+ try {
372
+ source = stripper(text, { mode });
373
+ typesStripped = true;
374
+ break;
375
+ } catch {
376
+ source = text;
377
+ }
378
+ }
379
+ }
380
+
381
+ const { tokens, recoveries } = lex(source);
382
+ const inTest = looksLikeATest(relFile);
383
+
384
+ /** @type {Map<string, string>} Every `const X = 'literal'` in this file, at any depth. */
385
+ const constants = new Map();
386
+ /** @type {Map<string, string>} The subset of those that this file exports. */
387
+ const exportedConstants = new Map();
388
+ /** @type {Set<string>} Names proven to be a router by what they were assigned. */
389
+ const routers = new Set();
390
+ // A receiver called `app` or `api` only counts as a router in a file that actually pulls
391
+ // in a web framework. Without this rule `api.get(id)` — a perfectly ordinary getter, and
392
+ // Terminal Deck has one — is read as a route called whatever `id` happens to hold.
393
+ const hasWebFramework = /\b(express|fastify|hono|koa|polka|connect|node:http|node:https)\b/.test(text);
394
+ /** @type {Set<string>} Names proven to be Electron's ipcMain. */
395
+ const ipcNames = new Set(['ipcMain']);
396
+
397
+ // First sweep: learn this file's vocabulary. Constants and aliases both have to be known
398
+ // before the registrations that use them are read, and they are not always written first.
399
+ for (let i = 0; i + 3 < tokens.length; i++) {
400
+ const t = tokens[i];
401
+ if (t.t !== 'name' || (t.v !== 'const' && t.v !== 'let' && t.v !== 'var')) continue;
402
+ const target = tokens[i + 1];
403
+ const equals = tokens[i + 2];
404
+ if (target.t !== 'name' || equals.v !== '=') continue;
405
+ const value = tokens[i + 3];
406
+ if ((value.t === 'string' || value.t === 'template') && !value.built) {
407
+ constants.set(target.v, value.v);
408
+ if (tokens[i - 1]?.v === 'export') exportedConstants.set(target.v, value.v);
409
+ } else if (value.t === 'name' && ipcNames.has(value.v) && tokens[i + 4]?.v !== '.') {
410
+ ipcNames.add(target.v);
411
+ } else if (isRouterFactory(tokens, i + 3)) {
412
+ routers.add(target.v);
413
+ }
414
+ }
415
+
416
+ /** @type {RawDoor[]} */
417
+ const doors = [];
418
+
419
+ /** @param {string} raw @returns {{name: string|Pending, via: string}} */
420
+ const fromConstant = (raw) => {
421
+ const known = constants.get(raw);
422
+ if (known !== undefined) return { name: known, via: 'a constant in the same file' };
423
+ return { name: { unresolved: raw }, via: 'a constant from another file' };
424
+ };
425
+
426
+ for (let i = 0; i + 2 < tokens.length; i++) {
427
+ const receiver = tokens[i];
428
+ const dot = tokens[i + 1];
429
+ const method = tokens[i + 2];
430
+ if (receiver.t !== 'name' || (dot.v !== '.' && dot.v !== '?.') || method.t !== 'name') continue;
431
+
432
+ // process.env.SOMETHING — the settings a product silently depends on.
433
+ if (receiver.v === 'process' && method.v === 'env') {
434
+ const after = tokens[i + 3];
435
+ const name = tokens[i + 4];
436
+ if (after?.v === '.' && name?.t === 'name') {
437
+ doors.push(door('env', name.v, 'read from the environment', relFile, name.line, inTest, true, 'literal'));
438
+ } else if (after?.v === '[' && name?.t === 'string') {
439
+ doors.push(door('env', name.v, 'read from the environment', relFile, name.line, inTest, true, 'literal'));
440
+ }
441
+ continue;
442
+ }
443
+
444
+ const open = tokens[i + 3];
445
+ if (open?.v !== '(') continue;
446
+ const arg = tokens[i + 4];
447
+
448
+ // ipcMain.handle('channel', …) — the doors an Electron app answers on.
449
+ if (ipcNames.has(receiver.v) && IPC_METHODS.has(method.v)) {
450
+ const answers = method.v === 'handle' || method.v === 'handleOnce'
451
+ ? 'answers with a value'
452
+ : 'listens, answers nothing';
453
+ if (!arg) continue;
454
+ if ((arg.t === 'string' || arg.t === 'template') && !arg.built) {
455
+ doors.push(door('ipc', arg.v, answers, relFile, arg.line, inTest, true, 'literal'));
456
+ } else if (arg.t === 'name') {
457
+ const found = fromConstant(arg.v);
458
+ doors.push(door('ipc', found.name, answers, relFile, arg.line, inTest, true, found.via));
459
+ } else {
460
+ doors.push(door('ipc', `${relFile}:${arg.line}`, answers, relFile, arg.line, inTest, false,
461
+ arg.t === 'template' ? 'a name built while it runs' : 'a name we could not read'));
462
+ }
463
+ continue;
464
+ }
465
+
466
+ const isRouter = routers.has(receiver.v) || (hasWebFramework && ROUTER_NAMES.has(receiver.v));
467
+ if (!isRouter) continue;
468
+
469
+ // app.get('/path', …) and friends. Requiring the path to start with a slash is what
470
+ // keeps `app.get('setting')` — a settings getter, not a route — out of the list.
471
+ if (HTTP_METHODS.has(method.v)) {
472
+ if (arg && (arg.t === 'string' || arg.t === 'template') && arg.v.startsWith('/')) {
473
+ doors.push(door('route', arg.v, method.v.toUpperCase(), relFile, arg.line, inTest, true, 'literal'));
474
+ } else if (arg?.t === 'name') {
475
+ const found = fromConstant(arg.v);
476
+ if (typeof found.name === 'string' && !found.name.startsWith('/')) continue;
477
+ doors.push(door('route', found.name, method.v.toUpperCase(), relFile, arg.line, inTest, true, found.via));
478
+ }
479
+ continue;
480
+ }
481
+ if (method.v === 'use' && arg?.t === 'string' && arg.v.startsWith('/')) {
482
+ doors.push(door('route', arg.v, 'MOUNT', relFile, arg.line, inTest, true, 'literal'));
483
+ continue;
484
+ }
485
+ // fastify.route({ method: 'GET', url: '/path' })
486
+ if (method.v === 'route') {
487
+ const end = matchBracket(tokens, i + 3);
488
+ let url = null;
489
+ let verb = 'ANY';
490
+ for (let j = i + 4; j < end; j++) {
491
+ const key = tokens[j];
492
+ if (key.t !== 'name' || tokens[j + 1]?.v !== ':') continue;
493
+ const value = tokens[j + 2];
494
+ if (!value || value.t !== 'string') continue;
495
+ if (key.v === 'url' || key.v === 'path') url = value.v;
496
+ if (key.v === 'method') verb = value.v.toUpperCase();
497
+ }
498
+ if (url) doors.push(door('route', url, verb, relFile, receiver.line, inTest, true, 'literal'));
499
+ }
500
+ }
501
+
502
+ // Exports get their own sweep, because `export` is a prefix rather than a receiver.
503
+ for (let i = 0; i < tokens.length; i++) {
504
+ const t = tokens[i];
505
+ if (t.t !== 'name') continue;
506
+ if (t.v === 'export') { readExport(tokens, i, relFile, inTest, doors); continue; }
507
+ // The CommonJS spelling of the same thing.
508
+ let at = -1;
509
+ if (t.v === 'module' && tokens[i + 1]?.v === '.' && tokens[i + 2]?.v === 'exports') at = i + 2;
510
+ else if (t.v === 'exports' && tokens[i - 1]?.v !== '.') at = i;
511
+ if (at === -1) continue;
512
+ if (tokens[at + 1]?.v === '.' && tokens[at + 2]?.t === 'name' && tokens[at + 3]?.v === '=') {
513
+ const name = tokens[at + 2];
514
+ doors.push(door('export', name.v, describeExport(tokens, at + 4), relFile, name.line, inTest, true, 'literal'));
515
+ }
516
+ }
517
+
518
+ // Command-line flags. These are a mention, not a proof — a string that looks like a flag
519
+ // may be one this program accepts or one it passes on to something else. It gets its own
520
+ // wording so nobody mistakes the two.
521
+ for (const token of tokens) {
522
+ if (token.t === 'string' && /^--[a-z0-9][a-z0-9-]*$/i.test(token.v)) {
523
+ doors.push(door('command', token.v, 'a flag this file mentions', relFile, token.line, inTest, true, 'literal'));
524
+ }
525
+ }
526
+
527
+ return { doors, constants: exportedConstants, recoveries, typesStripped };
528
+ }
529
+
530
+ /**
531
+ * @param {Door['kind']} kind
532
+ * @param {string|Pending} name
533
+ * @param {string} detail
534
+ * @param {string} file
535
+ * @param {number} line
536
+ * @param {boolean} inTest
537
+ * @param {boolean} named
538
+ * @param {string} via
539
+ * @returns {RawDoor}
540
+ */
541
+ function door(kind, name, detail, file, line, inTest, named, via) {
542
+ return { kind, name, detail, file, line, inTest, named, via };
543
+ }
544
+
545
+ /**
546
+ * Is the expression starting at `at` one of the framework factories that hands back
547
+ * something you can hang routes off?
548
+ * @param {Token[]} tokens
549
+ * @param {number} at
550
+ */
551
+ function isRouterFactory(tokens, at) {
552
+ const first = tokens[at];
553
+ if (!first) return false;
554
+ if (first.t === 'name' && first.v === 'new') return isRouterFactory(tokens, at + 1);
555
+ if (first.t !== 'name') return false;
556
+ if (/^(express|fastify|Fastify|Router|Hono|polka|connect)$/.test(first.v)) return true;
557
+ // express.Router(), http.createServer()
558
+ return tokens[at + 1]?.v === '.' && /^(Router|createServer)$/.test(tokens[at + 2]?.v ?? '');
559
+ }
560
+
561
+ /**
562
+ * Index of the bracket closing the one at `open`. Returns the end of the token list when
563
+ * the file is unbalanced, which happens in a file the lexer had to recover inside.
564
+ * @param {Token[]} tokens
565
+ * @param {number} open
566
+ */
567
+ function matchBracket(tokens, open) {
568
+ const opener = tokens[open]?.v;
569
+ if (opener !== '(' && opener !== '[' && opener !== '{') return open;
570
+ let depth = 0;
571
+ for (let i = open; i < tokens.length; i++) {
572
+ const t = tokens[i];
573
+ if (t.t !== 'punct') continue;
574
+ if (t.v === '(' || t.v === '[' || t.v === '{') depth++;
575
+ else if (t.v === ')' || t.v === ']' || t.v === '}') {
576
+ depth--;
577
+ if (depth === 0) return i;
578
+ }
579
+ }
580
+ return tokens.length - 1;
581
+ }
582
+
583
+ /**
584
+ * Read one `export …`. Handles the shapes that actually appear: a function, a class, a
585
+ * const, a default, a `{ a, b as c }` list, and a re-export.
586
+ * @param {Token[]} tokens
587
+ * @param {number} i
588
+ * @param {string} file
589
+ * @param {boolean} inTest
590
+ * @param {RawDoor[]} out
591
+ */
592
+ function readExport(tokens, i, file, inTest, out) {
593
+ let at = i + 1;
594
+ while (tokens[at]?.t === 'name' && (tokens[at].v === 'async' || tokens[at].v === 'declare')) at++;
595
+ const head = tokens[at];
596
+ if (!head) return;
597
+
598
+ if (head.v === 'default') {
599
+ out.push(door('export', 'default', describeExport(tokens, at + 1), file, head.line, inTest, true, 'literal'));
600
+ return;
601
+ }
602
+ if (head.v === '{') {
603
+ const end = matchBracket(tokens, at);
604
+ for (let j = at + 1; j < end; j++) {
605
+ const name = tokens[j];
606
+ if (name.t !== 'name' || name.v === 'type') continue;
607
+ let exported = name.v;
608
+ if (tokens[j + 1]?.v === 'as' && tokens[j + 2]?.t === 'name') { exported = tokens[j + 2].v; j += 2; }
609
+ out.push(door('export', exported, 'passed straight through from somewhere else', file, name.line, inTest, true, 'literal'));
610
+ while (j < end && tokens[j].v !== ',') j++;
611
+ }
612
+ return;
613
+ }
614
+ if (head.v === '*') return; // the names live in the other file
615
+ if (head.v === 'type' || head.v === 'interface') return; // types are not doors
616
+
617
+ if (head.v === 'function' || head.v === 'class') {
618
+ let nameAt = at + 1;
619
+ if (tokens[nameAt]?.v === '*') nameAt++;
620
+ const name = tokens[nameAt];
621
+ if (!name || name.t !== 'name') return;
622
+ const detail = head.v === 'class'
623
+ ? `a class with ${methodNames(tokens, nameAt).join(', ') || 'no methods'}`
624
+ : `a function taking (${parameterNames(tokens, nameAt + 1).join(', ')})`;
625
+ out.push(door('export', name.v, detail, file, name.line, inTest, true, 'literal'));
626
+ return;
627
+ }
628
+ if (head.v === 'const' || head.v === 'let' || head.v === 'var') {
629
+ const name = tokens[at + 1];
630
+ if (name?.t !== 'name') return;
631
+ out.push(door('export', name.v, describeExport(tokens, at + 3), file, name.line, inTest, true, 'literal'));
632
+ }
633
+ }
634
+
635
+ /**
636
+ * Say what an exported thing is, in the words a person would use.
637
+ *
638
+ * The exact value is deliberately not recorded for anything but a short literal. A
639
+ * library's API surface is its SHAPE; comparing the contents of an exported object belongs
640
+ * to the results channel, which sees it after the module has actually run and does not have
641
+ * to guess.
642
+ *
643
+ * @param {Token[]} tokens
644
+ * @param {number} at
645
+ */
646
+ function describeExport(tokens, at) {
647
+ const t = tokens[at];
648
+ if (!t) return 'something';
649
+ if (t.t === 'string' || t.t === 'template') {
650
+ return t.built ? 'text built while it runs' : `the text "${t.v.slice(0, 60)}"`;
651
+ }
652
+ if (t.t === 'number') return 'a number';
653
+ if (t.t === 'name') {
654
+ if (t.v === 'true' || t.v === 'false') return t.v;
655
+ if (t.v === 'async' || t.v === 'new') return describeExport(tokens, at + 1);
656
+ if (t.v === 'function') {
657
+ const parenAt = tokens[at + 1]?.t === 'name' ? at + 2 : at + 1;
658
+ return `a function taking (${parameterNames(tokens, parenAt).join(', ')})`;
659
+ }
660
+ if (t.v === 'class') return 'a class';
661
+ if (tokens[at + 1]?.v === '=>') return `a function taking (${t.v})`;
662
+ return 'something';
663
+ }
664
+ if (t.v === '(') {
665
+ const end = matchBracket(tokens, at);
666
+ if (tokens[end + 1]?.v === '=>') return `a function taking (${parameterNames(tokens, at).join(', ')})`;
667
+ return 'something';
668
+ }
669
+ if (t.v === '{') return 'an object';
670
+ if (t.v === '[') return 'a list';
671
+ return 'something';
672
+ }
673
+
674
+ /**
675
+ * The parameter names of the list starting at `open`. Destructured and rest parameters are
676
+ * labelled rather than expanded — what changes when somebody breaks an interface is the
677
+ * count and the order, not the shape of a destructure.
678
+ * @param {Token[]} tokens
679
+ * @param {number} open
680
+ */
681
+ function parameterNames(tokens, open) {
682
+ if (tokens[open]?.v !== '(') return [];
683
+ const end = matchBracket(tokens, open);
684
+ /** @type {string[]} */
685
+ const names = [];
686
+ let depth = 0;
687
+ let expectName = true;
688
+ for (let i = open + 1; i < end; i++) {
689
+ const t = tokens[i];
690
+ if (t.t === 'punct') {
691
+ if (t.v === '(' || t.v === '[' || t.v === '{') {
692
+ if (depth === 0 && expectName) { names.push(t.v === '{' ? '(an object)' : '(a list)'); expectName = false; }
693
+ depth++;
694
+ } else if (t.v === ')' || t.v === ']' || t.v === '}') {
695
+ depth--;
696
+ } else if (t.v === ',' && depth === 0) {
697
+ expectName = true;
698
+ }
699
+ continue;
700
+ }
701
+ if (depth === 0 && expectName && t.t === 'name') { names.push(t.v); expectName = false; }
702
+ }
703
+ return names;
704
+ }
705
+
706
+ /** Words that are not method names even though a bracket follows them. */
707
+ const NOT_A_METHOD = new Set(['constructor', 'if', 'for', 'while', 'switch', 'return', 'catch', 'get', 'set']);
708
+
709
+ /**
710
+ * The method names of the class whose name sits at `nameAt`. One level deep only.
711
+ * @param {Token[]} tokens
712
+ * @param {number} nameAt
713
+ */
714
+ function methodNames(tokens, nameAt) {
715
+ let open = nameAt + 1;
716
+ while (tokens[open] && tokens[open].v !== '{') open++;
717
+ if (!tokens[open]) return [];
718
+ const end = matchBracket(tokens, open);
719
+ /** @type {string[]} */
720
+ const names = [];
721
+ let depth = 0;
722
+ for (let i = open + 1; i < end; i++) {
723
+ const t = tokens[i];
724
+ if (t.t === 'punct') {
725
+ if (t.v === '(' || t.v === '[' || t.v === '{') depth++;
726
+ else if (t.v === ')' || t.v === ']' || t.v === '}') depth--;
727
+ continue;
728
+ }
729
+ if (depth === 0 && t.t === 'name' && tokens[i + 1]?.v === '(' && !NOT_A_METHOD.has(t.v)) names.push(t.v);
730
+ }
731
+ return [...new Set(names)];
732
+ }
733
+
734
+ // ---------------------------------------------------------------------------
735
+ // Reading a whole project
736
+ // ---------------------------------------------------------------------------
737
+
738
+ /**
739
+ * Walk a project and read every door out of it.
740
+ *
741
+ * Two passes over the findings, one pass over the disk: files are read and lexed once, and
742
+ * any name that pointed at a constant defined in another file is filled in afterwards from
743
+ * everything that was learned on the way. That is the only way a registration written as
744
+ * `ipcMain.handle(LID_AWAKE_GET, …)` turns into a channel with a real name instead of a
745
+ * shrug — and on Terminal Deck that is a hundred-odd of them.
746
+ *
747
+ * @param {object} opts
748
+ * @param {string} opts.root Project root. Only ever read.
749
+ * @param {string[]} [opts.folders] Subfolders to read. Defaults to the usual ones.
750
+ * @param {boolean} [opts.includeTests] Count doors found in test files. Default false.
751
+ * @param {number} [opts.maxFileBytes] Skip anything bigger. Default 24MB — a built
752
+ * bundle is a legitimate thing to read, and Terminal Deck's is 3.5MB. At the old 2MB
753
+ * the whole main process was skipped and the reader then said it had found no source
754
+ * at all, which is the exact shape of failure this tool exists to prevent: a silence
755
+ * that reads like an all-clear.
756
+ * @returns {Promise<ContractReading>}
757
+ */
758
+ export async function readContract(opts) {
759
+ const root = path.resolve(opts.root);
760
+ const maxFileBytes = opts.maxFileBytes ?? 24 * 1024 * 1024;
761
+ const found = await collectFiles(root, opts.folders ?? SOURCE_FOLDERS, maxFileBytes);
762
+
763
+ /** @type {RawDoor[]} */
764
+ const all = [];
765
+ /** @type {Map<string, string>} every string constant anywhere in the project */
766
+ const constants = new Map();
767
+ /** @type {Set<string>} names two files define differently */
768
+ const ambiguous = new Set();
769
+ /** @type {ReadingReport} */
770
+ const report = {
771
+ filesRead: 0, filesSkipped: found.skipped, testFiles: 0, lexRecoveries: 0,
772
+ typesStripped: 0, unnamed: 0, viaConstant: 0, duplicates: 0, problems: [], counts: {},
773
+ };
774
+ for (const where of found.unreadable) {
775
+ report.problems.push(`${where} could not be opened, so any door behind it is invisible to this run.`);
776
+ }
777
+ for (const big of found.tooBig) {
778
+ report.problems.push(`${big} is bigger than the ${Math.round(maxFileBytes / (1024 * 1024))}MB this reader will open, so its doors were not read.`);
779
+ }
780
+
781
+ for (const rel of found.files) {
782
+ let text;
783
+ try {
784
+ text = await fsp.readFile(path.join(root, rel), 'utf8');
785
+ } catch (error) {
786
+ report.problems.push(`${rel} could not be opened: ${describeError(error)}`);
787
+ continue;
788
+ }
789
+ let reading;
790
+ try {
791
+ reading = readFile(rel, text);
792
+ } catch (error) {
793
+ report.problems.push(`${rel} could not be read: ${describeError(error)}`);
794
+ continue;
795
+ }
796
+ report.filesRead++;
797
+ report.lexRecoveries += reading.recoveries;
798
+ if (reading.typesStripped) report.typesStripped++;
799
+ if (looksLikeATest(rel)) report.testFiles++;
800
+ for (const [key, value] of reading.constants) {
801
+ const known = constants.get(key);
802
+ if (known !== undefined && known !== value) ambiguous.add(key);
803
+ else constants.set(key, value);
804
+ }
805
+ all.push(...reading.doors);
806
+ }
807
+
808
+ /** @type {Door[]} */
809
+ const resolved = [];
810
+ for (const raw of all) {
811
+ if (typeof raw.name === 'string') {
812
+ resolved.push(/** @type {Door} */ (raw));
813
+ continue;
814
+ }
815
+ const wanted = raw.name.unresolved;
816
+ const known = constants.get(wanted);
817
+ if (known !== undefined && !ambiguous.has(wanted)) {
818
+ resolved.push({ ...raw, name: known, named: true, via: 'a constant from another file' });
819
+ } else {
820
+ // A door we can prove is there but cannot name. Reported as a hole, because dropping
821
+ // it is exactly how a contract list quietly becomes wrong.
822
+ resolved.push({
823
+ ...raw,
824
+ name: `${raw.file}:${raw.line}`,
825
+ named: false,
826
+ via: ambiguous.has(wanted)
827
+ ? `the constant ${wanted}, which two files define differently`
828
+ : `the constant ${wanted}, which was never found`,
829
+ });
830
+ }
831
+ }
832
+
833
+ const doors = resolved
834
+ .filter((d) => opts.includeTests || !d.inTest)
835
+ .sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name) || a.file.localeCompare(b.file) || a.line - b.line);
836
+
837
+ report.unnamed = doors.filter((d) => !d.named).length;
838
+ report.viaConstant = doors.filter((d) => d.named && d.via.includes('constant')).length;
839
+ /** @type {Set<string>} */
840
+ const seenNames = new Set();
841
+ for (const d of doors) {
842
+ const key = `${d.kind}:${d.name}:${d.detail}`;
843
+ // Only count a repeat that actually breaks something. An environment variable read in
844
+ // nine files, a flag mentioned in four, two `ipcMain.on` listeners and two modules
845
+ // exporting the same name are all normal; a second `ipcMain.handle` on one channel is
846
+ // refused by Electron at start-up, and a second route on one verb and path never runs.
847
+ const wouldBeABug = (d.kind === 'ipc' && d.detail.startsWith('answers')) || d.kind === 'route';
848
+ if (d.named && wouldBeABug && seenNames.has(key)) report.duplicates++;
849
+ seenNames.add(key);
850
+ report.counts[d.kind] = (report.counts[d.kind] ?? 0) + 1;
851
+ }
852
+
853
+ return { doors, report };
854
+ }
855
+
856
+ /** @param {unknown} error */
857
+ function describeError(error) {
858
+ return error instanceof Error ? error.message : String(error);
859
+ }
860
+
861
+ /**
862
+ * @param {string} root
863
+ * @param {string[]} folders
864
+ * @param {number} maxFileBytes
865
+ */
866
+ async function collectFiles(root, folders, maxFileBytes) {
867
+ /** @type {string[]} Files skipped for size, named so the gap can be reported. */
868
+ const tooBig = [];
869
+ /** @type {string[]} A folder that could not be opened at all, named for the same reason. */
870
+ const unreadable = [];
871
+ /** @type {string[]} */
872
+ const files = [];
873
+ let skipped = 0;
874
+
875
+ /** @param {string} dir */
876
+ const walk = async (dir) => {
877
+ /** @type {import('node:fs').Dirent[]} */
878
+ let entries;
879
+ try {
880
+ entries = await fsp.readdir(dir, { withFileTypes: true });
881
+ } catch (e) {
882
+ // A folder that will not open — a permission, a broken mount, a case-clash — used to
883
+ // vanish without a word, and every door behind it vanished with it. That is the same
884
+ // bug as the 2MB file limit wearing a different hat: fewer doors reported, and nothing
885
+ // anywhere saying so. Name it.
886
+ unreadable.push(`${path.relative(root, dir) || '.'} (${describeError(e)})`);
887
+ return;
888
+ }
889
+ for (const entry of entries) {
890
+ if (entry.name.startsWith('.')) continue;
891
+ if (entry.isSymbolicLink()) continue; // never follow a link back out of the project
892
+ const full = path.join(dir, entry.name);
893
+ if (entry.isDirectory()) {
894
+ if (!SKIP_DIRS.has(entry.name)) await walk(full);
895
+ continue;
896
+ }
897
+ if (!entry.isFile()) continue;
898
+ if (!CODE_EXTENSIONS.has(path.extname(entry.name))) continue;
899
+ if (/\.d\.[cm]?ts$/.test(entry.name)) continue; // declarations describe, they open nothing
900
+ try {
901
+ // A file too big to read is a hole, and a hole has to be named. Recording the
902
+ // path — not just a count — is what lets the coverage ledger say WHICH door it
903
+ // cannot see rather than quietly reporting fewer of them.
904
+ if ((await fsp.stat(full)).size > maxFileBytes) {
905
+ skipped++;
906
+ tooBig.push(path.relative(root, full));
907
+ continue;
908
+ }
909
+ } catch (e) {
910
+ unreadable.push(`${path.relative(root, full)} (${describeError(e)})`);
911
+ continue;
912
+ }
913
+ files.push(path.relative(root, full));
914
+ }
915
+ };
916
+
917
+ const roots = folders.map((f) => path.join(root, f)).filter((d) => fs.existsSync(d));
918
+ for (const dir of roots.length > 0 ? roots : [root]) await walk(dir);
919
+ files.sort();
920
+ // A source folder that was asked for and is not there at all is worth saying too: a typo in
921
+ // "folders" reads exactly like a project with no code in it.
922
+ const asked = folders.map((f) => path.join(root, f));
923
+ const present = asked.filter((d) => fs.existsSync(d));
924
+ return { files, skipped, tooBig, unreadable, lookedIn: present.length > 0 ? present.map((d) => path.relative(root, d)) : ['the whole project folder'] };
925
+ }
926
+
927
+ // ---------------------------------------------------------------------------
928
+ // Routes that live in the filesystem rather than in a call
929
+ // ---------------------------------------------------------------------------
930
+
931
+ /**
932
+ * Next.js puts its routes in folder names, so no amount of reading calls will find them.
933
+ * Both layouts are handled: an app folder, where a `route` file's exported method names are
934
+ * the verbs, and a pages/api folder, where the file itself is the route.
935
+ *
936
+ * A folder that cannot be opened takes every route behind it, so it is named rather than
937
+ * skipped. This is the same bug as the one fixed in the file walk on 2026-08-30 — a hole that
938
+ * looks exactly like a project with no routes in it — and it was still here in this function.
939
+ *
940
+ * @param {string} root
941
+ * @returns {Promise<{doors: Door[], problems: string[]}>}
942
+ */
943
+ export async function readFileRoutes(root) {
944
+ /** @type {Door[]} */
945
+ const doors = [];
946
+ /** @type {string[]} */
947
+ const problems = [];
948
+
949
+ /**
950
+ * @param {string} base
951
+ * @param {(rel: string, full: string) => Promise<void>} visit
952
+ */
953
+ const walk = async (base, visit) => {
954
+ if (!fs.existsSync(base)) return;
955
+ /** @type {string[]} */
956
+ const stack = [base];
957
+ while (stack.length > 0) {
958
+ const dir = /** @type {string} */ (stack.pop());
959
+ /** @type {import('node:fs').Dirent[]} */
960
+ let entries;
961
+ try {
962
+ entries = await fsp.readdir(dir, { withFileTypes: true });
963
+ } catch (e) {
964
+ problems.push(`${path.relative(root, dir) || '.'} could not be opened, so any route behind it is invisible to this run (${describeError(e)}).`);
965
+ continue;
966
+ }
967
+ for (const entry of entries) {
968
+ const full = path.join(dir, entry.name);
969
+ if (entry.isDirectory()) {
970
+ if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) stack.push(full);
971
+ } else if (entry.isFile()) {
972
+ await visit(path.relative(base, full), full);
973
+ }
974
+ }
975
+ }
976
+ };
977
+
978
+ for (const appDir of ['app', 'src/app']) {
979
+ await walk(path.join(root, appDir), async (rel, full) => {
980
+ if (!/(^|\/)route\.[cm]?[jt]sx?$/.test(rel.split(path.sep).join('/'))) return;
981
+ // A folder in brackets is a grouping, not part of the address; one starting with an
982
+ // underscore is private and is not routed at all.
983
+ const url = '/' + path.dirname(rel)
984
+ .split(path.sep)
985
+ .filter((s) => s !== '.' && !(s.startsWith('(') && s.endsWith(')')) && !s.startsWith('_'))
986
+ .join('/');
987
+ let verbs = ['ANY'];
988
+ try {
989
+ const reading = readFile(path.relative(root, full), await fsp.readFile(full, 'utf8'));
990
+ const named = reading.doors
991
+ .filter((d) => d.kind === 'export' && typeof d.name === 'string' && /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$/.test(d.name))
992
+ .map((d) => String(d.name));
993
+ if (named.length > 0) verbs = named;
994
+ } catch { /* an unreadable route file is still a route */ }
995
+ for (const verb of verbs) {
996
+ doors.push({
997
+ kind: 'route', name: url === '/' ? '/' : url.replace(/\/$/, ''), detail: verb,
998
+ file: path.relative(root, full), line: 1, inTest: false, named: true,
999
+ via: 'the folder it lives in',
1000
+ });
1001
+ }
1002
+ });
1003
+ }
1004
+
1005
+ for (const pagesDir of ['pages/api', 'src/pages/api']) {
1006
+ await walk(path.join(root, pagesDir), async (rel, full) => {
1007
+ if (!/\.[cm]?[jt]sx?$/.test(rel)) return;
1008
+ const stem = rel.replace(/\.[cm]?[jt]sx?$/, '').split(path.sep).join('/');
1009
+ doors.push({
1010
+ kind: 'route', name: `/api/${stem.replace(/\/?index$/, '')}`, detail: 'ANY',
1011
+ file: path.relative(root, full), line: 1, inTest: false, named: true,
1012
+ via: 'the folder it lives in',
1013
+ });
1014
+ });
1015
+ }
1016
+
1017
+ return { doors, problems };
1018
+ }
1019
+
1020
+ /**
1021
+ * The commands a package installs and the entries it exports, straight out of its own
1022
+ * package.json. Exact, because npm reads the same field.
1023
+ * @param {string} root
1024
+ * @returns {Promise<Door[]>}
1025
+ */
1026
+ export async function readPackageCommands(root) {
1027
+ /** @type {Door[]} */
1028
+ const doors = [];
1029
+ try {
1030
+ const pkg = JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8'));
1031
+ const bin = typeof pkg.bin === 'string' ? { [pkg.name]: pkg.bin } : (pkg.bin ?? {});
1032
+ for (const [name, target] of Object.entries(bin)) {
1033
+ doors.push({ kind: 'command', name, detail: `installs as a command, runs ${target}`, file: 'package.json', line: 1, inTest: false, named: true, via: 'package.json' });
1034
+ }
1035
+ for (const [name, script] of Object.entries(pkg.scripts ?? {})) {
1036
+ doors.push({ kind: 'command', name: `npm run ${name}`, detail: String(script), file: 'package.json', line: 1, inTest: false, named: true, via: 'package.json' });
1037
+ }
1038
+ for (const [name, target] of Object.entries(pkg.exports ?? {})) {
1039
+ doors.push({ kind: 'export', name: `the package entry ${name}`, detail: typeof target === 'string' ? target : 'a conditional entry', file: 'package.json', line: 1, inTest: false, named: true, via: 'package.json' });
1040
+ }
1041
+ } catch { /* not every project is an npm package */ }
1042
+ return doors;
1043
+ }
1044
+
1045
+ // ---------------------------------------------------------------------------
1046
+ // Turning a reading into observations
1047
+ // ---------------------------------------------------------------------------
1048
+
1049
+ /**
1050
+ * The path an observation about one door gets.
1051
+ *
1052
+ * The head names the KIND of door, not the journey that found it. That ordering is what
1053
+ * lets the engine cluster twenty missing IPC channels into one finding, and it is why these
1054
+ * heads match the ones the engine already knows: `ipc`, `route`, `export`, `cli`, `proc`.
1055
+ *
1056
+ * An exported name carries its file; a channel, a route, a command and a setting do not,
1057
+ * because those are global names and moving one to another file does not change the
1058
+ * promise. That distinction is the whole difference between a contract diff that stays
1059
+ * quiet through a refactor and one that shouts through it.
1060
+ *
1061
+ * @param {Door} found
1062
+ */
1063
+ function pathForDoor(found) {
1064
+ switch (found.kind) {
1065
+ case 'ipc': return joinPath('ipc', found.name, 'registered');
1066
+ case 'route': return joinPath('route', found.detail, found.name, 'declared');
1067
+ case 'export': return joinPath('export', found.file, found.name);
1068
+ case 'command': return joinPath('cli', found.name, 'declared');
1069
+ default: return joinPath('proc', 'env', found.name);
1070
+ }
1071
+ }
1072
+
1073
+ /** @type {Record<Door['kind'], string>} */
1074
+ const KIND_LABEL = { ipc: 'ipc channel', route: 'route', export: 'exported', command: 'command', env: 'environment' };
1075
+
1076
+ /** @type {Record<Door['kind'], string>} */
1077
+ const KIND_PHRASE = {
1078
+ ipc: 'an IPC channel', route: 'a route', export: 'an exported name',
1079
+ command: 'a command', env: 'an environment variable it reads',
1080
+ };
1081
+
1082
+ /**
1083
+ * One observation per door, so a difference points at the door that went missing rather
1084
+ * than at a list that got shorter. The counts go alongside, because "there are three fewer
1085
+ * IPC channels than the build you shipped" is the sentence that makes somebody look.
1086
+ *
1087
+ * Three kinds of door repeat legitimately and three do not, and the paths are built to
1088
+ * match. An environment variable read in nine files is one setting, not nine; a flag
1089
+ * mentioned in four files is one flag. But two `ipcMain.handle` calls on the same channel
1090
+ * is a bug Electron refuses at start-up, and two routes on the same verb and path means one
1091
+ * of them never runs — so those are said out loud. Two `ipcMain.on` listeners are perfectly
1092
+ * legal and are not. An exported name is qualified by its file, because two modules
1093
+ * exporting `parse` are two different functions.
1094
+ *
1095
+ * @param {ContractReading} reading
1096
+ * @param {string} [journeyId]
1097
+ * @returns {import('./contract.js').Observation[]}
1098
+ */
1099
+ export function contractObservations(reading, journeyId = 'the-code') {
1100
+ /** @type {import('./contract.js').Observation[]} */
1101
+ const out = [];
1102
+ /** @type {Map<string, number>} */
1103
+ const seen = new Map();
1104
+
1105
+ for (const found of reading.doors) {
1106
+ const repeatsAreLegal = found.kind === 'env' || found.kind === 'command'
1107
+ || (found.kind === 'ipc' && found.detail.startsWith('listens'));
1108
+ const key = `${found.kind}:${found.name}:${found.detail}`;
1109
+ const times = (seen.get(key) ?? 0) + 1;
1110
+ seen.set(key, times);
1111
+ if (times > 1 && repeatsAreLegal) continue; // one setting, not nine mentions of it
1112
+
1113
+ const suffix = times > 1 ? ` (registered ${times} times — only the last one takes effect)` : '';
1114
+ out.push(observation({
1115
+ channel: 'contract',
1116
+ path: pathForDoor(found),
1117
+ value: found.named ? found.detail + suffix : `there, but we cannot read its name${suffix}`,
1118
+ says: found.named
1119
+ ? `The code opens ${KIND_PHRASE[found.kind]} called "${found.name}" that ${found.detail}.${
1120
+ times > 1 ? ' It is registered more than once, which means only the last one has any effect.' : ''}`
1121
+ : `The code opens ${KIND_PHRASE[found.kind]} whose name is worked out while it runs, so we know it is there but not what it is called (${found.via}).`,
1122
+ covered: found.named ? undefined : false,
1123
+ reason: found.named ? undefined : 'not supported here',
1124
+ where: { file: found.file, line: found.line },
1125
+ journey: journeyId,
1126
+ }));
1127
+ }
1128
+
1129
+ for (const [kind, count] of Object.entries(reading.report.counts)) {
1130
+ const label = KIND_LABEL[/** @type {Door['kind']} */ (kind)] ?? kind;
1131
+ out.push(observation({
1132
+ channel: 'counters',
1133
+ path: joinPath('count', 'contract', label),
1134
+ value: count,
1135
+ says: `The code has ${count} ${label} ${count === 1 ? 'door' : 'doors'} in it.`,
1136
+ }));
1137
+ }
1138
+
1139
+ if (reading.report.unnamed > 0) {
1140
+ const many = reading.report.unnamed !== 1;
1141
+ out.push(notCovered({
1142
+ channel: 'contract',
1143
+ path: joinPath('count', 'contract', 'doors we cannot name'),
1144
+ reason: 'not supported here',
1145
+ says: `${reading.report.unnamed} door${many ? 's' : ''} ${many ? 'exist' : 'exists'} whose ${many ? 'names are' : 'name is'} built while the program runs, so a change to ${many ? 'them' : 'it'} would not be seen here.`,
1146
+ }));
1147
+ }
1148
+ for (const problem of reading.report.problems) {
1149
+ out.push(notCovered({
1150
+ channel: 'contract',
1151
+ path: joinPath('contract', 'unreadable', problem.split(' ')[0]),
1152
+ reason: 'crashed',
1153
+ says: problem,
1154
+ }));
1155
+ }
1156
+
1157
+ return out;
1158
+ }
1159
+
1160
+ // ---------------------------------------------------------------------------
1161
+ // The adapter
1162
+ // ---------------------------------------------------------------------------
1163
+
1164
+ /**
1165
+ * Which platform this project actually is.
1166
+ *
1167
+ * Guessed from what it depends on, because the answer is usually obvious from the
1168
+ * dependencies and asking a person a question the code already answers is exactly what this
1169
+ * tool is supposed to stop. A project can say so outright in its config and be believed.
1170
+ *
1171
+ * @param {import('./contract.js').AdapterProject} project
1172
+ * @returns {import('./contract.js').Surface}
1173
+ */
1174
+ export function surfaceOf(project) {
1175
+ if (project.config?.surface) return project.config.surface;
1176
+ try {
1177
+ const pkg = JSON.parse(fs.readFileSync(path.join(project.root, 'package.json'), 'utf8'));
1178
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
1179
+ if ('electron' in deps) return 'electron';
1180
+ if (['express', 'fastify', 'hono', 'koa', 'next', '@hapi/hapi'].some((n) => n in deps)) return 'server';
1181
+ if (pkg.bin) return 'cli';
1182
+ } catch { /* no package.json is an answer too */ }
1183
+ return 'library';
1184
+ }
1185
+
1186
+ /** @type {ContractReading|null} */
1187
+ let lastReading = null;
1188
+
1189
+ /**
1190
+ * The static-contract adapter.
1191
+ *
1192
+ * It applies to every project, always, because every project has source. It is the one
1193
+ * adapter that costs nothing to run and can never break anything, so the engine runs it
1194
+ * first and hands its result to the others — the HTTP adapter learns its routes from here
1195
+ * rather than by crawling a running server.
1196
+ */
1197
+ export const sourceAdapter = defineAdapter({
1198
+ name: 'source',
1199
+ title: 'The contract, read out of the code',
1200
+ describe:
1201
+ "Reads the project's own source without running any of it and lists every door it opens: IPC channels, HTTP routes, exported functions, commands and the environment variables it reads. It cannot see a door whose name is assembled while the program runs, and it cannot tell whether a door is reachable — only that it was written.",
1202
+ channels: ['contract', 'counters'],
1203
+
1204
+ /** @param {import('./contract.js').AdapterProject} project */
1205
+ async detect(project) {
1206
+ const folders = project.config?.folders ?? SOURCE_FOLDERS;
1207
+ // The same limit readContract uses. It was 2MB here and 24MB there, so a project
1208
+ // whose source is one big bundle was declared to have no source at all — and then
1209
+ // never read, even though the reader could have read it perfectly well.
1210
+ const found = await collectFiles(project.root, folders, 24 * 1024 * 1024);
1211
+ const canStrip = typeof nodeModule.stripTypeScriptTypes === 'function';
1212
+ /** @type {import('./contract.js').Missing[]} */
1213
+ const missing = [];
1214
+ if (!canStrip) {
1215
+ missing.push({
1216
+ what: 'Node 22.6 or newer',
1217
+ unlocks: 'cleaner reading of TypeScript files — without it the type annotations are left in and a few names are read less accurately',
1218
+ howToGet: 'upgrade Node; nothing else is needed',
1219
+ });
1220
+ }
1221
+ return {
1222
+ applies: found.files.length > 0,
1223
+ confidence: found.files.length > 0 ? 1 : 0,
1224
+ why: found.files.length > 0
1225
+ ? `There are ${found.files.length} source files to read. Nothing gets run, so this costs almost nothing and it cannot break anything.`
1226
+ : 'No JavaScript or TypeScript source was found in the usual folders, so there is nothing to read.',
1227
+ missing,
1228
+ notes: canStrip ? [] : ['TypeScript types are being read as they are, rather than stripped out first.'],
1229
+ };
1230
+ },
1231
+
1232
+ /** @param {import('./contract.js').AdapterProject} project */
1233
+ async journeys(project) {
1234
+ return [{
1235
+ name: 'the-code',
1236
+ describe: 'read every door out of the source without running any of it',
1237
+ source: 'code',
1238
+ surface: surfaceOf(project),
1239
+ channels: ['contract', 'counters'],
1240
+ steps: [{ act: 'read', folders: project.config?.folders ?? SOURCE_FOLDERS }],
1241
+ }];
1242
+ },
1243
+
1244
+ /** @param {import('./contract.js').Build} build */
1245
+ async prepare(build) {
1246
+ // Nothing to prepare. The source is read where it lies and never written to.
1247
+ return {
1248
+ build,
1249
+ root: build.root,
1250
+ ready: true,
1251
+ why: 'Reading source needs no preparation and never touches the files.',
1252
+ dispose: async () => {},
1253
+ };
1254
+ },
1255
+
1256
+ /**
1257
+ * @param {import('./contract.js').Journey} journey
1258
+ * @param {import('./contract.js').PreparedBuild} build
1259
+ */
1260
+ async run(journey, build) {
1261
+ const reading = await readContract({ root: build.root });
1262
+ const fileRoutes = await readFileRoutes(build.root);
1263
+ reading.doors.push(...fileRoutes.doors);
1264
+ // A folder the route walk could not open is a hole in the door list, and the door list is
1265
+ // the channel that catches doors disappearing. It goes where every other reading problem
1266
+ // goes: into the report, which becomes an observation of its own, so it shows up as a
1267
+ // difference the moment it starts or stops happening.
1268
+ reading.report.problems.push(...fileRoutes.problems);
1269
+ reading.doors.push(...await readPackageCommands(build.root));
1270
+ reading.report.counts = {};
1271
+ for (const found of reading.doors) {
1272
+ reading.report.counts[found.kind] = (reading.report.counts[found.kind] ?? 0) + 1;
1273
+ }
1274
+ lastReading = reading;
1275
+ return contractObservations(reading, journey.name);
1276
+ },
1277
+
1278
+ async teardown() {
1279
+ lastReading = null;
1280
+ },
1281
+ });
1282
+
1283
+ /**
1284
+ * The last thing the source adapter read.
1285
+ *
1286
+ * The HTTP adapter uses this to find its routes without crawling. Anything that cannot
1287
+ * guarantee it runs after the source adapter should call {@link readContract} itself rather
1288
+ * than depend on run order.
1289
+ */
1290
+ export function lastContractReading() {
1291
+ return lastReading;
1292
+ }