staysfixed 0.3.1 → 0.4.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 (46) hide show
  1. package/README.md +534 -402
  2. package/package.json +8 -3
  3. package/src/cli/index.js +14 -0
  4. package/src/v2/adapters/android-driver.js +1705 -0
  5. package/src/v2/adapters/android.js +1117 -0
  6. package/src/v2/adapters/contract.js +565 -0
  7. package/src/v2/adapters/electron.js +1594 -0
  8. package/src/v2/adapters/http.js +733 -0
  9. package/src/v2/adapters/ios-driver.js +1551 -0
  10. package/src/v2/adapters/ios.js +989 -0
  11. package/src/v2/adapters/isolate.js +739 -0
  12. package/src/v2/adapters/process.js +920 -0
  13. package/src/v2/adapters/source.js +1241 -0
  14. package/src/v2/adapters/web-driver.js +1532 -0
  15. package/src/v2/adapters/web.js +1009 -0
  16. package/src/v2/adapters/windows.js +1329 -0
  17. package/src/v2/browsers.js +1203 -0
  18. package/src/v2/cause.js +364 -0
  19. package/src/v2/check.js +1331 -0
  20. package/src/v2/ci.js +1209 -0
  21. package/src/v2/cli.js +657 -0
  22. package/src/v2/cluster.js +372 -0
  23. package/src/v2/coverage.js +1116 -0
  24. package/src/v2/detect.js +1199 -0
  25. package/src/v2/doctor.js +1690 -0
  26. package/src/v2/escalate.js +679 -0
  27. package/src/v2/init.js +1394 -0
  28. package/src/v2/intent.js +659 -0
  29. package/src/v2/journeys/from-routes.js +498 -0
  30. package/src/v2/journeys/from-suite.js +988 -0
  31. package/src/v2/journeys/index.js +651 -0
  32. package/src/v2/journeys/record.js +516 -0
  33. package/src/v2/mcp/server.js +374 -0
  34. package/src/v2/mcp/tools.js +1571 -0
  35. package/src/v2/normalise.js +783 -0
  36. package/src/v2/observation.js +877 -0
  37. package/src/v2/rank.js +672 -0
  38. package/src/v2/reference.js +1051 -0
  39. package/src/v2/remote.js +911 -0
  40. package/src/v2/run.js +964 -0
  41. package/src/v2/sealed.js +564 -0
  42. package/src/v2/selfcheck.js +564 -0
  43. package/src/v2/ship.js +684 -0
  44. package/src/v2/store.js +703 -0
  45. package/src/v2/types.js +503 -0
  46. package/src/v2/waiver.js +511 -0
@@ -0,0 +1,1241 @@
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 2MB.
752
+ * @returns {Promise<ContractReading>}
753
+ */
754
+ export async function readContract(opts) {
755
+ const root = path.resolve(opts.root);
756
+ const maxFileBytes = opts.maxFileBytes ?? 2 * 1024 * 1024;
757
+ const found = await collectFiles(root, opts.folders ?? SOURCE_FOLDERS, maxFileBytes);
758
+
759
+ /** @type {RawDoor[]} */
760
+ const all = [];
761
+ /** @type {Map<string, string>} every string constant anywhere in the project */
762
+ const constants = new Map();
763
+ /** @type {Set<string>} names two files define differently */
764
+ const ambiguous = new Set();
765
+ /** @type {ReadingReport} */
766
+ const report = {
767
+ filesRead: 0, filesSkipped: found.skipped, testFiles: 0, lexRecoveries: 0,
768
+ typesStripped: 0, unnamed: 0, viaConstant: 0, duplicates: 0, problems: [], counts: {},
769
+ };
770
+
771
+ for (const rel of found.files) {
772
+ let text;
773
+ try {
774
+ text = await fsp.readFile(path.join(root, rel), 'utf8');
775
+ } catch (error) {
776
+ report.problems.push(`${rel} could not be opened: ${describeError(error)}`);
777
+ continue;
778
+ }
779
+ let reading;
780
+ try {
781
+ reading = readFile(rel, text);
782
+ } catch (error) {
783
+ report.problems.push(`${rel} could not be read: ${describeError(error)}`);
784
+ continue;
785
+ }
786
+ report.filesRead++;
787
+ report.lexRecoveries += reading.recoveries;
788
+ if (reading.typesStripped) report.typesStripped++;
789
+ if (looksLikeATest(rel)) report.testFiles++;
790
+ for (const [key, value] of reading.constants) {
791
+ const known = constants.get(key);
792
+ if (known !== undefined && known !== value) ambiguous.add(key);
793
+ else constants.set(key, value);
794
+ }
795
+ all.push(...reading.doors);
796
+ }
797
+
798
+ /** @type {Door[]} */
799
+ const resolved = [];
800
+ for (const raw of all) {
801
+ if (typeof raw.name === 'string') {
802
+ resolved.push(/** @type {Door} */ (raw));
803
+ continue;
804
+ }
805
+ const wanted = raw.name.unresolved;
806
+ const known = constants.get(wanted);
807
+ if (known !== undefined && !ambiguous.has(wanted)) {
808
+ resolved.push({ ...raw, name: known, named: true, via: 'a constant from another file' });
809
+ } else {
810
+ // A door we can prove is there but cannot name. Reported as a hole, because dropping
811
+ // it is exactly how a contract list quietly becomes wrong.
812
+ resolved.push({
813
+ ...raw,
814
+ name: `${raw.file}:${raw.line}`,
815
+ named: false,
816
+ via: ambiguous.has(wanted)
817
+ ? `the constant ${wanted}, which two files define differently`
818
+ : `the constant ${wanted}, which was never found`,
819
+ });
820
+ }
821
+ }
822
+
823
+ const doors = resolved
824
+ .filter((d) => opts.includeTests || !d.inTest)
825
+ .sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name) || a.file.localeCompare(b.file) || a.line - b.line);
826
+
827
+ report.unnamed = doors.filter((d) => !d.named).length;
828
+ report.viaConstant = doors.filter((d) => d.named && d.via.includes('constant')).length;
829
+ /** @type {Set<string>} */
830
+ const seenNames = new Set();
831
+ for (const d of doors) {
832
+ const key = `${d.kind}:${d.name}:${d.detail}`;
833
+ // Only count a repeat that actually breaks something. An environment variable read in
834
+ // nine files, a flag mentioned in four, two `ipcMain.on` listeners and two modules
835
+ // exporting the same name are all normal; a second `ipcMain.handle` on one channel is
836
+ // refused by Electron at start-up, and a second route on one verb and path never runs.
837
+ const wouldBeABug = (d.kind === 'ipc' && d.detail.startsWith('answers')) || d.kind === 'route';
838
+ if (d.named && wouldBeABug && seenNames.has(key)) report.duplicates++;
839
+ seenNames.add(key);
840
+ report.counts[d.kind] = (report.counts[d.kind] ?? 0) + 1;
841
+ }
842
+
843
+ return { doors, report };
844
+ }
845
+
846
+ /** @param {unknown} error */
847
+ function describeError(error) {
848
+ return error instanceof Error ? error.message : String(error);
849
+ }
850
+
851
+ /**
852
+ * @param {string} root
853
+ * @param {string[]} folders
854
+ * @param {number} maxFileBytes
855
+ */
856
+ async function collectFiles(root, folders, maxFileBytes) {
857
+ /** @type {string[]} */
858
+ const files = [];
859
+ let skipped = 0;
860
+
861
+ /** @param {string} dir */
862
+ const walk = async (dir) => {
863
+ /** @type {import('node:fs').Dirent[]} */
864
+ let entries;
865
+ try {
866
+ entries = await fsp.readdir(dir, { withFileTypes: true });
867
+ } catch {
868
+ return;
869
+ }
870
+ for (const entry of entries) {
871
+ if (entry.name.startsWith('.')) continue;
872
+ if (entry.isSymbolicLink()) continue; // never follow a link back out of the project
873
+ const full = path.join(dir, entry.name);
874
+ if (entry.isDirectory()) {
875
+ if (!SKIP_DIRS.has(entry.name)) await walk(full);
876
+ continue;
877
+ }
878
+ if (!entry.isFile()) continue;
879
+ if (!CODE_EXTENSIONS.has(path.extname(entry.name))) continue;
880
+ if (/\.d\.[cm]?ts$/.test(entry.name)) continue; // declarations describe, they open nothing
881
+ try {
882
+ if ((await fsp.stat(full)).size > maxFileBytes) { skipped++; continue; }
883
+ } catch {
884
+ continue;
885
+ }
886
+ files.push(path.relative(root, full));
887
+ }
888
+ };
889
+
890
+ const roots = folders.map((f) => path.join(root, f)).filter((d) => fs.existsSync(d));
891
+ for (const dir of roots.length > 0 ? roots : [root]) await walk(dir);
892
+ files.sort();
893
+ return { files, skipped };
894
+ }
895
+
896
+ // ---------------------------------------------------------------------------
897
+ // Routes that live in the filesystem rather than in a call
898
+ // ---------------------------------------------------------------------------
899
+
900
+ /**
901
+ * Next.js puts its routes in folder names, so no amount of reading calls will find them.
902
+ * Both layouts are handled: an app folder, where a `route` file's exported method names are
903
+ * the verbs, and a pages/api folder, where the file itself is the route.
904
+ *
905
+ * @param {string} root
906
+ * @returns {Promise<Door[]>}
907
+ */
908
+ export async function readFileRoutes(root) {
909
+ /** @type {Door[]} */
910
+ const doors = [];
911
+
912
+ /**
913
+ * @param {string} base
914
+ * @param {(rel: string, full: string) => Promise<void>} visit
915
+ */
916
+ const walk = async (base, visit) => {
917
+ if (!fs.existsSync(base)) return;
918
+ /** @type {string[]} */
919
+ const stack = [base];
920
+ while (stack.length > 0) {
921
+ const dir = /** @type {string} */ (stack.pop());
922
+ /** @type {import('node:fs').Dirent[]} */
923
+ let entries;
924
+ try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { continue; }
925
+ for (const entry of entries) {
926
+ const full = path.join(dir, entry.name);
927
+ if (entry.isDirectory()) {
928
+ if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) stack.push(full);
929
+ } else if (entry.isFile()) {
930
+ await visit(path.relative(base, full), full);
931
+ }
932
+ }
933
+ }
934
+ };
935
+
936
+ for (const appDir of ['app', 'src/app']) {
937
+ await walk(path.join(root, appDir), async (rel, full) => {
938
+ if (!/(^|\/)route\.[cm]?[jt]sx?$/.test(rel.split(path.sep).join('/'))) return;
939
+ // A folder in brackets is a grouping, not part of the address; one starting with an
940
+ // underscore is private and is not routed at all.
941
+ const url = '/' + path.dirname(rel)
942
+ .split(path.sep)
943
+ .filter((s) => s !== '.' && !(s.startsWith('(') && s.endsWith(')')) && !s.startsWith('_'))
944
+ .join('/');
945
+ let verbs = ['ANY'];
946
+ try {
947
+ const reading = readFile(path.relative(root, full), await fsp.readFile(full, 'utf8'));
948
+ const named = reading.doors
949
+ .filter((d) => d.kind === 'export' && typeof d.name === 'string' && /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$/.test(d.name))
950
+ .map((d) => String(d.name));
951
+ if (named.length > 0) verbs = named;
952
+ } catch { /* an unreadable route file is still a route */ }
953
+ for (const verb of verbs) {
954
+ doors.push({
955
+ kind: 'route', name: url === '/' ? '/' : url.replace(/\/$/, ''), detail: verb,
956
+ file: path.relative(root, full), line: 1, inTest: false, named: true,
957
+ via: 'the folder it lives in',
958
+ });
959
+ }
960
+ });
961
+ }
962
+
963
+ for (const pagesDir of ['pages/api', 'src/pages/api']) {
964
+ await walk(path.join(root, pagesDir), async (rel, full) => {
965
+ if (!/\.[cm]?[jt]sx?$/.test(rel)) return;
966
+ const stem = rel.replace(/\.[cm]?[jt]sx?$/, '').split(path.sep).join('/');
967
+ doors.push({
968
+ kind: 'route', name: `/api/${stem.replace(/\/?index$/, '')}`, detail: 'ANY',
969
+ file: path.relative(root, full), line: 1, inTest: false, named: true,
970
+ via: 'the folder it lives in',
971
+ });
972
+ });
973
+ }
974
+
975
+ return doors;
976
+ }
977
+
978
+ /**
979
+ * The commands a package installs and the entries it exports, straight out of its own
980
+ * package.json. Exact, because npm reads the same field.
981
+ * @param {string} root
982
+ * @returns {Promise<Door[]>}
983
+ */
984
+ export async function readPackageCommands(root) {
985
+ /** @type {Door[]} */
986
+ const doors = [];
987
+ try {
988
+ const pkg = JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8'));
989
+ const bin = typeof pkg.bin === 'string' ? { [pkg.name]: pkg.bin } : (pkg.bin ?? {});
990
+ for (const [name, target] of Object.entries(bin)) {
991
+ doors.push({ kind: 'command', name, detail: `installs as a command, runs ${target}`, file: 'package.json', line: 1, inTest: false, named: true, via: 'package.json' });
992
+ }
993
+ for (const [name, script] of Object.entries(pkg.scripts ?? {})) {
994
+ doors.push({ kind: 'command', name: `npm run ${name}`, detail: String(script), file: 'package.json', line: 1, inTest: false, named: true, via: 'package.json' });
995
+ }
996
+ for (const [name, target] of Object.entries(pkg.exports ?? {})) {
997
+ 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' });
998
+ }
999
+ } catch { /* not every project is an npm package */ }
1000
+ return doors;
1001
+ }
1002
+
1003
+ // ---------------------------------------------------------------------------
1004
+ // Turning a reading into observations
1005
+ // ---------------------------------------------------------------------------
1006
+
1007
+ /**
1008
+ * The path an observation about one door gets.
1009
+ *
1010
+ * The head names the KIND of door, not the journey that found it. That ordering is what
1011
+ * lets the engine cluster twenty missing IPC channels into one finding, and it is why these
1012
+ * heads match the ones the engine already knows: `ipc`, `route`, `export`, `cli`, `proc`.
1013
+ *
1014
+ * An exported name carries its file; a channel, a route, a command and a setting do not,
1015
+ * because those are global names and moving one to another file does not change the
1016
+ * promise. That distinction is the whole difference between a contract diff that stays
1017
+ * quiet through a refactor and one that shouts through it.
1018
+ *
1019
+ * @param {Door} found
1020
+ */
1021
+ function pathForDoor(found) {
1022
+ switch (found.kind) {
1023
+ case 'ipc': return joinPath('ipc', found.name, 'registered');
1024
+ case 'route': return joinPath('route', found.detail, found.name, 'declared');
1025
+ case 'export': return joinPath('export', found.file, found.name);
1026
+ case 'command': return joinPath('cli', found.name, 'declared');
1027
+ default: return joinPath('proc', 'env', found.name);
1028
+ }
1029
+ }
1030
+
1031
+ /** @type {Record<Door['kind'], string>} */
1032
+ const KIND_LABEL = { ipc: 'ipc channel', route: 'route', export: 'exported', command: 'command', env: 'environment' };
1033
+
1034
+ /** @type {Record<Door['kind'], string>} */
1035
+ const KIND_PHRASE = {
1036
+ ipc: 'an IPC channel', route: 'a route', export: 'an exported name',
1037
+ command: 'a command', env: 'an environment variable it reads',
1038
+ };
1039
+
1040
+ /**
1041
+ * One observation per door, so a difference points at the door that went missing rather
1042
+ * than at a list that got shorter. The counts go alongside, because "there are three fewer
1043
+ * IPC channels than the build you shipped" is the sentence that makes somebody look.
1044
+ *
1045
+ * Three kinds of door repeat legitimately and three do not, and the paths are built to
1046
+ * match. An environment variable read in nine files is one setting, not nine; a flag
1047
+ * mentioned in four files is one flag. But two `ipcMain.handle` calls on the same channel
1048
+ * is a bug Electron refuses at start-up, and two routes on the same verb and path means one
1049
+ * of them never runs — so those are said out loud. Two `ipcMain.on` listeners are perfectly
1050
+ * legal and are not. An exported name is qualified by its file, because two modules
1051
+ * exporting `parse` are two different functions.
1052
+ *
1053
+ * @param {ContractReading} reading
1054
+ * @param {string} [journeyId]
1055
+ * @returns {import('./contract.js').Observation[]}
1056
+ */
1057
+ export function contractObservations(reading, journeyId = 'the-code') {
1058
+ /** @type {import('./contract.js').Observation[]} */
1059
+ const out = [];
1060
+ /** @type {Map<string, number>} */
1061
+ const seen = new Map();
1062
+
1063
+ for (const found of reading.doors) {
1064
+ const repeatsAreLegal = found.kind === 'env' || found.kind === 'command'
1065
+ || (found.kind === 'ipc' && found.detail.startsWith('listens'));
1066
+ const key = `${found.kind}:${found.name}:${found.detail}`;
1067
+ const times = (seen.get(key) ?? 0) + 1;
1068
+ seen.set(key, times);
1069
+ if (times > 1 && repeatsAreLegal) continue; // one setting, not nine mentions of it
1070
+
1071
+ const suffix = times > 1 ? ` (registered ${times} times — only the last one takes effect)` : '';
1072
+ out.push(observation({
1073
+ channel: 'contract',
1074
+ path: pathForDoor(found),
1075
+ value: found.named ? found.detail + suffix : `there, but we cannot read its name${suffix}`,
1076
+ says: found.named
1077
+ ? `The code opens ${KIND_PHRASE[found.kind]} called "${found.name}" that ${found.detail}.${
1078
+ times > 1 ? ' It is registered more than once, which means only the last one has any effect.' : ''}`
1079
+ : `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}).`,
1080
+ covered: found.named ? undefined : false,
1081
+ reason: found.named ? undefined : 'not supported here',
1082
+ where: { file: found.file, line: found.line },
1083
+ journey: journeyId,
1084
+ }));
1085
+ }
1086
+
1087
+ for (const [kind, count] of Object.entries(reading.report.counts)) {
1088
+ const label = KIND_LABEL[/** @type {Door['kind']} */ (kind)] ?? kind;
1089
+ out.push(observation({
1090
+ channel: 'counters',
1091
+ path: joinPath('count', 'contract', label),
1092
+ value: count,
1093
+ says: `The code has ${count} ${label} ${count === 1 ? 'door' : 'doors'} in it.`,
1094
+ }));
1095
+ }
1096
+
1097
+ if (reading.report.unnamed > 0) {
1098
+ const many = reading.report.unnamed !== 1;
1099
+ out.push(notCovered({
1100
+ channel: 'contract',
1101
+ path: joinPath('count', 'contract', 'doors we cannot name'),
1102
+ reason: 'not supported here',
1103
+ 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.`,
1104
+ }));
1105
+ }
1106
+ for (const problem of reading.report.problems) {
1107
+ out.push(notCovered({
1108
+ channel: 'contract',
1109
+ path: joinPath('contract', 'unreadable', problem.split(' ')[0]),
1110
+ reason: 'crashed',
1111
+ says: problem,
1112
+ }));
1113
+ }
1114
+
1115
+ return out;
1116
+ }
1117
+
1118
+ // ---------------------------------------------------------------------------
1119
+ // The adapter
1120
+ // ---------------------------------------------------------------------------
1121
+
1122
+ /**
1123
+ * Which platform this project actually is.
1124
+ *
1125
+ * Guessed from what it depends on, because the answer is usually obvious from the
1126
+ * dependencies and asking a person a question the code already answers is exactly what this
1127
+ * tool is supposed to stop. A project can say so outright in its config and be believed.
1128
+ *
1129
+ * @param {import('./contract.js').AdapterProject} project
1130
+ * @returns {import('./contract.js').Surface}
1131
+ */
1132
+ export function surfaceOf(project) {
1133
+ if (project.config?.surface) return project.config.surface;
1134
+ try {
1135
+ const pkg = JSON.parse(fs.readFileSync(path.join(project.root, 'package.json'), 'utf8'));
1136
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
1137
+ if ('electron' in deps) return 'electron';
1138
+ if (['express', 'fastify', 'hono', 'koa', 'next', '@hapi/hapi'].some((n) => n in deps)) return 'server';
1139
+ if (pkg.bin) return 'cli';
1140
+ } catch { /* no package.json is an answer too */ }
1141
+ return 'library';
1142
+ }
1143
+
1144
+ /** @type {ContractReading|null} */
1145
+ let lastReading = null;
1146
+
1147
+ /**
1148
+ * The static-contract adapter.
1149
+ *
1150
+ * It applies to every project, always, because every project has source. It is the one
1151
+ * adapter that costs nothing to run and can never break anything, so the engine runs it
1152
+ * first and hands its result to the others — the HTTP adapter learns its routes from here
1153
+ * rather than by crawling a running server.
1154
+ */
1155
+ export const sourceAdapter = defineAdapter({
1156
+ name: 'source',
1157
+ title: 'The contract, read out of the code',
1158
+ describe:
1159
+ "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.",
1160
+ channels: ['contract', 'counters'],
1161
+
1162
+ /** @param {import('./contract.js').AdapterProject} project */
1163
+ async detect(project) {
1164
+ const folders = project.config?.folders ?? SOURCE_FOLDERS;
1165
+ const found = await collectFiles(project.root, folders, 2 * 1024 * 1024);
1166
+ const canStrip = typeof nodeModule.stripTypeScriptTypes === 'function';
1167
+ /** @type {import('./contract.js').Missing[]} */
1168
+ const missing = [];
1169
+ if (!canStrip) {
1170
+ missing.push({
1171
+ what: 'Node 22.6 or newer',
1172
+ unlocks: 'cleaner reading of TypeScript files — without it the type annotations are left in and a few names are read less accurately',
1173
+ howToGet: 'upgrade Node; nothing else is needed',
1174
+ });
1175
+ }
1176
+ return {
1177
+ applies: found.files.length > 0,
1178
+ confidence: found.files.length > 0 ? 1 : 0,
1179
+ why: found.files.length > 0
1180
+ ? `There are ${found.files.length} source files to read. Nothing gets run, so this costs almost nothing and it cannot break anything.`
1181
+ : 'No JavaScript or TypeScript source was found in the usual folders, so there is nothing to read.',
1182
+ missing,
1183
+ notes: canStrip ? [] : ['TypeScript types are being read as they are, rather than stripped out first.'],
1184
+ };
1185
+ },
1186
+
1187
+ /** @param {import('./contract.js').AdapterProject} project */
1188
+ async journeys(project) {
1189
+ return [{
1190
+ name: 'the-code',
1191
+ describe: 'read every door out of the source without running any of it',
1192
+ source: 'code',
1193
+ surface: surfaceOf(project),
1194
+ channels: ['contract', 'counters'],
1195
+ steps: [{ act: 'read', folders: project.config?.folders ?? SOURCE_FOLDERS }],
1196
+ }];
1197
+ },
1198
+
1199
+ /** @param {import('./contract.js').Build} build */
1200
+ async prepare(build) {
1201
+ // Nothing to prepare. The source is read where it lies and never written to.
1202
+ return {
1203
+ build,
1204
+ root: build.root,
1205
+ ready: true,
1206
+ why: 'Reading source needs no preparation and never touches the files.',
1207
+ dispose: async () => {},
1208
+ };
1209
+ },
1210
+
1211
+ /**
1212
+ * @param {import('./contract.js').Journey} journey
1213
+ * @param {import('./contract.js').PreparedBuild} build
1214
+ */
1215
+ async run(journey, build) {
1216
+ const reading = await readContract({ root: build.root });
1217
+ reading.doors.push(...await readFileRoutes(build.root));
1218
+ reading.doors.push(...await readPackageCommands(build.root));
1219
+ reading.report.counts = {};
1220
+ for (const found of reading.doors) {
1221
+ reading.report.counts[found.kind] = (reading.report.counts[found.kind] ?? 0) + 1;
1222
+ }
1223
+ lastReading = reading;
1224
+ return contractObservations(reading, journey.name);
1225
+ },
1226
+
1227
+ async teardown() {
1228
+ lastReading = null;
1229
+ },
1230
+ });
1231
+
1232
+ /**
1233
+ * The last thing the source adapter read.
1234
+ *
1235
+ * The HTTP adapter uses this to find its routes without crawling. Anything that cannot
1236
+ * guarantee it runs after the source adapter should call {@link readContract} itself rather
1237
+ * than depend on run order.
1238
+ */
1239
+ export function lastContractReading() {
1240
+ return lastReading;
1241
+ }