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.
- package/CHANGELOG.md +32 -2
- package/docs/guards.md +18 -0
- package/docs/how-v2-works.md +10 -0
- package/package.json +1 -1
- package/src/cli/approve.js +4 -1
- package/src/cli/flake.js +4 -1
- package/src/cli/mark.js +5 -1
- package/src/cli/status.js +53 -1
- package/src/cli/trace.js +27 -2
- package/src/core/config.js +136 -25
- package/src/core/stop-tree.js +109 -0
- package/src/drive/browser.js +20 -31
- package/src/drive/page.js +74 -2
- package/src/guard/api.js +14 -9
- package/src/types.js +1 -1
- package/src/v2/adapters/child.js +15 -17
- package/src/v2/adapters/contract.js +122 -1
- package/src/v2/adapters/http.js +152 -30
- package/src/v2/adapters/isolate.js +169 -14
- package/src/v2/adapters/process.js +72 -8
- package/src/v2/adapters/source.js +254 -7
- package/src/v2/adapters/web.js +69 -19
- package/src/v2/browsers.js +136 -24
- package/src/v2/cause.js +46 -5
- package/src/v2/check.js +332 -34
- package/src/v2/cli.js +19 -1
- package/src/v2/coverage.js +555 -18
- package/src/v2/detect.js +737 -40
- package/src/v2/doctor.js +3 -3
- package/src/v2/escalate.js +57 -11
- package/src/v2/init.js +562 -21
- package/src/v2/journeys/answers-probe.js +376 -0
- package/src/v2/journeys/from-exports.js +456 -0
- package/src/v2/journeys/from-suite.js +9 -1
- package/src/v2/mcp/tools.js +185 -12
- package/src/v2/observation.js +145 -0
- package/src/v2/run.js +133 -9
- package/src/v2/selfcheck.js +297 -11
- package/src/v2/store.js +16 -1
|
@@ -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
|
-
|
|
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
|
}
|