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,456 @@
1
+ /**
2
+ * Journeys that CALL what a library exports, instead of only looking at it.
3
+ *
4
+ * THE HOLE THIS FILLS, measured 2026-08-31 on a four-line library. A project whose whole
5
+ * product is `export function slug(text)` and `export function isReserved(word)` was set up,
6
+ * checked and shipped with this tool. Then the separator inside `slug` was changed from "-"
7
+ * to "_" and one word was dropped from the reserved list — so every web address the library
8
+ * produces became a different string, and `isReserved('admin')` went from true to false. A
9
+ * check answered:
10
+ *
11
+ * ok Nothing that worked has changed. 13 addresses checked against the stored record.
12
+ *
13
+ * and exited 0. Nothing was broken about that run: it compared what it had, and what it had
14
+ * was the NAMES and the SHAPES of the exports — "slug: a function taking 1 argument" — which
15
+ * had not moved by one character. No channel anywhere in the tool had ever called the
16
+ * function. On a library, the names and the shapes are the packaging; the answers are the
17
+ * product. Comparing only the packaging and then saying "nothing that worked has changed" is
18
+ * a false all-clear, which is the one answer this tool may never give.
19
+ *
20
+ * WHAT THIS FILE DOES. For every module the settings already name under `process.imports`, it
21
+ * adds one more journey beside the existing shape journey: run `answers-probe.js` against
22
+ * that module inside the same scratch copy, with the same stopped clock and the same watcher
23
+ * refusing every outbound connection, and print one line per call. Those lines are ordinary
24
+ * output and are compared like any other output, so an answer that changes fails the check
25
+ * with the old value and the new one side by side.
26
+ *
27
+ * WHY ONE JOURNEY PER MODULE AND NOT ONE PER FUNCTION. One per function would give every
28
+ * exported name its own address, which reads better in a report — and it costs one process
29
+ * and two folder snapshots per function per build per run. Measured on this machine on
30
+ * 2026-08-31: twelve extra journeys took a check from 1.4 to 8.2 seconds, about 570ms each,
31
+ * on a project where the journeys did nothing at all. A library of forty exports would pay
32
+ * half a minute on every check for a nicer heading. One journey per module costs one process,
33
+ * and the printed block names the function and the input on every single line, so a
34
+ * difference still says exactly which function changed and what it now answers.
35
+ *
36
+ * WHAT IS NEVER CALLED, and it is named in the output rather than skipped quietly: anything
37
+ * whose name says it deletes, sends, publishes, charges or migrates; anything that is really
38
+ * a class; and anything the probe ran out of time or budget to reach. See
39
+ * `whyItWouldNotBeCalled` for the rule and `answers-probe.js` for the rest of the boundary.
40
+ */
41
+
42
+ import { fileURLToPath } from 'node:url';
43
+
44
+ import { joinPath, notCovered, observation } from '../adapters/contract.js';
45
+ import { IRREVERSIBLE_WORDS, wordsIn } from './from-routes.js';
46
+
47
+ /** @typedef {import('../types.js').Journey} Journey */
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // The contract between this file and the probe it runs
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * How the answers are told apart from anything the module printed on its way in.
55
+ *
56
+ * The shape probe learned this the hard way: without a marker, one line printed at import
57
+ * time — a dotenv banner, a deprecation warning, anything — was enough to make the whole
58
+ * reading unusable. The same trap is here, so the same fence is.
59
+ */
60
+ export const ANSWERS_START = '<<< staysfixed: what it answers >>>';
61
+
62
+ /** Where the answers stop and the accounting starts. */
63
+ export const ANSWERS_END = '<<< staysfixed: end of answers >>>';
64
+
65
+ /** The line naming every function that really was called. Read back by the coverage ledger. */
66
+ export const CALLED_PREFIX = 'called: ';
67
+
68
+ /** One line per function that was NOT called, and why. */
69
+ export const NOT_CALLED_PREFIX = 'not called: ';
70
+
71
+ /**
72
+ * The kinds of hole a not-called line may claim, in brackets after the name.
73
+ *
74
+ * Checked against a list rather than trusted, because the value goes straight into the
75
+ * sentence the owner reads and an unrecognised one would print as a reason nobody wrote.
76
+ * Anything not in here falls back to a plain refusal, which is the weakest true statement.
77
+ */
78
+ const REFUSAL_KINDS = new Set(['irreversible', 'not supported here', 'crashed', 'timed out', 'refused']);
79
+
80
+ /**
81
+ * How many arguments a function is given at most.
82
+ *
83
+ * The ladder below has a dozen rungs. Every combination of it across three slots is over a
84
+ * thousand calls for one function, which is a cost nobody agreed to for an answer nobody
85
+ * reads, so each rung fills every slot with the same value instead.
86
+ */
87
+ export const MAX_ARGS = 3;
88
+
89
+ /**
90
+ * How many functions one module's probe will call.
91
+ *
92
+ * A cap that bites is printed as a hole with the names in it, so a big library is told what
93
+ * was not looked at rather than being quietly half-checked.
94
+ */
95
+ export const MAX_FUNCTIONS = 40;
96
+
97
+ /** How long one call may take before its answer is abandoned. */
98
+ export const PER_CALL_MS = 1000;
99
+
100
+ /** How long the whole probe may spend calling before it stops and names what it did not reach. */
101
+ export const WHOLE_RUN_MS = 20_000;
102
+
103
+ /**
104
+ * The fixed inputs every function is called with.
105
+ *
106
+ * FIXED, and never generated. This tool throws away any address that cannot answer the same
107
+ * way twice on one build, so a probe that fuzzed its inputs would produce a great deal of
108
+ * noise and nothing that could ever be compared. These are ordinary values a library actually
109
+ * receives, plus the empty and the wrong ones — "used to throw on null, now returns
110
+ * undefined" is a real change and a ladder of only sensible inputs would never see it.
111
+ *
112
+ * The strings are chosen against what libraries actually do to strings: case, surrounding
113
+ * space, a word products reserve, and one containing every separator a slug function might
114
+ * join with — which is the exact shape of the change that produced the false all-clear this
115
+ * file exists to stop.
116
+ */
117
+ export const PROBE_INPUTS = Object.freeze([
118
+ { shown: '""', value: '' },
119
+ { shown: '"Hello World"', value: 'Hello World' },
120
+ { shown: '" Admin Panel "', value: ' Admin Panel ' },
121
+ { shown: '"admin"', value: 'admin' },
122
+ { shown: '"a-b_c.d e"', value: 'a-b_c.d e' },
123
+ { shown: '0', value: 0 },
124
+ { shown: '1', value: 1 },
125
+ { shown: '-1', value: -1 },
126
+ { shown: 'true', value: true },
127
+ { shown: 'null', value: null },
128
+ { shown: 'undefined', value: undefined },
129
+ { shown: '[]', value: [] },
130
+ ]);
131
+
132
+ /**
133
+ * How one input is written in the output line.
134
+ * @param {{shown: string}} input
135
+ * @returns {string}
136
+ */
137
+ export function describeInput(input) {
138
+ return input.shown;
139
+ }
140
+
141
+ /**
142
+ * Why this exported function will not be called, or empty when it will be.
143
+ *
144
+ * THE SAME GENEROUS GUESS THE TOOL ALREADY MAKES, in the same words. A route or a command
145
+ * whose name contains "delete", "publish" or "charge" is never opened for real; an exported
146
+ * function of that name is somebody's code that does the same thing, and calling it because
147
+ * it happened to be exported would be this tool causing the exact kind of damage it exists to
148
+ * catch. Refusing one that was actually harmless costs a little coverage and says so out
149
+ * loud. Calling one that was not sends a real email. Those are the two mistakes available and
150
+ * the first one is the one to make.
151
+ *
152
+ * This is deliberately NOT wired into `irreversibility()` in from-routes.js, and that is a
153
+ * decision rather than an oversight. That function answers "should the ledger treat this door
154
+ * as unwalkable", and it excludes exports on purpose: most exports are constants, and a
155
+ * constant called `AGENTS_REMOVE_CHANNEL` is not dangerous to read. The rule here fires only
156
+ * where the export turns out at run time to be a FUNCTION, which is the only case where the
157
+ * word in the name is about to become an action.
158
+ *
159
+ * @param {string} name
160
+ * @returns {string} Empty when it is safe to call.
161
+ */
162
+ export function whyItWouldNotBeCalled(name) {
163
+ const words = new Set(wordsIn(name));
164
+ for (const entry of IRREVERSIBLE_WORDS) {
165
+ if (words.has(entry.word)) {
166
+ return `its name contains "${entry.word}", so ${entry.why}, and this tool never calls a stranger's code that might do that.`;
167
+ }
168
+ }
169
+ return '';
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Building the journeys
174
+ // ---------------------------------------------------------------------------
175
+
176
+ /** Where the probe program lives, as an absolute path on this machine. */
177
+ export function answersProbePath() {
178
+ return fileURLToPath(new URL('./answers-probe.js', import.meta.url));
179
+ }
180
+
181
+ /**
182
+ * Quoted so a path with a space in it is still one argument.
183
+ *
184
+ * Double quotes rather than single ones, because the command is handed to whatever shell the
185
+ * machine has and single quotes mean nothing at all to the Windows one. A path containing a
186
+ * double quote would still break this — and it would break loudly, as a probe that printed
187
+ * nothing and exited non-zero, which the run already reports as a hole rather than a pass.
188
+ *
189
+ * @param {string} text
190
+ * @returns {string}
191
+ */
192
+ function quoted(text) {
193
+ return `"${String(text).split('"').join('')}"`;
194
+ }
195
+
196
+ /**
197
+ * The command line that calls one module's exports and prints the answers.
198
+ *
199
+ * It runs the Node that is running this tool. A bare `node` would take whatever version the
200
+ * scratch shell happens to find, and two builds walked on two different Node versions is a
201
+ * difference nobody caused arriving as a finding.
202
+ *
203
+ * @param {string} moduleId
204
+ * @returns {string}
205
+ */
206
+ export function answersCommand(moduleId) {
207
+ return `${quoted(process.execPath)} ${quoted(answersProbePath())} ${quoted(moduleId)}`;
208
+ }
209
+
210
+ /**
211
+ * The name of the answers journey for one configured import.
212
+ * @param {string} entryName
213
+ * @returns {string}
214
+ */
215
+ export function answersJourneyName(entryName) {
216
+ return `what ${entryName} answers`;
217
+ }
218
+
219
+ /**
220
+ * One answers journey for every module the settings already name.
221
+ *
222
+ * Nothing new has to be configured for this to work, and that is the point: `staysfixed init`
223
+ * already writes `process.imports` for any project that is a library, so every library
224
+ * already set up with this tool gets the answers compared the next time it is checked,
225
+ * without anybody editing a settings file they do not know exists.
226
+ *
227
+ * @param {object} input
228
+ * @param {Record<string, any>} [input.config] The `process` section of the settings.
229
+ * @returns {{journeys: Journey[], gaps: import('../types.js').CoverageGap[]}}
230
+ */
231
+ export function journeysFromExports(input) {
232
+ /** @type {Journey[]} */
233
+ const journeys = [];
234
+ /** @type {import('../types.js').CoverageGap[]} */
235
+ const gaps = [];
236
+
237
+ const imports = Array.isArray(input.config?.imports) ? input.config.imports : [];
238
+ for (const entry of imports) {
239
+ if (!entry || typeof entry !== 'object' || !entry.module) continue;
240
+ const entryName = String(entry.name ?? entry.module);
241
+ const moduleId = String(entry.module);
242
+ journeys.push({
243
+ name: answersJourneyName(entryName),
244
+ describe: `call everything ${moduleId} exports, with fixed inputs, and compare the answers`,
245
+ source: 'code',
246
+ surface: 'library',
247
+ from: 'the project config',
248
+ channels: ['results', 'complaints', 'effects', 'counters'],
249
+ steps: [{ act: 'run', run: answersCommand(moduleId) }],
250
+ // Long enough for the probe's own budget plus the cost of starting Node and importing a
251
+ // module that may itself be slow to load. The probe stops itself first in the ordinary
252
+ // case; this is only the outer stop for a module that hangs on import.
253
+ timeoutMs: WHOLE_RUN_MS + 25_000,
254
+ });
255
+ }
256
+
257
+ if (imports.length > 0) {
258
+ // Said on every run that has one of these journeys, because it is true on every one of
259
+ // them and a reader who is told "the answers are compared" will otherwise assume all of
260
+ // them are.
261
+ gaps.push({
262
+ what: 'The exported functions are called with a fixed list of inputs, and only those.',
263
+ why:
264
+ `Each one is called with no arguments and then with ${PROBE_INPUTS.length} fixed values — the empty string, ` +
265
+ 'some ordinary text, a number, true, null, undefined and an empty list. A function that only answers ' +
266
+ 'differently on an input that is not in that list would answer identically here. Functions whose names say ' +
267
+ 'they delete, send, publish, charge or migrate are never called at all, and neither are classes; each one is ' +
268
+ 'named in the printed answer sheet with the reason.',
269
+ unlockedBy:
270
+ 'Write a journeys file that calls them with the inputs that matter to this product and pass it with --journeys, ' +
271
+ 'or point the check at the tests you already have with --journeys suite.',
272
+ surface: 'library',
273
+ });
274
+ }
275
+
276
+ return { journeys, gaps };
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Reading the answer sheet back
281
+ // ---------------------------------------------------------------------------
282
+
283
+ /**
284
+ * ONE ADDRESS PER CALL, not one address for the whole sheet.
285
+ *
286
+ * The process adapter reports whatever a command printed as a single value at
287
+ * `cli.<journey>.stdout`, and the first working version of this feature left it that way. It
288
+ * caught the regression — and the finding read:
289
+ *
290
+ * "what the package entry answers / stdout" now reads "…eserved(\"admin\") -> false\n…"
291
+ * where it read "…eserved(\"admin\") -> true\n…"
292
+ *
293
+ * which is a true statement nobody outside this codebase can act on. It is a window onto the
294
+ * middle of a wall of text, it names no function, and a library where ten functions changed
295
+ * reads exactly the same as one where a single character did.
296
+ *
297
+ * So the sheet is taken apart here. Every call becomes its own observation at
298
+ * `export.<module>.<function>.<the input it was given>`, and the finding then reads
299
+ * "index.js / slug / (\"Hello World\") now reads \"hello_world\" where it read \"hello-world\"",
300
+ * which is a sentence an owner can act on without knowing anything about this tool.
301
+ *
302
+ * THE ADDRESS IS NOT DECORATION. `export.<file>.<name>` is exactly the address the code
303
+ * reader writes down for that same exported name, so the coverage ledger joins the two on its
304
+ * own and the function counts as genuinely opened — because it genuinely was called. A
305
+ * function the probe refused is written down at that address as a refusal instead, which the
306
+ * ledger deliberately does NOT count as opened, so a refused function stays visible as a hole.
307
+ *
308
+ * The raw sheet is dropped once it has been taken apart, because keeping it would report
309
+ * every change twice: once per call, and again as a wall of text. Anything the module printed
310
+ * on its way in sits in front of the marker and is kept, because a library that starts
311
+ * printing a warning on import has changed and that is worth knowing.
312
+ *
313
+ * @param {import('../adapters/contract.js').Observation[]} observations
314
+ * @param {import('../types.js').Journey} journey
315
+ * @returns {import('../adapters/contract.js').Observation[]}
316
+ */
317
+ export function splitAnswerSheet(observations, journey) {
318
+ const stdoutPath = joinPath('cli', journey.name, 'stdout');
319
+ const sheet = observations.find((o) => o.path === stdoutPath);
320
+ const text = typeof sheet?.value === 'string' ? sheet.value : '';
321
+ const start = text.indexOf(ANSWERS_START);
322
+ const end = text.indexOf(ANSWERS_END);
323
+ // No marker, or no closing marker, means the probe never finished. That is a hole and it is
324
+ // already reported as one by the run around this — the command printed nothing usable and
325
+ // its exit code is compared — so the sheet is left exactly as it is rather than being half
326
+ // read. A half-finished sheet read as a full one is how a function that was never called
327
+ // ends up counted as covered.
328
+ if (!sheet || start === -1 || end === -1 || end < start) return observations;
329
+
330
+ const body = text.slice(start, end).split('\n');
331
+ const after = text.slice(end).split('\n');
332
+ const moduleId = (body.find((l) => l.startsWith('module: ')) ?? '').slice('module: '.length).trim() || journey.name;
333
+
334
+ /** @type {import('../adapters/contract.js').Observation[]} */
335
+ const out = [];
336
+ /** @type {string[]} */
337
+ const unreadable = [];
338
+ const calledNames = new Set();
339
+
340
+ for (const line of body) {
341
+ if (line === '' || line === ANSWERS_START || line.startsWith('module: ')) continue;
342
+ const close = line.indexOf(') -> ');
343
+ const open = line.indexOf('(');
344
+ if (close < 0 || open <= 0 || close < open) {
345
+ // Not a call, so it is an exported VALUE: `NAME = "something"`. Its address is the
346
+ // exported name itself with nothing after it, because there is no input — and that is
347
+ // the same address the code reader writes down, so a constant whose value moved is a
348
+ // finding and the door counts as genuinely opened.
349
+ const equals = line.indexOf(' = ');
350
+ if (equals <= 0) { unreadable.push(line); continue; }
351
+ const constant = line.slice(0, equals);
352
+ const held = line.slice(equals + 3);
353
+ calledNames.add(constant);
354
+ out.push(observation({
355
+ channel: 'results',
356
+ path: ['export', moduleId, constant],
357
+ value: held,
358
+ says: `${moduleId} exports ${constant}, and it holds ${held}. If this changes, everything reading it gets a different value without asking for one.`,
359
+ journey: journey.name,
360
+ }));
361
+ continue;
362
+ }
363
+ const name = line.slice(0, open);
364
+ const given = line.slice(open, close + 1);
365
+ const answer = line.slice(close + ') -> '.length);
366
+ calledNames.add(name);
367
+ out.push(observation({
368
+ channel: 'results',
369
+ // THE LAST SEGMENT CARRIES THE WHOLE CALL, and that is not decoration either. The
370
+ // report writes its headline from the last part of an address, so a segment holding
371
+ // only the input produced 'In what the program gives back, "("admin")" is now "false"
372
+ // where it was "true"' — a sentence with no function in it, on a library that exports
373
+ // six of them. With the name in front it reads 'isReserved("admin") is now false where
374
+ // it was true', which is the whole finding in one line. The name is still its own
375
+ // segment in front, so the coverage ledger goes on matching this to the exported name
376
+ // the code reader found.
377
+ path: ['export', moduleId, name, `${name}${given}`],
378
+ value: answer,
379
+ says: `${moduleId} — calling ${name}${given} answers ${answer}. If this changes, everything that calls it gets a different answer without asking for one.`,
380
+ journey: journey.name,
381
+ }));
382
+ }
383
+
384
+ for (const line of after) {
385
+ if (!line.startsWith(NOT_CALLED_PREFIX)) continue;
386
+ const rest = line.slice(NOT_CALLED_PREFIX.length);
387
+ const dash = rest.indexOf(' — ');
388
+ if (dash <= 0) { unreadable.push(line); continue; }
389
+ const head = rest.slice(0, dash).trim();
390
+ const why = rest.slice(dash + 3).trim();
391
+ // "name (kind)". The kind picks the sentence the owner reads. Reporting a refusal to call
392
+ // `deleteEverything` under the same words as a class this tool cannot construct — "the
393
+ // project asked us not to" — is true of neither and reads as a setting somebody chose.
394
+ const bracket = /^(.*?)\s+\(([^)]+)\)$/.exec(head);
395
+ const name = (bracket ? bracket[1] : head).trim();
396
+ const kind = bracket ? bracket[2].trim() : '';
397
+ const reason = /** @type {import('../adapters/contract.js').NotCoveredReason} */ (
398
+ REFUSAL_KINDS.has(kind) ? kind : 'refused'
399
+ );
400
+ out.push(notCovered({
401
+ channel: 'results',
402
+ path: ['export', moduleId, name],
403
+ reason,
404
+ says: `${moduleId} exports ${name} and nothing called it: ${why} Its name and its shape are compared; what it does is not, so a version of it that behaves differently would look identical here. This is a hole, not a pass.`,
405
+ }));
406
+ }
407
+
408
+ out.push(observation({
409
+ channel: 'counters',
410
+ path: ['count', moduleId, 'functions called'],
411
+ value: calledNames.size,
412
+ says: `${calledNames.size} of the functions ${moduleId} exports were called and had their answers compared.`,
413
+ journey: journey.name,
414
+ }));
415
+
416
+ if (unreadable.length > 0) {
417
+ // A line this file could not read is a call whose answer is not being compared, and the
418
+ // only wrong thing to do with it is nothing. It is counted as a hole with the lines in it,
419
+ // so a change to the probe's own output format shows up as missing coverage rather than as
420
+ // a quietly smaller number of addresses.
421
+ out.push(notCovered({
422
+ channel: 'results',
423
+ path: ['export', moduleId, 'the answer sheet'],
424
+ reason: 'not supported here',
425
+ says:
426
+ `${unreadable.length} ${unreadable.length === 1 ? 'line' : 'lines'} of the answer sheet could not be read back, so ` +
427
+ `${unreadable.length === 1 ? 'that call is' : 'those calls are'} not compared: ${unreadable.slice(0, 3).join(' / ')}` +
428
+ `${unreadable.length > 3 ? `, and ${unreadable.length - 3} more` : ''}. This is a hole, not a pass.`,
429
+ }));
430
+ }
431
+
432
+ // What the module printed on its way IN, which is everything before the marker. Kept at the
433
+ // address it already had, so a library that starts printing a deprecation warning on import
434
+ // is still caught. Empty is fine and is compared as empty.
435
+ const before = text.slice(0, start);
436
+ return [
437
+ ...observations.filter((o) => o !== sheet),
438
+ observation({
439
+ channel: sheet.channel,
440
+ path: stdoutPath,
441
+ value: before,
442
+ says: `What "${journey.describe}" printed before it started calling anything. A library normally prints nothing here.`,
443
+ journey: journey.name,
444
+ }),
445
+ ...out,
446
+ ];
447
+ }
448
+
449
+ /**
450
+ * Is this journey one of the answer sheets this file makes?
451
+ * @param {import('../types.js').Journey} journey
452
+ * @returns {boolean}
453
+ */
454
+ export function isAnAnswerJourney(journey) {
455
+ return journey?.surface === 'library' && typeof journey?.name === 'string' && journey.name.startsWith('what ') && journey.name.endsWith(' answers');
456
+ }
@@ -34,6 +34,7 @@ import fs from 'node:fs';
34
34
  import fsp from 'node:fs/promises';
35
35
  import os from 'node:os';
36
36
  import path from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
37
38
  import { spawn } from 'node:child_process';
38
39
 
39
40
  /** @typedef {import('../types.js').Journey} Journey */
@@ -867,7 +868,14 @@ export function relativeIfInside(url, root) {
867
868
  let absolute = String(url);
868
869
  if (absolute.startsWith('file://')) {
869
870
  try {
870
- absolute = new URL(absolute).pathname;
871
+ // `fileURLToPath`, never `.pathname`. On Windows the pathname of
872
+ // `file:///D:/a/project/total.js` is `/D:/a/project/total.js` — with a leading slash —
873
+ // which is absolute enough to pass the test below and yet matches no root, so
874
+ // `path.relative` answered `..\..` and every touched file was thrown away. The
875
+ // coverage measured from a test suite was therefore empty on Windows, quietly. Found by
876
+ // the automated checks on 2026-09-01, the first time they ran on Windows, on a runner
877
+ // that allows symbolic links where the machine it was developed against does not.
878
+ absolute = fileURLToPath(absolute);
871
879
  } catch {
872
880
  return null;
873
881
  }