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.
- package/README.md +534 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +565 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +733 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +920 -0
- package/src/v2/adapters/source.js +1241 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +364 -0
- package/src/v2/check.js +1331 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +657 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1116 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1690 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +498 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +877 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +911 -0
- package/src/v2/run.js +964 -0
- package/src/v2/sealed.js +564 -0
- package/src/v2/selfcheck.js +564 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +503 -0
- 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
|
+
}
|