staysfixed 0.3.0 → 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
- package/src/watch/panel.js +73 -44
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journeys read straight out of the code — the cheapest source, and the exact one.
|
|
3
|
+
*
|
|
4
|
+
* `adapters/source.js` already reads every door a project opens without running any of it:
|
|
5
|
+
* IPC channels, HTTP routes, exported names, commands, settings. This file turns that list
|
|
6
|
+
* of doors into JOURNEYS — named sequences that go and knock on them. Nobody writes
|
|
7
|
+
* anything, nobody records anything, and it costs about a second on a large project.
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS IS THE BEST SOURCE. A recorded session tells you about one path a person
|
|
10
|
+
* happened to take. The suite tells you about the paths somebody thought to write a test
|
|
11
|
+
* for. The code tells you about every door there is, including the ones nobody has opened
|
|
12
|
+
* since they were written — which is exactly where a silent break hides.
|
|
13
|
+
*
|
|
14
|
+
* WHY DOORS ARE GROUPED RATHER THAN ONE JOURNEY EACH. Terminal Deck has 5,785 doors. A
|
|
15
|
+
* journey is the unit of retry, of the wobble measurement and of the stored record, so
|
|
16
|
+
* 5,785 of them would mean 5,785 folders and four walks each, and one door falling over
|
|
17
|
+
* would lose nothing but would still cost a whole run. Doors are gathered into families
|
|
18
|
+
* that share a prefix — everything on `session:`, everything under `/api/users` — so a
|
|
19
|
+
* large project gets tens of journeys covering thousands of doors, and a difference still
|
|
20
|
+
* lands on the individual door because the PATH names the door, not the journey.
|
|
21
|
+
*
|
|
22
|
+
* WHAT THIS CANNOT DO. It knocks on doors; it does not know what is behind them. A route
|
|
23
|
+
* gets a request with no body, an IPC channel gets a call with no arguments, an exported
|
|
24
|
+
* function is looked at rather than called. Calling something with invented arguments is
|
|
25
|
+
* how a tool invents a failure that is really its own fault — so this file never does it,
|
|
26
|
+
* and the suite source exists for the cases where real arguments matter.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import {
|
|
31
|
+
readContract,
|
|
32
|
+
readFileRoutes,
|
|
33
|
+
readPackageCommands,
|
|
34
|
+
surfaceOf,
|
|
35
|
+
} from '../adapters/source.js';
|
|
36
|
+
|
|
37
|
+
/** @typedef {import('../types.js').Journey} Journey */
|
|
38
|
+
/** @typedef {import('../types.js').JourneyStep} JourneyStep */
|
|
39
|
+
/** @typedef {import('../types.js').Channel} Channel */
|
|
40
|
+
/** @typedef {import('../types.js').Surface} Surface */
|
|
41
|
+
/** @typedef {import('../adapters/source.js').Door} Door */
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// What must never be knocked on for real
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Words in a door's name that mean opening it for real would be irreversible.
|
|
49
|
+
*
|
|
50
|
+
* This is a NAME-BASED guess and it is deliberately generous: a journey wrongly marked
|
|
51
|
+
* irreversible is observed at the call and not at the effect, which costs a little
|
|
52
|
+
* coverage; a journey wrongly marked safe sends somebody a real email. When those are the
|
|
53
|
+
* two mistakes available, you make the first one.
|
|
54
|
+
*
|
|
55
|
+
* The adapter is what actually stops the effect happening. This flag is how it is told to.
|
|
56
|
+
*/
|
|
57
|
+
export const IRREVERSIBLE_WORDS = Object.freeze([
|
|
58
|
+
{ word: 'pay', why: 'it sounds like it moves money' },
|
|
59
|
+
{ word: 'payment', why: 'it sounds like it moves money' },
|
|
60
|
+
{ word: 'charge', why: 'it sounds like it moves money' },
|
|
61
|
+
{ word: 'checkout', why: 'it sounds like it moves money' },
|
|
62
|
+
{ word: 'refund', why: 'it sounds like it moves money' },
|
|
63
|
+
{ word: 'invoice', why: 'it sounds like it moves money' },
|
|
64
|
+
{ word: 'subscribe', why: 'it sounds like it starts a paid subscription' },
|
|
65
|
+
{ word: 'billing', why: 'it sounds like it moves money' },
|
|
66
|
+
{ word: 'send', why: 'it sounds like it sends a message somebody receives' },
|
|
67
|
+
{ word: 'email', why: 'it sounds like it sends a message somebody receives' },
|
|
68
|
+
{ word: 'sms', why: 'it sounds like it sends a message somebody receives' },
|
|
69
|
+
{ word: 'notify', why: 'it sounds like it sends a message somebody receives' },
|
|
70
|
+
{ word: 'publish', why: 'it sounds like it makes something public' },
|
|
71
|
+
{ word: 'deploy', why: 'it sounds like it changes something that is live' },
|
|
72
|
+
{ word: 'release', why: 'it sounds like it changes something that is live' },
|
|
73
|
+
{ word: 'delete', why: 'it sounds like it destroys data' },
|
|
74
|
+
{ word: 'destroy', why: 'it sounds like it destroys data' },
|
|
75
|
+
{ word: 'remove', why: 'it sounds like it destroys data' },
|
|
76
|
+
{ word: 'drop', why: 'it sounds like it destroys data' },
|
|
77
|
+
{ word: 'purge', why: 'it sounds like it destroys data' },
|
|
78
|
+
{ word: 'wipe', why: 'it sounds like it destroys data' },
|
|
79
|
+
{ word: 'reset', why: 'it sounds like it throws away what is there' },
|
|
80
|
+
{ word: 'migrate', why: 'it sounds like it rewrites stored data in place' },
|
|
81
|
+
{ word: 'uninstall', why: 'it sounds like it takes something away that has to be put back' },
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
/** Verbs on a route that change something by definition, whatever the route is called. */
|
|
85
|
+
const CHANGING_METHODS = new Set(['DELETE']);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Package scripts that never finish on their own. A journey that never ends is not a
|
|
89
|
+
* journey, it is a hang, and a hang looks exactly like a broken product.
|
|
90
|
+
*/
|
|
91
|
+
const NEVER_EXITS = /^(dev|start|serve|watch|preview|storybook|tunnel)(:|$)/;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Package scripts that are somebody else's job. Building and releasing are not journeys
|
|
95
|
+
* through a product; they are how the product gets made, and running them here would
|
|
96
|
+
* rebuild the thing being measured underneath the measurement. The test scripts are left
|
|
97
|
+
* out for a different reason: the suite is a far better journey source than a command that
|
|
98
|
+
* runs all of it at once, and `from-suite.js` is where it is read properly.
|
|
99
|
+
*/
|
|
100
|
+
const NOT_A_JOURNEY = /^(build|dist|pack|release|version|preversion|postversion|prepare|prepublish|prepublishOnly|postinstall|art|test|check|lint|format|typecheck|coverage)(:|$)/;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Is this `command` door something that can actually be run?
|
|
104
|
+
*
|
|
105
|
+
* The code reader files three different things under `command`: programs a package
|
|
106
|
+
* installs, scripts in package.json, and every flag a source file mentions. The first two
|
|
107
|
+
* can be walked through. A flag cannot — it modifies a command rather than being one — and
|
|
108
|
+
* turning each into its own journey buries the real commands under a hundred of them.
|
|
109
|
+
*
|
|
110
|
+
* @param {Door} door
|
|
111
|
+
* @returns {boolean}
|
|
112
|
+
*/
|
|
113
|
+
export function isRunnable(door) {
|
|
114
|
+
if (door.kind !== 'command') return false;
|
|
115
|
+
if (String(door.name).startsWith('-')) return false;
|
|
116
|
+
return door.via === 'package.json';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Split a name into the words a person would read in it: `session:createMany` becomes
|
|
121
|
+
* session, create, many. Word-splitting rather than substring matching is what stops
|
|
122
|
+
* `undeleteAll` reading as `delete` and `resend` reading as `send`.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} name
|
|
125
|
+
* @returns {string[]}
|
|
126
|
+
*/
|
|
127
|
+
export function wordsIn(name) {
|
|
128
|
+
return String(name)
|
|
129
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
130
|
+
.split(/[^A-Za-z0-9]+/)
|
|
131
|
+
.filter(Boolean)
|
|
132
|
+
.map((w) => w.toLowerCase());
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Kinds of door where walking through actually does something. An exported name is looked
|
|
137
|
+
* at, never called, so a constant called `AGENTS_REMOVE_CHANNEL` is not dangerous to read —
|
|
138
|
+
* and marking it dangerous would bury the handful of doors that really are.
|
|
139
|
+
*/
|
|
140
|
+
const CAN_HAVE_AN_EFFECT = new Set(['ipc', 'route', 'command']);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Would opening this door for real be something that cannot be undone?
|
|
144
|
+
*
|
|
145
|
+
* @param {Door} door
|
|
146
|
+
* @returns {{irreversible: boolean, why: string}}
|
|
147
|
+
*/
|
|
148
|
+
export function irreversibility(door) {
|
|
149
|
+
if (!CAN_HAVE_AN_EFFECT.has(door.kind)) return { irreversible: false, why: '' };
|
|
150
|
+
const words = new Set(wordsIn(door.name));
|
|
151
|
+
for (const entry of IRREVERSIBLE_WORDS) {
|
|
152
|
+
if (words.has(entry.word)) {
|
|
153
|
+
return { irreversible: true, why: `The name contains "${entry.word}", so ${entry.why}.` };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (door.kind === 'route' && CHANGING_METHODS.has(String(door.detail).toUpperCase())) {
|
|
157
|
+
return { irreversible: true, why: 'It is a DELETE route, so asking for it properly would remove something.' };
|
|
158
|
+
}
|
|
159
|
+
return { irreversible: false, why: '' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Names
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Turn anything into a name that is safe as a folder and readable in a report.
|
|
168
|
+
*
|
|
169
|
+
* Escaping rather than dropping matters here for the same reason it does in the path
|
|
170
|
+
* grammar: `v1.2` and `v12` are different things, and a scheme that strips would merge
|
|
171
|
+
* two journeys into one address and then report the difference between them as a change.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} text
|
|
174
|
+
* @param {number} [limit]
|
|
175
|
+
* @returns {string}
|
|
176
|
+
*/
|
|
177
|
+
export function slug(text, limit = 60) {
|
|
178
|
+
const cleaned = String(text)
|
|
179
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
180
|
+
.toLowerCase()
|
|
181
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
182
|
+
.replace(/^-+|-+$/g, '');
|
|
183
|
+
const out = cleaned === '' ? 'unnamed' : cleaned;
|
|
184
|
+
return out.length <= limit ? out : `${out.slice(0, limit - 7)}-${shortHash(out)}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* A short, stable fingerprint. Only ever used to keep two long names apart, never as an
|
|
189
|
+
* identity anything is compared on.
|
|
190
|
+
* @param {string} text
|
|
191
|
+
* @returns {string}
|
|
192
|
+
*/
|
|
193
|
+
export function shortHash(text) {
|
|
194
|
+
let h = 0x811c9dc5;
|
|
195
|
+
for (let i = 0; i < text.length; i++) {
|
|
196
|
+
h ^= text.charCodeAt(i);
|
|
197
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
198
|
+
}
|
|
199
|
+
return h.toString(36).padStart(6, '0').slice(0, 6);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The family a door belongs to.
|
|
204
|
+
*
|
|
205
|
+
* IPC channels are named `thing:action` almost everywhere, and routes are a folder tree, so
|
|
206
|
+
* both have a natural first level. Exported names group by the file they live in, because
|
|
207
|
+
* that is what a person means by "the store module". Commands stand alone: each one is a
|
|
208
|
+
* separate program run and grouping them would mean one failing command hid the next.
|
|
209
|
+
*
|
|
210
|
+
* @param {Door} door
|
|
211
|
+
* @returns {{group: string, label: string}}
|
|
212
|
+
*/
|
|
213
|
+
export function familyOf(door) {
|
|
214
|
+
switch (door.kind) {
|
|
215
|
+
case 'ipc': {
|
|
216
|
+
const head = String(door.name).split(/[:/.]/)[0] || 'other';
|
|
217
|
+
return { group: `ipc-${slug(head)}`, label: `IPC channels starting with "${head}"` };
|
|
218
|
+
}
|
|
219
|
+
case 'route': {
|
|
220
|
+
const segments = String(door.name).split('/').filter(Boolean);
|
|
221
|
+
const head = segments[0] ?? 'root';
|
|
222
|
+
const second = segments[0] === 'api' && segments[1] ? `api/${segments[1]}` : head;
|
|
223
|
+
return { group: `route-${slug(second)}`, label: `routes under /${second}` };
|
|
224
|
+
}
|
|
225
|
+
case 'export': {
|
|
226
|
+
const file = String(door.file).split(path.sep).join('/');
|
|
227
|
+
const folder = file.includes('/') ? file.slice(0, file.lastIndexOf('/')) : '.';
|
|
228
|
+
return {
|
|
229
|
+
group: `export-${slug(folder === '.' ? 'top' : folder)}`,
|
|
230
|
+
label: folder === '.' ? 'what the top-level files export' : `what the files in ${folder} export`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
case 'command':
|
|
234
|
+
return { group: `cli-${slug(door.name)}`, label: `the command "${door.name}"` };
|
|
235
|
+
default:
|
|
236
|
+
return { group: 'settings', label: 'the settings it reads' };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Doors to steps
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
/** What an adapter is being asked to do at each kind of door. */
|
|
245
|
+
const ACT_FOR_KIND = /** @type {const} */ ({
|
|
246
|
+
ipc: 'invoke',
|
|
247
|
+
route: 'request',
|
|
248
|
+
export: 'inspect',
|
|
249
|
+
command: 'run',
|
|
250
|
+
env: 'read',
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
/** The channels a walk through each kind of door can honestly fill. */
|
|
254
|
+
const CHANNELS_FOR_KIND = /** @type {Record<string, Channel[]>} */ ({
|
|
255
|
+
ipc: ['results', 'complaints', 'effects', 'counters'],
|
|
256
|
+
route: ['results', 'complaints', 'effects', 'counters'],
|
|
257
|
+
export: ['contract', 'results'],
|
|
258
|
+
command: ['results', 'complaints', 'effects', 'counters'],
|
|
259
|
+
env: ['contract'],
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* One door, as a step an adapter can act on.
|
|
264
|
+
*
|
|
265
|
+
* Everything the adapter needs is on the step, and nothing it does not: the door's file and
|
|
266
|
+
* line ride along because ranking measures distance from the changed code, and a step that
|
|
267
|
+
* knows which file it came from turns "something broke" into "something broke next to what
|
|
268
|
+
* you just edited".
|
|
269
|
+
*
|
|
270
|
+
* @param {Door} door
|
|
271
|
+
* @returns {JourneyStep}
|
|
272
|
+
*/
|
|
273
|
+
export function stepForDoor(door) {
|
|
274
|
+
const risk = irreversibility(door);
|
|
275
|
+
/** @type {JourneyStep} */
|
|
276
|
+
const step = {
|
|
277
|
+
act: ACT_FOR_KIND[door.kind] ?? 'read',
|
|
278
|
+
kind: door.kind,
|
|
279
|
+
door: door.name,
|
|
280
|
+
detail: door.detail,
|
|
281
|
+
file: door.file,
|
|
282
|
+
line: door.line,
|
|
283
|
+
};
|
|
284
|
+
if (door.kind === 'route') {
|
|
285
|
+
step.method = String(door.detail).toUpperCase();
|
|
286
|
+
step.route = door.name;
|
|
287
|
+
}
|
|
288
|
+
if (door.kind === 'command') step.command = door.name;
|
|
289
|
+
if (risk.irreversible) {
|
|
290
|
+
step.irreversible = true;
|
|
291
|
+
step.why = risk.why;
|
|
292
|
+
step.note = 'Watch the call go out and stop it there. Never let the effect happen.';
|
|
293
|
+
}
|
|
294
|
+
return step;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Doors to journeys
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* @typedef {object} FromRoutesOptions
|
|
303
|
+
* @property {Surface} [surface] What these journeys run against. Read off the project
|
|
304
|
+
* when it is not given.
|
|
305
|
+
* @property {number} [maxSteps] Doors per journey. See the note at the top of the file
|
|
306
|
+
* for why this is not simply "all of them".
|
|
307
|
+
* @property {boolean} [includeTests] Include doors a test file registers. Off, because a
|
|
308
|
+
* fake registration in a test is not a door the product
|
|
309
|
+
* answers on.
|
|
310
|
+
* @property {('ipc'|'route'|'export'|'command'|'env')[]} [kinds] Only these kinds.
|
|
311
|
+
* @property {(door: Door) => boolean} [where] A last filter, for a caller with its own idea.
|
|
312
|
+
*/
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* @typedef {object} FromRoutesReport
|
|
316
|
+
* @property {number} doors Doors the code reader found.
|
|
317
|
+
* @property {number} doorsCovered Doors a journey now knocks on.
|
|
318
|
+
* @property {number} journeys
|
|
319
|
+
* @property {Record<string, number>} byKind Doors covered, per kind.
|
|
320
|
+
* @property {{what: string, why: string, doors: number}[]} left
|
|
321
|
+
* Doors deliberately not turned into journeys, and why.
|
|
322
|
+
* This is the coverage hole, said out loud rather than
|
|
323
|
+
* left to be discovered.
|
|
324
|
+
*/
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Turn a list of doors into journeys.
|
|
328
|
+
*
|
|
329
|
+
* Pure: hand it doors, get journeys. Everything that touches a disk lives in
|
|
330
|
+
* {@link journeysFromCode}, so this half can be tested with a list written by hand.
|
|
331
|
+
*
|
|
332
|
+
* @param {Door[]} doors
|
|
333
|
+
* @param {FromRoutesOptions} [options]
|
|
334
|
+
* @returns {{journeys: Journey[], report: FromRoutesReport}}
|
|
335
|
+
*/
|
|
336
|
+
export function journeysFromDoors(doors, options = {}) {
|
|
337
|
+
const maxSteps = options.maxSteps ?? 40;
|
|
338
|
+
const surface = options.surface ?? 'library';
|
|
339
|
+
/** @type {FromRoutesReport} */
|
|
340
|
+
const report = { doors: doors.length, doorsCovered: 0, journeys: 0, byKind: {}, left: [] };
|
|
341
|
+
|
|
342
|
+
/** @type {Map<string, {reason: string, count: number}>} */
|
|
343
|
+
const left = new Map();
|
|
344
|
+
/** @param {string} reason */
|
|
345
|
+
const leaveOut = (reason) => {
|
|
346
|
+
const entry = left.get(reason) ?? { reason, count: 0 };
|
|
347
|
+
entry.count++;
|
|
348
|
+
left.set(reason, entry);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/** @type {Map<string, {label: string, kind: Door['kind'], doors: Door[]}>} */
|
|
352
|
+
const families = new Map();
|
|
353
|
+
|
|
354
|
+
for (const door of doors) {
|
|
355
|
+
if (!options.includeTests && door.inTest) { leaveOut('they are registered inside a test file, not by the product'); continue; }
|
|
356
|
+
if (!door.named) { leaveOut('their names are built while the program runs, so there is nothing to knock on'); continue; }
|
|
357
|
+
if (options.kinds && !options.kinds.includes(door.kind)) { leaveOut('their kind was not asked for'); continue; }
|
|
358
|
+
if (door.kind === 'env') { leaveOut('a setting is read, not opened — the contract channel already watches them'); continue; }
|
|
359
|
+
if (door.kind === 'command' && !isRunnable(door)) {
|
|
360
|
+
leaveOut('they are flags rather than programs — a flag modifies a command, it is not something a journey can walk through on its own, and the contract channel already watches every one of them');
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (door.kind === 'command' && NEVER_EXITS.test(door.name.replace(/^npm run /, ''))) {
|
|
364
|
+
leaveOut('they never exit on their own, so a walk through one would hang rather than finish');
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (door.kind === 'command' && NOT_A_JOURNEY.test(door.name.replace(/^npm run /, ''))) {
|
|
368
|
+
leaveOut('they build or release the product rather than use it, and running one would rebuild the thing being measured');
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (options.where && !options.where(door)) { leaveOut('a filter the caller supplied left them out'); continue; }
|
|
372
|
+
|
|
373
|
+
const family = familyOf(door);
|
|
374
|
+
const existing = families.get(family.group);
|
|
375
|
+
if (existing) existing.doors.push(door);
|
|
376
|
+
else families.set(family.group, { label: family.label, kind: door.kind, doors: [door] });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** @type {Journey[]} */
|
|
380
|
+
const journeys = [];
|
|
381
|
+
for (const [group, family] of [...families.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
382
|
+
const ordered = family.doors
|
|
383
|
+
.slice()
|
|
384
|
+
.sort((a, b) => (a.name === b.name ? compare(`${a.file}:${a.line}`, `${b.file}:${b.line}`) : compare(a.name, b.name)));
|
|
385
|
+
const chunks = chunk(ordered, maxSteps);
|
|
386
|
+
chunks.forEach((part, index) => {
|
|
387
|
+
const name = chunks.length === 1 ? `code-${group}` : `code-${group}-${index + 1}`;
|
|
388
|
+
const steps = part.map(stepForDoor);
|
|
389
|
+
const files = unique(part.map((d) => d.file));
|
|
390
|
+
/** @type {Journey} */
|
|
391
|
+
const journey = {
|
|
392
|
+
name,
|
|
393
|
+
describe:
|
|
394
|
+
chunks.length === 1
|
|
395
|
+
? `knock on the ${part.length} ${part.length === 1 ? 'door' : 'doors'} in ${family.label}`
|
|
396
|
+
: `knock on ${family.label}, part ${index + 1} of ${chunks.length} (${part.length} doors)`,
|
|
397
|
+
source: 'code',
|
|
398
|
+
surface,
|
|
399
|
+
from: files.length === 1 ? files[0] : `${files.length} files, starting with ${files[0]}`,
|
|
400
|
+
channels: CHANNELS_FOR_KIND[family.kind] ?? ['results'],
|
|
401
|
+
steps,
|
|
402
|
+
};
|
|
403
|
+
if (steps.some((s) => s.irreversible === true)) {
|
|
404
|
+
journey.irreversible = true;
|
|
405
|
+
}
|
|
406
|
+
journeys.push(journey);
|
|
407
|
+
report.doorsCovered += part.length;
|
|
408
|
+
report.byKind[family.kind] = (report.byKind[family.kind] ?? 0) + part.length;
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
report.journeys = journeys.length;
|
|
413
|
+
report.left = [...left.values()]
|
|
414
|
+
.sort((a, b) => b.count - a.count)
|
|
415
|
+
.map((entry) => ({
|
|
416
|
+
what: `${entry.count} ${entry.count === 1 ? 'door is' : 'doors are'} not walked.`,
|
|
417
|
+
why: `They were left out because ${entry.reason}.`,
|
|
418
|
+
doors: entry.count,
|
|
419
|
+
}));
|
|
420
|
+
return { journeys, report };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Read a project's code and hand back journeys that visit its doors.
|
|
425
|
+
*
|
|
426
|
+
* Reads. Never runs, never writes, never starts anything — which is why this is safe to
|
|
427
|
+
* point at a repository somebody else is working in.
|
|
428
|
+
*
|
|
429
|
+
* @param {object} opts
|
|
430
|
+
* @param {string} opts.root Project root. Read only.
|
|
431
|
+
* @param {string[]} [opts.folders] Folders to read. Defaults to the usual ones.
|
|
432
|
+
* @param {Record<string, any>} [opts.config] The project's config, for the surface.
|
|
433
|
+
* @param {FromRoutesOptions} [opts.journeys]
|
|
434
|
+
* @returns {Promise<{journeys: Journey[], doors: Door[], report: FromRoutesReport & {readMs: number, filesRead: number, unnamed: number}}>}
|
|
435
|
+
*/
|
|
436
|
+
export async function journeysFromCode(opts) {
|
|
437
|
+
const started = Date.now();
|
|
438
|
+
const reading = await readContract({ root: opts.root, folders: opts.folders });
|
|
439
|
+
reading.doors.push(...(await readFileRoutes(opts.root)));
|
|
440
|
+
reading.doors.push(...(await readPackageCommands(opts.root)));
|
|
441
|
+
|
|
442
|
+
const surface =
|
|
443
|
+
opts.journeys?.surface ?? /** @type {Surface} */ (surfaceOf({ root: opts.root, config: opts.config }));
|
|
444
|
+
const built = journeysFromDoors(reading.doors, { ...opts.journeys, surface });
|
|
445
|
+
|
|
446
|
+
if (reading.report.unnamed > 0) {
|
|
447
|
+
built.report.left.push({
|
|
448
|
+
what: `${reading.report.unnamed} doors exist whose names are worked out while the program runs.`,
|
|
449
|
+
why: 'The code reader can see that a door is there but not what it is called, so nothing can knock on it.',
|
|
450
|
+
doors: reading.report.unnamed,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
journeys: built.journeys,
|
|
456
|
+
doors: reading.doors,
|
|
457
|
+
report: {
|
|
458
|
+
...built.report,
|
|
459
|
+
readMs: Date.now() - started,
|
|
460
|
+
filesRead: reading.report.filesRead,
|
|
461
|
+
unnamed: reading.report.unnamed,
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
// Small things
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* @template T
|
|
472
|
+
* @param {T[]} items
|
|
473
|
+
* @param {number} size
|
|
474
|
+
* @returns {T[][]}
|
|
475
|
+
*/
|
|
476
|
+
function chunk(items, size) {
|
|
477
|
+
if (items.length <= size) return items.length === 0 ? [] : [items];
|
|
478
|
+
/** @type {T[][]} */
|
|
479
|
+
const out = [];
|
|
480
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
481
|
+
return out;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* @param {string[]} values
|
|
486
|
+
* @returns {string[]}
|
|
487
|
+
*/
|
|
488
|
+
function unique(values) {
|
|
489
|
+
return [...new Set(values.filter(Boolean))];
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* @param {string} a
|
|
494
|
+
* @param {string} b
|
|
495
|
+
*/
|
|
496
|
+
function compare(a, b) {
|
|
497
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
498
|
+
}
|