staysfixed 0.12.0 → 0.13.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.
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Call what a module exports, with fixed inputs, and print the answers.
3
+ *
4
+ * WHY THIS FILE EXISTS. Until 2026-08-31 a library was checked by importing it and writing
5
+ * down the NAMES and SHAPES of what it exported — "slug: a function taking 1 argument". That
6
+ * is a real thing to compare and it is not the thing that breaks. Measured that day on a
7
+ * four-line library: the separator inside `slug` was changed from "-" to "_", so every web
8
+ * address the library produces became a different string, and `isReserved('admin')` went from
9
+ * true to false. Both exports still existed, both still took one argument, so every address
10
+ * agreed and `staysfixed check` answered "Nothing that worked has changed" and exited 0. That
11
+ * is the one answer this tool may never give. Nothing here calls anything, so nothing here
12
+ * could ever have seen it.
13
+ *
14
+ * This program is what closes that hole. It imports the module the same way the shape probe
15
+ * does, calls every exported function it is willing to call with a fixed ladder of inputs,
16
+ * and prints one line per call. The lines are compared like any other output, so a function
17
+ * that starts answering differently changes the text and the check fails with the old answer
18
+ * and the new one side by side.
19
+ *
20
+ * WHY IT IS A FILE AND NOT A STRING. The shape probe is built as one long JavaScript string
21
+ * and handed to `node -e`, wrapped in single quotes. That works on this machine and it makes
22
+ * the code unreadable, and single quotes are not how Windows quotes anything. A file is run
23
+ * by path, reads the same as the rest of the codebase, and can be tested on its own.
24
+ *
25
+ * WHAT KEEPS THIS SAFE. Four things, and none of them is optional:
26
+ *
27
+ * 1. It only ever runs inside the scratch copy the process adapter makes, never in
28
+ * anybody's working folder, with a stopped clock, a scratch HOME and a scratch temp
29
+ * folder, and with every outbound connection recorded and then refused at the wire.
30
+ * That boundary is not this file's doing and this file must never be run outside it.
31
+ * 2. It refuses by name. A function whose name says it deletes, sends, publishes, charges
32
+ * or migrates is NOT called — it is named in the output as not called, with the reason,
33
+ * so the hole is visible instead of silent. This is the same generous name-based guess
34
+ * the tool already makes about routes and commands, made in the same words.
35
+ * 3. It never constructs a class and never reads a property that is really a getter,
36
+ * because both of those run somebody else's code without the name of the thing being
37
+ * called ever appearing at the call site.
38
+ * 4. It stops. Every call has a deadline and the whole run has a deadline, and whatever it
39
+ * did not reach is printed as not reached rather than left out.
40
+ *
41
+ * WHAT IT STILL CANNOT SEE, said here because a reader of the output deserves to know: a
42
+ * function that only misbehaves on an input this ladder does not contain, and a function
43
+ * this program refused to call. Both are named in the output.
44
+ *
45
+ * Usage: node answers-probe.js <module id> (with the working directory inside the copy)
46
+ */
47
+
48
+ import { existsSync } from 'node:fs';
49
+ import { fileURLToPath, pathToFileURL } from 'node:url';
50
+
51
+ import {
52
+ ANSWERS_END,
53
+ ANSWERS_START,
54
+ CALLED_PREFIX,
55
+ MAX_ARGS,
56
+ MAX_FUNCTIONS,
57
+ NOT_CALLED_PREFIX,
58
+ PER_CALL_MS,
59
+ PROBE_INPUTS,
60
+ WHOLE_RUN_MS,
61
+ describeInput,
62
+ whyItWouldNotBeCalled,
63
+ } from './from-exports.js';
64
+
65
+ /**
66
+ * Where the module really is.
67
+ *
68
+ * The rule is copied deliberately from the shape probe rather than reinvented: "starts with a
69
+ * dot, or has a slash in it" was the old test, and `index.js` has neither, so Node was asked
70
+ * for a PACKAGE by that name and answered that it did not exist. `staysfixed init` writes
71
+ * exactly `{ module: "index.js" }` for an ordinary package entry, so getting this wrong makes
72
+ * the probe fail identically on both builds — which produces no difference at all and reads
73
+ * exactly like a clean check.
74
+ *
75
+ * @param {string} id
76
+ * @returns {string}
77
+ */
78
+ function resolveModule(id) {
79
+ const asFile = new URL(id, pathToFileURL(`${process.cwd()}/`)).href;
80
+ let onDisk = false;
81
+ try {
82
+ onDisk = existsSync(fileURLToPath(asFile));
83
+ } catch {
84
+ onDisk = false;
85
+ }
86
+ const looksLikeAPath = id.startsWith('.') || id.startsWith('/') || id.includes('/');
87
+ return looksLikeAPath || onDisk ? asFile : id;
88
+ }
89
+
90
+ /**
91
+ * Is this function really a class?
92
+ *
93
+ * Constructing a stranger's class is not something this program is willing to do — a
94
+ * constructor can open a file, start a server or connect to a database, and unlike a plain
95
+ * call there is no useful answer to compare afterwards. Calling one without `new` throws, and
96
+ * a page of identical TypeErrors would drown the answers that matter.
97
+ *
98
+ * @param {Function} fn
99
+ * @returns {boolean}
100
+ */
101
+ function isAClass(fn) {
102
+ try {
103
+ return /^\s*class[\s{]/.test(Function.prototype.toString.call(fn));
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * One value, written the same way every time.
111
+ *
112
+ * Two runs of identical code have to produce identical text here or the whole feature becomes
113
+ * a flake generator, so: object keys are sorted, lists and depth are capped, and anything
114
+ * this function does not recognise is described rather than printed. Nothing reads the clock
115
+ * and nothing reads a random number.
116
+ *
117
+ * @param {unknown} value
118
+ * @param {number} [depth]
119
+ * @param {Set<unknown>} [seen]
120
+ * @returns {string}
121
+ */
122
+ export function write(value, depth = 0, seen = new Set()) {
123
+ if (value === null) return 'null';
124
+ if (value === undefined) return 'undefined';
125
+ const type = typeof value;
126
+ if (type === 'string') return JSON.stringify(value);
127
+ if (type === 'number') return Object.is(value, -0) ? '-0' : String(value);
128
+ if (type === 'boolean') return String(value);
129
+ if (type === 'bigint') return `${value}n`;
130
+ if (type === 'symbol') return String(value);
131
+ if (type === 'function') {
132
+ const fn = /** @type {Function} */ (value);
133
+ const named = fn.name ? ` called ${fn.name}` : '';
134
+ return `a function${named} taking ${fn.length} ${fn.length === 1 ? 'argument' : 'arguments'}`;
135
+ }
136
+ if (value instanceof Error) return `${value.name}: ${cleanMessage(value.message)}`;
137
+ if (value instanceof Date) {
138
+ // The clock is stopped inside the boundary this runs in, so a date is reproducible. It is
139
+ // written in full rather than bucketed because a library that formats dates is exactly the
140
+ // kind of library whose output must be compared to the character.
141
+ return `Date(${Number.isNaN(value.getTime()) ? 'invalid' : value.toISOString()})`;
142
+ }
143
+ if (value instanceof RegExp) return String(value);
144
+ if (seen.has(value)) return '<the same object again>';
145
+ if (depth >= 3) return Array.isArray(value) ? `a list of ${value.length}` : 'an object, not opened this far down';
146
+ seen.add(value);
147
+ try {
148
+ if (Array.isArray(value)) {
149
+ const shown = value.slice(0, 20).map((item) => write(item, depth + 1, seen));
150
+ return `[${shown.join(', ')}${value.length > 20 ? `, and ${value.length - 20} more` : ''}]`;
151
+ }
152
+ if (value instanceof Set) {
153
+ const items = [...value].slice(0, 20).map((item) => write(item, depth + 1, seen)).sort();
154
+ return `Set{${items.join(', ')}${value.size > 20 ? `, and ${value.size - 20} more` : ''}}`;
155
+ }
156
+ if (value instanceof Map) {
157
+ const items = [...value.entries()]
158
+ .slice(0, 20)
159
+ .map(([k, v]) => `${write(k, depth + 1, seen)}: ${write(v, depth + 1, seen)}`)
160
+ .sort();
161
+ return `Map{${items.join(', ')}${value.size > 20 ? `, and ${value.size - 20} more` : ''}}`;
162
+ }
163
+ // Own keys only, sorted, and read through a descriptor so a getter is never fired. A
164
+ // getter is somebody else's code running without its name appearing at any call site,
165
+ // which is the one thing this program refuses to do by accident.
166
+ const keys = Object.keys(/** @type {object} */ (value)).sort().slice(0, 30);
167
+ const parts = keys.map((key) => {
168
+ const d = Object.getOwnPropertyDescriptor(/** @type {object} */ (value), key);
169
+ if (d && !('value' in d)) return `${key}: a computed property, not read`;
170
+ return `${key}: ${write(d?.value, depth + 1, seen)}`;
171
+ });
172
+ const total = Object.keys(/** @type {object} */ (value)).length;
173
+ return `{${parts.join(', ')}${total > 30 ? `, and ${total - 30} more` : ''}}`;
174
+ } finally {
175
+ seen.delete(value);
176
+ }
177
+ }
178
+
179
+ /**
180
+ * An error message with this machine's own paths taken out of it.
181
+ *
182
+ * A stack trace or a message carrying the scratch folder's absolute path differs between two
183
+ * builds for a reason that has nothing to do with the product — the two builds are walked in
184
+ * two different scratch folders — and that difference would arrive as a finding nobody caused.
185
+ * The tool strips its own footprint out of stdout further downstream as well; this is the
186
+ * cheap half done at the source.
187
+ *
188
+ * @param {string} text
189
+ * @returns {string}
190
+ */
191
+ function cleanMessage(text) {
192
+ return String(text ?? '')
193
+ .split('\n')[0]
194
+ .split(process.cwd()).join('.')
195
+ .slice(0, 300);
196
+ }
197
+
198
+ /**
199
+ * Call one function once and say what came back, in one line, whatever happened.
200
+ *
201
+ * A throw is an answer and is written down as one. "Still throws the same TypeError" and
202
+ * "used to throw and now returns a number" are both facts worth comparing, and a probe that
203
+ * only recorded successful calls would report the second as nothing at all.
204
+ *
205
+ * @param {Function} fn
206
+ * @param {unknown[]} args
207
+ * @returns {Promise<string>}
208
+ */
209
+ async function callOnce(fn, args) {
210
+ let answer;
211
+ try {
212
+ answer = fn(...args);
213
+ } catch (e) {
214
+ return `threw ${write(e)}`;
215
+ }
216
+ if (answer === null || typeof answer !== 'object' || typeof (/** @type {any} */ (answer).then) !== 'function') {
217
+ return write(answer);
218
+ }
219
+ // A promise is awaited, because the answer of an async function IS the thing worth
220
+ // comparing and "a promise" is the same nine characters whatever the library does. It is
221
+ // awaited under a deadline: an async function that never settles would otherwise hold the
222
+ // whole run until the journey's own timeout killed it, and a killed journey reports nothing
223
+ // about any of the functions that came after it.
224
+ let timer;
225
+ const deadline = new Promise((resolve) => {
226
+ timer = setTimeout(() => resolve('__staysfixed_no_answer__'), PER_CALL_MS);
227
+ // Unreferenced so it can never be the reason this program stays alive.
228
+ if (typeof timer?.unref === 'function') timer.unref();
229
+ });
230
+ try {
231
+ const settled = await Promise.race([
232
+ Promise.resolve(answer).then((v) => ({ ok: true, v }), (e) => ({ ok: false, v: e })),
233
+ deadline,
234
+ ]);
235
+ if (settled === '__staysfixed_no_answer__') {
236
+ return `had still not answered after ${PER_CALL_MS}ms, so nothing about this call is compared`;
237
+ }
238
+ const s = /** @type {{ok: boolean, v: unknown}} */ (settled);
239
+ return s.ok ? `a promise for ${write(s.v)}` : `a promise that failed with ${write(s.v)}`;
240
+ } finally {
241
+ clearTimeout(timer);
242
+ }
243
+ }
244
+
245
+ /**
246
+ * The fixed ladder of arguments one function is called with.
247
+ *
248
+ * FIXED is the whole point. Random or generated inputs would make two runs of the same build
249
+ * disagree with each other, and this tool throws away anything that cannot answer the same
250
+ * way twice — so a fuzzed probe would produce a great deal of noise and nothing that could
251
+ * ever be compared. These are ordinary values a library actually receives, plus the empty and
252
+ * the wrong ones, because "used to throw on null and now returns undefined" is a real change.
253
+ *
254
+ * A function of more than one argument gets the same value in each slot, capped at three.
255
+ * Every combination of the ladder across three slots is a thousand calls per function, which
256
+ * is a cost nobody agreed to for an answer nobody reads.
257
+ *
258
+ * @param {Function} fn
259
+ * @returns {{args: unknown[], shown: string}[]}
260
+ */
261
+ export function ladderFor(fn) {
262
+ const arity = Math.min(Math.max(Number(fn.length) || 0, 0), MAX_ARGS);
263
+ /** @type {{args: unknown[], shown: string}[]} */
264
+ const out = [{ args: [], shown: '' }];
265
+ if (arity === 0) return out;
266
+ for (const input of PROBE_INPUTS) {
267
+ const args = Array.from({ length: arity }, () => input.value);
268
+ out.push({ args, shown: args.map(() => describeInput(input)).join(', ') });
269
+ }
270
+ return out;
271
+ }
272
+
273
+ async function main() {
274
+ const id = process.argv[2];
275
+ if (!id) {
276
+ process.stderr.write('This probe needs the module to import as its one argument.\n');
277
+ process.exitCode = 2;
278
+ return;
279
+ }
280
+
281
+ /** @type {Record<string, unknown>} */
282
+ let module;
283
+ try {
284
+ module = await import(resolveModule(id));
285
+ } catch (e) {
286
+ // Said on the complaints channel and NOT on stdout, so the answers block is never half
287
+ // written. The run around this reads an empty stdout plus a non-zero exit as "this never
288
+ // reached the product", which is exactly what happened, and records it as a hole.
289
+ process.stderr.write(`Could not import ${id}: ${cleanMessage(/** @type {Error} */ (e)?.message ?? String(e))}\n`);
290
+ process.exitCode = 1;
291
+ return;
292
+ }
293
+
294
+ const write_ = (/** @type {string} */ line) => process.stdout.write(`${line}\n`);
295
+ write_(ANSWERS_START);
296
+ write_(`module: ${id}`);
297
+
298
+ const names = Object.keys(module).sort();
299
+ /** @type {string[]} */
300
+ const called = [];
301
+ /** @type {string[]} */
302
+ const refused = [];
303
+ /** @type {string[]} */
304
+ const notReached = [];
305
+ /** @type {string[]} */
306
+ const values = [];
307
+
308
+ const startedAt = Date.now();
309
+ let functions = 0;
310
+
311
+ for (const name of names) {
312
+ let value;
313
+ try {
314
+ value = module[name];
315
+ } catch (e) {
316
+ refused.push(`${name} (crashed) — reading it threw ${write(e)}, so it was never called.`);
317
+ continue;
318
+ }
319
+ if (typeof value !== 'function') {
320
+ // A VALUE IS AN ANSWER TOO, and until 2026-08-31 nothing compared one. The shape probe
321
+ // records an exported string as the words "some text" and an exported number as
322
+ // "number", so `export const API_URL = 'https://api.example.com'` could be repointed at
323
+ // a staging server, or a list of reserved words emptied, and every address agreed. It is
324
+ // the same false all-clear as the one this file exists to stop, wearing a constant
325
+ // instead of a function. The value is written out here in full, deterministically.
326
+ values.push(`${name} = ${write(value)}`);
327
+ continue;
328
+ }
329
+ const fn = /** @type {Function} */ (value);
330
+
331
+ if (isAClass(fn)) {
332
+ refused.push(`${name} (not supported here) — it is a class, and this tool never builds one: a constructor can do anything and gives no answer to compare.`);
333
+ continue;
334
+ }
335
+ const why = whyItWouldNotBeCalled(name);
336
+ if (why) {
337
+ refused.push(`${name} (irreversible) — ${why}`);
338
+ continue;
339
+ }
340
+ if (functions >= MAX_FUNCTIONS) {
341
+ notReached.push(name);
342
+ continue;
343
+ }
344
+ if (Date.now() - startedAt > WHOLE_RUN_MS) {
345
+ notReached.push(name);
346
+ continue;
347
+ }
348
+
349
+ functions++;
350
+ called.push(name);
351
+ for (const rung of ladderFor(fn)) {
352
+ const answer = await callOnce(fn, rung.args);
353
+ write_(`${name}(${rung.shown}) -> ${answer}`);
354
+ }
355
+ }
356
+
357
+ for (const line of values) write_(line);
358
+ write_(ANSWERS_END);
359
+ write_(`${CALLED_PREFIX}${called.length === 0 ? '(none)' : called.join(', ')}`);
360
+ // ONE LINE PER NAME, never a list on one line, and each carrying the KIND of hole it is in
361
+ // brackets. Whatever reads this back turns each of these into its own recorded hole at that
362
+ // function's own address — and the kind decides which sentence the owner is shown, because
363
+ // "doing this for real would destroy data" and "this tool cannot observe a class" are two
364
+ // different facts and reporting both as "the project asked us not to" is a third thing that
365
+ // is true of neither. A line naming four functions at once would arrive as one hole with a
366
+ // made-up name and three functions that silently read as fine.
367
+ for (const line of refused) write_(`${NOT_CALLED_PREFIX}${line}`);
368
+ for (const name of notReached) {
369
+ write_(
370
+ `${NOT_CALLED_PREFIX}${name} (timed out) — the probe had already called ${MAX_FUNCTIONS} functions or spent ` +
371
+ `${Math.round(WHOLE_RUN_MS / 1000)} seconds calling, so this one was never reached and nothing here says anything about it.`,
372
+ );
373
+ }
374
+ }
375
+
376
+ await main();