@motujs/cli 0.0.0-stage → 0.1.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.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/bundled/github/agents/archipelago-build.agent.md +298 -0
  3. package/bundled/github/agents/island-create.agent.md +261 -0
  4. package/bundled/github/agents/island-extract.agent.md +360 -0
  5. package/bundled/github/agents/island-locate.agent.md +208 -0
  6. package/bundled/github/agents/motu-checks.agent.md +158 -0
  7. package/bundled/github/agents/motu-evidence.agent.md +267 -0
  8. package/bundled/github/agents/motu-regions.agent.md +304 -0
  9. package/bundled/github/host-rules.md +43 -0
  10. package/bundled/github/prompts/archipelago-build.prompt.md +19 -0
  11. package/bundled/github/prompts/island-create.prompt.md +37 -0
  12. package/bundled/github/prompts/island-extract.prompt.md +59 -0
  13. package/bundled/github/prompts/island-locate.prompt.md +43 -0
  14. package/bundled/packages/adapters/angularjs/vite.mjs +43 -0
  15. package/bundled/packages/adapters/next/lagoon/next-stubs.tsx +108 -0
  16. package/bundled/packages/adapters/next/vite.mjs +121 -0
  17. package/bundled/packages/adapters/vite/vite.mjs +179 -0
  18. package/package.json +64 -3
  19. package/src/alias-loader.mjs +63 -0
  20. package/src/alias-register.mjs +28 -0
  21. package/src/asset-stub.cjs +3 -0
  22. package/src/asset-stub.mjs +5 -0
  23. package/src/cli.mjs +78 -0
  24. package/src/commands/adopt-root.mjs +194 -0
  25. package/src/commands/archipelago-init.mjs +314 -0
  26. package/src/commands/archipelago.mjs +138 -0
  27. package/src/commands/assurance.mjs +40 -0
  28. package/src/commands/check.mjs +346 -0
  29. package/src/commands/codegen.mjs +29 -0
  30. package/src/commands/coverage.mjs +358 -0
  31. package/src/commands/create.mjs +277 -0
  32. package/src/commands/defaults.mjs +129 -0
  33. package/src/commands/diff.mjs +595 -0
  34. package/src/commands/fixtures.mjs +153 -0
  35. package/src/commands/init.mjs +473 -0
  36. package/src/commands/integrate.mjs +208 -0
  37. package/src/commands/integration.mjs +1349 -0
  38. package/src/commands/lagoon-try.mjs +272 -0
  39. package/src/commands/lagoon.mjs +1419 -0
  40. package/src/commands/link.mjs +110 -0
  41. package/src/commands/region-coverage.mjs +604 -0
  42. package/src/commands/removal-check.mjs +878 -0
  43. package/src/commands/skills.mjs +156 -0
  44. package/src/commands/snapshot.mjs +456 -0
  45. package/src/commands/sync.mjs +38 -0
  46. package/src/commands/verify/island-runtime.mjs +581 -0
  47. package/src/commands/verify/island-static.mjs +586 -0
  48. package/src/commands/verify/island.mjs +135 -0
  49. package/src/commands/verify/reach.mjs +426 -0
  50. package/src/commands/verify/region-composition.mjs +391 -0
  51. package/src/commands/verify/region-config.mjs +330 -0
  52. package/src/commands/verify/region-flows.mjs +760 -0
  53. package/src/commands/verify/region-sources.mjs +516 -0
  54. package/src/commands/verify/region.mjs +271 -0
  55. package/src/commands/verify/report.mjs +338 -0
  56. package/src/commands/verify/screens-runtime.mjs +123 -0
  57. package/src/commands/verify.mjs +18 -0
  58. package/src/lagoon-build.mjs +25 -0
  59. package/src/lagoon-dev.mjs +32 -0
  60. package/src/lib/alias-core.mjs +49 -0
  61. package/src/lib/archipelagos.mjs +111 -0
  62. package/src/lib/assurance.mjs +144 -0
  63. package/src/lib/baselines.mjs +107 -0
  64. package/src/lib/bundlability.mjs +123 -0
  65. package/src/lib/changed.mjs +157 -0
  66. package/src/lib/component-props.mjs +145 -0
  67. package/src/lib/config.mjs +451 -0
  68. package/src/lib/contracts.mjs +244 -0
  69. package/src/lib/data-flow.mjs +47 -0
  70. package/src/lib/declared-sources.mjs +101 -0
  71. package/src/lib/dock-inject.mjs +47 -0
  72. package/src/lib/effects.mjs +97 -0
  73. package/src/lib/eject.mjs +715 -0
  74. package/src/lib/evidence-json.mjs +74 -0
  75. package/src/lib/expect-calls.mjs +53 -0
  76. package/src/lib/finding-memory.mjs +0 -0
  77. package/src/lib/flow-calls.mjs +53 -0
  78. package/src/lib/framework.mjs +98 -0
  79. package/src/lib/host-rules.mjs +55 -0
  80. package/src/lib/host-sources.mjs +121 -0
  81. package/src/lib/island-placement.mjs +64 -0
  82. package/src/lib/island-render.mjs +105 -0
  83. package/src/lib/islands.mjs +112 -0
  84. package/src/lib/lagoon-bundle.mjs +138 -0
  85. package/src/lib/lagoon-declares.mjs +553 -0
  86. package/src/lib/lagoon-materialize.mjs +217 -0
  87. package/src/lib/lagoon-vite.mjs +788 -0
  88. package/src/lib/live-url.mjs +53 -0
  89. package/src/lib/node-aliases.mjs +73 -0
  90. package/src/lib/operations.mjs +145 -0
  91. package/src/lib/provenance-plugin.mjs +74 -0
  92. package/src/lib/region-refs.mjs +40 -0
  93. package/src/lib/remote.mjs +171 -0
  94. package/src/lib/scaffold.mjs +606 -0
  95. package/src/lib/screens.mjs +266 -0
  96. package/src/lib/snapshots.mjs +85 -0
  97. package/src/lib/stubs.mjs +114 -0
  98. package/src/lib/ts-project.mjs +42 -0
  99. package/src/lib/util.mjs +626 -0
  100. package/src/lib/worlds.mjs +236 -0
  101. package/src/lib/write-if-changed.mjs +35 -0
  102. package/src/lib/writes-coverage.mjs +32 -0
  103. package/src/playwright-lagoon.mjs +2305 -0
  104. package/src/run.mjs +385 -0
  105. package/src/runtime-harness.mjs +225 -0
  106. package/README.md +0 -3
@@ -0,0 +1,604 @@
1
+ // `motu archipelago coverage <id> --corpus <file…>` — what the region DOES, against what it PREVIEWS.
2
+ //
3
+ // Every other check in this CLI compares the region to its declaration. This compares it to reality:
4
+ // a corpus of the states production actually reached, folded from beacons, against the states the
5
+ // region's flows establish. What comes back is a worklist, not a verdict — which is why the default
6
+ // exit is 0 and `--fail-above` is opt-in. An uncovered state is information, and a check that goes
7
+ // red for information is a check people learn to skip.
8
+ //
9
+ // IT DOES NOT WRITE ANYTHING. The scenario skeletons go to stdout for someone to paste and fill in.
10
+ // Written to a file they would be a file full of TODO, which looks like coverage and rots — the same
11
+ // reason `island create` stopped scaffolding `fixtures.mock.ts`.
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import { spawnSync } from 'node:child_process';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { dirname, resolve } from 'node:path';
16
+ import {
17
+ fingerprintRegion,
18
+ fingerprintId,
19
+ compareCoverage,
20
+ mergeCorpora,
21
+ knownIds,
22
+ keysHash,
23
+ } from '@motujs/coverage';
24
+ import { color, paths, REPO_ROOT } from '../lib/util.mjs';
25
+ import { runRegionFlows, closeLagoonPool } from '../playwright-lagoon.mjs';
26
+ import { producedKeys } from '../lib/worlds.mjs';
27
+ import { readRegions } from '../lib/eject.mjs';
28
+
29
+ const HARNESS = resolve(dirname(fileURLToPath(import.meta.url)), '../runtime-harness.mjs');
30
+ const CLI_PKG = resolve(dirname(fileURLToPath(import.meta.url)), '..');
31
+
32
+ /**
33
+ * Every region key the archipelago declares — the fingerprint's columns.
34
+ *
35
+ * The same union `declaredRegionKeys` computes at runtime, from the same four places, read statically
36
+ * instead. The two must agree or a corpus recorded in a browser cannot be compared to anything here,
37
+ * which is exactly the mismatch `mergeCorpora` and `compareCoverage` refuse rather than paper over.
38
+ */
39
+ function declaredKeys(region) {
40
+ const keys = new Set();
41
+ for (const island of region.islands ?? []) {
42
+ for (const key of Object.values(island.bind ?? {})) keys.add(key);
43
+ for (const target of Object.values(island.writes ?? {})) {
44
+ if (typeof target === 'string') keys.add(target);
45
+ else for (const k of Object.values(target ?? {})) keys.add(k);
46
+ }
47
+ for (const key of island.reads ?? []) keys.add(key);
48
+ }
49
+ for (const produced of Object.values(region.sources ?? {})) {
50
+ for (const key of produced.produces ?? []) keys.add(key);
51
+ }
52
+ return [...keys].sort();
53
+ }
54
+
55
+ /** The keys the archipelago declares as closed sets — `coverage: { enums: [...] }`. */
56
+ function declaredEnums(region) {
57
+ let text;
58
+ try {
59
+ text = readFileSync(region.file, 'utf8');
60
+ } catch {
61
+ return [];
62
+ }
63
+ const block = text.match(/\bcoverage:\s*\{[^}]*\benums:\s*\[([^\]]*)\]/);
64
+ return block ? [...block[1].matchAll(/'([^']+)'/g)].map((m) => m[1]) : [];
65
+ }
66
+
67
+ /** A region's declared flows, by the same two routes `archipelago verify` uses. */
68
+ async function readFlows(id) {
69
+ const file = paths.archipelagoEvidence(id);
70
+ if (!existsSync(file)) return null;
71
+ try {
72
+ const mod = await import(`file://${file}?t=${Date.now()}`);
73
+ if (Array.isArray(mod.scenarios)) return mod.scenarios;
74
+ } catch {
75
+ // A plain node import cannot resolve a `.js` specifier pointing at a `.ts` sibling, which is the
76
+ // convention every evidence file uses. Fall through to the tsx loader.
77
+ }
78
+ const res = spawnSync(process.execPath, ['--import', 'tsx', HARNESS, '', file, 'native', 'scenarios'], {
79
+ encoding: 'utf8',
80
+ cwd: CLI_PKG,
81
+ env: { ...process.env, MOTU_PROJECT_ROOT: paths.rel ? undefined : undefined },
82
+ });
83
+ if (res.status !== 0) return null;
84
+ try {
85
+ return JSON.parse((res.stdout || '').trim().split('\n').filter(Boolean).pop()).scenarios ?? null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * The states the flows establish.
93
+ *
94
+ * A seed, plus the seed overlaid with each step's `provide` as they accumulate — because a flow is a
95
+ * sequence and the state after step two is as previewed as the state before step one.
96
+ *
97
+ * WHAT THIS CANNOT SEE, and the report says so: a state reached by `emit`. That goes through an
98
+ * island, so its result is only knowable by running the region — which is the browser lane's job, not
99
+ * this one's. Such a state shows up as uncovered here and is a false positive; the honest fix is for
100
+ * the flow lane to contribute its own fingerprints, and until it does this is a static approximation
101
+ * that errs toward reporting too much rather than too little.
102
+ */
103
+ function coveredStates(flows, keys, enums) {
104
+ const fp = (state) => fingerprintRegion(keys, (k) => state[k], { enums });
105
+ const out = [];
106
+ // One real seed, kept so a skeleton can say `[]` instead of guessing at what `empty` means.
107
+ const sample = { ...(flows[0]?.seed ?? {}) };
108
+ let emits = 0;
109
+ for (const flow of flows) {
110
+ let state = { ...(flow.seed ?? {}) };
111
+ out.push(fp(state));
112
+ for (const step of flow.steps ?? []) {
113
+ if (step.emit) emits++;
114
+ if (!step.provide) continue;
115
+ state = { ...state, ...step.provide };
116
+ out.push(fp(state));
117
+ }
118
+ }
119
+ return { states: out, emits, sample };
120
+ }
121
+
122
+ /**
123
+ * The seed literal a reader would paste, for a state nobody previewed.
124
+ *
125
+ * TYPED FROM THE FLOWS' OWN SEED where it can be. A fingerprint says `empty`, which is true of `[]`,
126
+ * `''` and `{}` alike — but the flows already establish that key with a real value, so its type is
127
+ * known and the skeleton can say `[]` rather than offering three guesses. Only a key no flow
128
+ * establishes at all falls back to a TODO, which is honest: nobody has ever given it a value.
129
+ */
130
+ function skeleton(fingerprint, coveredFp, sampleSeed) {
131
+ const base = coveredFp[0] ?? {};
132
+ const differs = Object.keys(fingerprint).filter((k) => base[k] !== fingerprint[k]);
133
+ const emptyFor = (key) => {
134
+ const seen = sampleSeed?.[key];
135
+ if (Array.isArray(seen)) return '[]';
136
+ if (typeof seen === 'string') return "''";
137
+ if (seen && typeof seen === 'object') return '{}';
138
+ return '[] /* or "" or {} */';
139
+ };
140
+ const literal = (state, key) => {
141
+ if (state === 'absent') return undefined;
142
+ if (state === 'null') return 'null';
143
+ if (state === 'empty') return emptyFor(key);
144
+ if (state === 'true' || state === 'false') return state;
145
+ if (state.startsWith('= ')) return JSON.stringify(state.slice(2));
146
+ return '/* TODO: a value */';
147
+ };
148
+ const body = differs
149
+ .map((k) => [k, literal(fingerprint[k], k)])
150
+ .filter(([, v]) => v !== undefined)
151
+ .map(([k, v]) => `${k}: ${v}`)
152
+ .join(', ');
153
+ return `{ name: 'TODO', seed: { ...SEED${body ? `, ${body}` : ''} } }`;
154
+ }
155
+
156
+ export async function regionCoverageCommand(argv) {
157
+ // --json MUST OWN STDOUT. The prose and the payload describe the same findings, so a caller that
158
+ // parses one while the other is interleaved gets neither — and the first version of this printed
159
+ // the header and every systemic line before the JSON, which is invalid on the first character.
160
+ // Errors stay on console.error: stderr is where they cannot corrupt what is being parsed.
161
+ const say = argv.json ? () => {} : console.log;
162
+ const id = argv._[0];
163
+ if (!id) {
164
+ console.error(color.red('motu archipelago coverage <id> --corpus <file…>'));
165
+ process.exit(2);
166
+ }
167
+ const region = readRegions(paths.archipelagosDir).find((r) => r.id === id);
168
+ if (!region) {
169
+ console.error(color.red(`no archipelago '${id}' under ${paths.rel(paths.archipelagosDir)}`));
170
+ process.exit(2);
171
+ }
172
+
173
+ const keys = declaredKeys(region);
174
+ const enums = declaredEnums(region);
175
+ const flows = await readFlows(id);
176
+
177
+ say(color.bold(`\nmotu archipelago coverage — ${id}\n`));
178
+
179
+ if (!flows?.length) {
180
+ say(
181
+ ` ${color.dim('–')} ${color.dim('coverage'.padEnd(20))} ` +
182
+ color.dim(`no readable flows in ${paths.rel(paths.archipelagoEvidence(id))} — nothing to compare a corpus against`),
183
+ );
184
+ process.exit(2);
185
+ }
186
+ const { states: covered, emits: staticEmits, sample } = coveredStates(flows, keys, enums);
187
+ let emits = staticEmits;
188
+
189
+ // `--runtime`: THE FLOWS' REAL STATES, not their seeds. A region fed by sources (Peps' `actions`:
190
+ // the week, the stats, the check-ins) holds keys no seed mentions, and a step that `emit`s reaches a
191
+ // state only the region can compute — so the static set above reported every production state as
192
+ // uncovered and blamed the seeds ("a missing column") for what the sources had already filled. The
193
+ // flow lane now records each step's region state (`motu diff` reads the same trace), and those
194
+ // states are what the flows actually preview.
195
+ let observed = null;
196
+ if (argv.runtime === true && flows.length) {
197
+ try {
198
+ const run = await runRegionFlows({ id, port: 6200 + Math.floor(Math.random() * 300), scenarios: flows, trace: true });
199
+ const fp = (state) => fingerprintRegion(keys, (k) => state[k], { enums });
200
+ const seen = (run.flows ?? []).filter((st) => st.trace?.keys && Object.keys(st.trace.keys).length);
201
+ for (const st of seen) covered.push(fp(st.trace.keys));
202
+ observed = seen.length;
203
+ // Every step that ran was observed, so a state reached by `emit` is no longer a blind spot.
204
+ if (seen.length) emits = 0;
205
+ } catch (err) {
206
+ say(color.yellow(` ! --runtime: the flows could not be replayed (${String(err?.message ?? err).split('\n')[0]}) — static states only`));
207
+ } finally {
208
+ await closeLagoonPool().catch(() => {});
209
+ }
210
+ }
211
+
212
+ // NO CORPUS IS NOT A PASS. Without one this can still say what the flows cover and print the known
213
+ // set to publish, which is useful on its own — but it has examined no reality, and says so.
214
+ //
215
+ // `corpusUrl` FROM CONFIG IS THE DEFAULT, so the everyday invocation is `motu archipelago coverage
216
+ // <id>` rather than a URL somebody has to remember. It is the one coverage address allowed in
217
+ // committed config, because it is read HERE — on a developer's machine — and never reaches a
218
+ // browser; the beacon's own addresses stay in the page's meta tags for exactly that reason.
219
+ // THE CONFIGURED URL FOLLOWS THE REGION BEING ASKED ABOUT. It is one string for a project with
220
+ // several regions, so a `region=` written into it is right for exactly one of them — `motu region
221
+ // coverage directory` fetched the actions corpus and compared a region to another region's data.
222
+ //
223
+ // The declaration guard caught that (`corpus 7f46c60a vs code 3eda0a71`), which is the keysHash
224
+ // bucketing earning its place: without it the two would have been compared and every state reported
225
+ // uncovered, with nothing to say why.
226
+ const configuredUrl = (raw) => {
227
+ if (!/^https?:\/\//.test(raw)) return raw;
228
+ try {
229
+ const u = new URL(raw);
230
+ // `{region}` for a URL that names it elsewhere — a path segment, say.
231
+ if (u.href.includes('%7Bregion%7D') || u.href.includes('{region}')) {
232
+ return decodeURIComponent(u.href).replaceAll('{region}', id);
233
+ }
234
+ u.searchParams.set('region', id);
235
+ return u.href;
236
+ } catch {
237
+ return raw;
238
+ }
239
+ };
240
+ const configured = paths.coverage?.corpusUrl ? [configuredUrl(paths.coverage.corpusUrl)] : [];
241
+ const asked = [argv.corpus, ...(argv._.slice(1) ?? [])].flat().filter(Boolean);
242
+ const files = asked.length ? asked : configured;
243
+ if (files.length && !asked.length) {
244
+ say(` ${color.dim('·')} ${color.dim('corpus'.padEnd(20))} ${color.dim(`${files[0]} (coverage.corpusUrl)`)}`);
245
+ }
246
+ if (!files.length) {
247
+ say(
248
+ ` ${color.dim('–')} ${color.dim('coverage'.padEnd(20))} ` +
249
+ color.dim('no --corpus given and no coverage.corpusUrl configured: nothing was compared'),
250
+ );
251
+ say(
252
+ color.dim(
253
+ `\n ${covered.length} state(s) previewed by ${flows.length} flow(s) over ${keys.length} declared key(s).` +
254
+ `\n Known set (publish this so clients stay quiet about them):\n`,
255
+ ),
256
+ );
257
+ say(' ' + JSON.stringify(knownIds(covered)));
258
+ process.exit(2);
259
+ }
260
+
261
+ // A TOKEN NEVER COMES FROM CONFIG. Committed config is read by `motu sync` and baked into the
262
+ // generated registry, which the LAGOON imports — so a secret placed there would travel into a
263
+ // published, publicly reachable page. It comes from the environment, is used for this one fetch,
264
+ // and is never written anywhere.
265
+ const token = argv.token ?? process.env.MOTU_COVERAGE_TOKEN ?? null;
266
+
267
+ // A URL IS A FILE HERE. The corpus lives wherever the project put it, and the whole point of the
268
+ // read side being a cacheable blob is that checking against the real thing should not need an
269
+ // export step — `motu archipelago coverage <id> --corpus https://…` is the drift check.
270
+ /**
271
+ * A STATUS PAGE IS ALSO A CORPUS SOURCE, whichever way it arrived.
272
+ *
273
+ * `/coverage/status` answers in a summary shape (`{ top: [{ browsers, state }] }`) because a phone
274
+ * has to render it, and accepting that is what saves somebody standing up a second endpoint to say
275
+ * the same thing.
276
+ *
277
+ * This used to live inside the fetch branch, so the SAME BODY was a corpus from a URL and garbage
278
+ * from a file — and a file is exactly how it arrives when the person reading the status page is on
279
+ * their phone and pastes it. The failure was not even a message about shape: `mergeCorpora` reached
280
+ * `corpus.keys.join(...)` on an object that has no `keys`, and the CLI printed
281
+ * "Cannot read properties of undefined (reading 'join')".
282
+ */
283
+ const asCorpus = (body) => {
284
+ // THE HOST'S OWN READ API, unwrapped. `GET /api/coverage?repo=&region=` answers
285
+ // `{ repo, region, corpus, declarations }` — the corpus in an envelope that names which repo and
286
+ // which region it came from, and how many older declarations are being kept beside it. That
287
+ // envelope is not a corpus, and passing it on reached `corpus.keys.join(...)` on an object with
288
+ // no `keys` — the exact "Cannot read properties of undefined (reading 'join')" this function was
289
+ // already written once to prevent, arriving by the other door.
290
+ //
291
+ // IT IS THE BETTER SOURCE, which is why this is an unwrap and not a rejection: the host holds the
292
+ // WHOLE corpus with real `firstAt`/`lastAt`, where a status page is a top-N summary with the
293
+ // timestamps zeroed. `coverage.corpusUrl` pointed at the host is now the everyday configuration,
294
+ // and an application needs no read route of its own for the CLI's sake.
295
+ if (body?.corpus && Array.isArray(body.corpus.entries)) return body.corpus;
296
+ if (!Array.isArray(body?.top)) return body;
297
+ const entries = body.top.map((t) => ({
298
+ fingerprint: Object.fromEntries(
299
+ String(t.state)
300
+ .split(' ')
301
+ .map((pair) => {
302
+ const i = pair.indexOf(':');
303
+ return [pair.slice(0, i), pair.slice(i + 1)];
304
+ }),
305
+ ),
306
+ // `count` OR `browsers`. The field was renamed when the status page stopped counting distinct
307
+ // browsers — a corpus carries one number, because merging two of them can only add, and the old
308
+ // name claimed a distinction the data no longer makes. Both are accepted so a deployment that
309
+ // is mid-upgrade reports something rather than silently ingesting ones.
310
+ count: t.count ?? t.browsers ?? 1,
311
+ firstAt: 0,
312
+ lastAt: 0,
313
+ }));
314
+ return {
315
+ v: 1,
316
+ // `declaration` IS THE STAMP, `declarations` IS A COUNT. This read `body.declarations?.[0]`,
317
+ // which is `undefined` against every status route that exists — acme's answers a NUMBER there
318
+ // (how many key lists the host is keeping) and puts the hash in `declaration`. So the fallback
319
+ // fired every time and recomputed a hash from the keys of a TRUNCATED top-N, which is only the
320
+ // host's stamp by luck. Prefer what the route actually says.
321
+ keysHash: body.declaration ?? keysHash(Object.keys(entries[0]?.fingerprint ?? {})),
322
+ regionId: body.region ?? id,
323
+ keys: Object.keys(entries[0]?.fingerprint ?? {}).sort(),
324
+ entries,
325
+ };
326
+ };
327
+
328
+ const load = async (f) => {
329
+ // A ROOT-RELATIVE PATH IS A CONFIG MISTAKE, NOT A FILENAME. `corpusUrl` is a browser-shaped
330
+ // string in a file full of browser-shaped strings, so `/api/motu/coverage/status` is the natural
331
+ // thing to write — and it reaches here as a path, misses on disk, and reports
332
+ // "ENOENT: no such file or directory, open '/api/…'", which sends the reader looking for a file
333
+ // they never meant to have. Say what is actually wrong.
334
+ if (f.startsWith('/') && !existsSync(f)) {
335
+ throw new Error(
336
+ `${f} looks like a route, not a file. coverage.corpusUrl is fetched from a developer's ` +
337
+ `machine, so it needs the origin too — e.g. https://your-app${f}`,
338
+ );
339
+ }
340
+ if (!/^https?:\/\//.test(f)) return asCorpus(JSON.parse(readFileSync(f, 'utf8')));
341
+ const res = await fetch(f, token ? { headers: { authorization: `Bearer ${token}` } } : undefined);
342
+ if (res.status === 401 || res.status === 403) {
343
+ throw new Error(
344
+ `${f} answered ${res.status}` +
345
+ (token ? ' with the token given' : ' — set MOTU_COVERAGE_TOKEN, or pass --token'),
346
+ );
347
+ }
348
+ if (!res.ok) throw new Error(`${f} answered ${res.status}`);
349
+ return asCorpus(await res.json());
350
+ };
351
+
352
+ let corpus;
353
+ try {
354
+ corpus = mergeCorpora(await Promise.all(files.map(load)));
355
+ } catch (err) {
356
+ say(` ${color.red('✗')} ${color.dim('corpus'.padEnd(20))} ${color.red(String(err?.message ?? err))}`);
357
+ process.exit(2);
358
+ }
359
+
360
+ // --save: PUT THE DATA WHERE THE LAGOON CAN SHOW IT, and nothing else.
361
+ //
362
+ // A published lagoon is one self-contained HTML file on a host anybody can reach, so it must not
363
+ // carry the address it was fetched from or the credential that opened it. Baking the corpus at
364
+ // BUILD time is what makes that true by construction rather than by redaction: the page holds the
365
+ // rows and has no idea where they came from.
366
+ //
367
+ // Written into the lagoon root so the build globs it, and only ever the corpus — the URL and the
368
+ // token do not appear in the file, which the assertion below enforces rather than assumes.
369
+ if (argv.save) {
370
+ const dir = resolve(paths.lagoonDir, 'src/coverage');
371
+ mkdirSync(dir, { recursive: true });
372
+ const out = resolve(dir, `${id}.json`);
373
+ const body = JSON.stringify(
374
+ { v: corpus.v ?? 1, keysHash: corpus.keysHash, regionId: corpus.regionId, keys: corpus.keys, entries: corpus.entries },
375
+ null,
376
+ 2,
377
+ );
378
+ for (const secret of [token, ...files.filter((f) => /^https?:\/\//.test(f))]) {
379
+ if (secret && body.includes(secret)) {
380
+ console.error(color.red(`refusing to save: the corpus body contains ${secret === token ? 'the token' : 'its source URL'}`));
381
+ process.exit(2);
382
+ }
383
+ }
384
+ writeFileSync(out, body + '\n');
385
+ say(
386
+ ` ${color.green('✓')} ${color.dim('saved'.padEnd(20))} ` +
387
+ color.dim(`${paths.rel(out)} · ${corpus.entries.length} state(s), no URL and no token in it`),
388
+ );
389
+ }
390
+
391
+ // --forget <id…> / --forget-all: remove a recorded state, which is NOT the same act as accepting it.
392
+ //
393
+ // Accepting says "we looked and chose not to preview this". Forgetting says "this was never true" —
394
+ // and the case it exists for is a mistake in the INSTRUMENT, not in the application. acme recorded
395
+ // `isCurrentWeek:true isOtherWeek:true`, where the second is defined as the negation of the first:
396
+ // a state the page cannot compute, recorded because the region was published one key at a time.
397
+ // Fixing the publishing does not un-record it, and a report that keeps offering an impossible state
398
+ // will eventually be answered with a scenario for something that cannot happen.
399
+ const toForget = [argv.forget].flat().filter((v) => typeof v === 'string' && v);
400
+ if (toForget.length || argv['forget-all']) {
401
+ const { loadHostConfig, gitIdentity } = await import('../lib/remote.mjs');
402
+ const cfg = loadHostConfig();
403
+ const base = (process.env.MOTU_HOST_URL || cfg.url || '').replace(/\/+$/, '');
404
+ const hostToken = process.env.MOTU_HOST_TOKEN || cfg.token || null;
405
+ if (!base || !hostToken) {
406
+ say(` ${color.red('✗')} ${color.dim('forget'.padEnd(20))} ${color.red('no lagoon host configured — see ~/.config/motu/host.json')}`);
407
+ process.exit(2);
408
+ }
409
+ const hash = corpus.keysHash ?? keysHash(corpus.keys);
410
+ const repo = paths.publishAs?.repo ?? gitIdentity(REPO_ROOT).repo;
411
+ const qs = `repo=${encodeURIComponent(repo)}&region=${encodeURIComponent(id)}&h=${encodeURIComponent(hash)}`;
412
+ const res = await fetch(`${base}/api/coverage/forget?${qs}`, {
413
+ method: 'POST',
414
+ headers: { authorization: `Bearer ${hostToken}`, 'content-type': 'application/json' },
415
+ body: JSON.stringify(argv['forget-all'] ? [] : toForget),
416
+ }).catch(() => null);
417
+ if (!res?.ok) {
418
+ say(` ${color.red('✗')} ${color.dim('forget'.padEnd(20))} ${color.red(res ? `the host answered ${res.status}` : 'the host is unreachable')}`);
419
+ process.exit(2);
420
+ }
421
+ const body = await res.json();
422
+ say(
423
+ ` ${color.green('✓')} ${color.dim('forget'.padEnd(20))} ` +
424
+ color.dim(`${body.removed} state(s) removed · ${body.remaining} left for declaration ${hash}`),
425
+ );
426
+ }
427
+
428
+ // --accept <id…>: the SECOND of the three answers, and the reason it is its own act.
429
+ //
430
+ // "We looked and chose not to preview this" must not be the same state as "nobody looked" — the
431
+ // same reason accepting a snapshot is a separate command from taking one. It goes to the host under
432
+ // the ADMIN token, never the ingest one: nothing may promote a state to known except a flow or a
433
+ // person, and a reporting credential that could also accept would let the tool mark its own
434
+ // findings resolved.
435
+ const toAccept = [argv.accept].flat().filter((v) => typeof v === 'string' && v);
436
+ if (toAccept.length) {
437
+ const { loadHostConfig, gitIdentity } = await import('../lib/remote.mjs');
438
+ const cfg = loadHostConfig();
439
+ const base = (process.env.MOTU_HOST_URL || cfg.url || '').replace(/\/+$/, '');
440
+ const hostToken = process.env.MOTU_HOST_TOKEN || cfg.token || null;
441
+ const repo = paths.publishAs?.repo ?? null;
442
+ if (!base || !hostToken) {
443
+ say(` ${color.red('✗')} ${color.dim('accept'.padEnd(20))} ${color.red('no lagoon host configured — see ~/.config/motu/host.json')}`);
444
+ process.exit(2);
445
+ }
446
+ const hash = corpus.keysHash ?? keysHash(corpus.keys);
447
+ const qs = `repo=${encodeURIComponent(repo ?? gitIdentity(REPO_ROOT).repo)}&region=${encodeURIComponent(id)}&h=${encodeURIComponent(hash)}`;
448
+ const res = await fetch(`${base}/api/coverage/accept?${qs}`, {
449
+ method: 'POST',
450
+ headers: { authorization: `Bearer ${hostToken}`, 'content-type': 'application/json' },
451
+ body: JSON.stringify(toAccept),
452
+ }).catch(() => null);
453
+ if (!res?.ok) {
454
+ say(
455
+ ` ${color.red('✗')} ${color.dim('accept'.padEnd(20))} ${color.red(res ? `the host answered ${res.status}` : 'the host is unreachable')}`,
456
+ );
457
+ process.exit(2);
458
+ }
459
+ const body = await res.json();
460
+ say(
461
+ ` ${color.green('✓')} ${color.dim('accept'.padEnd(20))} ` +
462
+ color.dim(`${toAccept.length} state(s) accepted · ${body.accepted} in the set for declaration ${hash}`),
463
+ );
464
+ }
465
+
466
+ const report = compareCoverage(corpus, covered, keys);
467
+ const total = corpus.entries.reduce((n, e) => n + e.count, 0);
468
+
469
+ if (report.keysDiffer) {
470
+ const { onlyRecorded, onlyDeclared } = report.keysDiffer;
471
+ say(
472
+ ` ${color.red('✗')} ${color.dim('declaration'.padEnd(20))} ` +
473
+ color.red(
474
+ `corpus ${corpus.keysHash ?? keysHash(corpus.keys)} vs code ${keysHash(keys)} — ` +
475
+ `${onlyRecorded.length ? `recorded only: ${onlyRecorded.join(', ')}. ` : ''}` +
476
+ `${onlyDeclared.length ? `declared only: ${onlyDeclared.join(', ')}. ` : ''}` +
477
+ `The same state fingerprints differently on each side, so nothing below is comparable`,
478
+ ),
479
+ );
480
+ process.exit(1);
481
+ }
482
+
483
+ // WHO PRODUCES THE KEY decides the advice. A key a SOURCE produces cannot be seeded (seeding pins it
484
+ // and the source can never move it), so "widen the seeds" was the wrong advice for it: the state is
485
+ // reached by re-answering the source in a WORLD (docs/proposals/worlds.md). A key the region owns
486
+ // keeps the old advice.
487
+ const producer = new Map(Object.entries(producedKeys(paths.archipelagoFile(id))).flatMap(([src, ks]) => ks.map((k) => [k, src])));
488
+ for (const s of report.systemic) {
489
+ const src = producer.get(s.key);
490
+ say(
491
+ ` ${color.yellow('!')} ${color.dim('systemic'.padEnd(20))} ` +
492
+ `${color.bold(s.key)}: production is ${color.bold(`[${s.recorded}]`)} and the flows only ever show ` +
493
+ `${color.bold(`[${s.scenarios.join(', ') || 'nothing'}]`)} — ` +
494
+ color.dim(
495
+ src
496
+ ? `produced by source \`${src}\`: no flow runs in a world where it answers that. Declare one (\`regions: { ${id}: { channels: { ${src}: [...] } } }\`) and a flow that names it`
497
+ : 'not a missing scenario, a missing column. Widen the flow seeds',
498
+ ),
499
+ );
500
+ }
501
+
502
+ const threshold = argv['fail-above'] != null ? Number(argv['fail-above']) : null;
503
+
504
+ // --json: THE SAME FINDINGS, for something that is not a person.
505
+ //
506
+ // The prose above is written to be read once and acted on; an agent needs the fingerprint, the
507
+ // share and the SKELETON as data, because the skeleton is the actionable half — it is the scenario
508
+ // stub, already typed from the flows' own seed. Printed before the human report rather than
509
+ // alongside it, so the two cannot disagree about what was found and stdout stays parseable.
510
+ if (argv.json) {
511
+ const over = threshold != null ? report.uncovered.filter((u) => u.share * 100 >= threshold) : [];
512
+ console.log(
513
+ JSON.stringify(
514
+ {
515
+ region: id,
516
+ declaration: corpus.keysHash ?? keysHash(corpus.keys),
517
+ keys: corpus.keys,
518
+ recorded: { states: corpus.entries.length, occurrences: total },
519
+ covered: report.covered,
520
+ // A key where the two sides are DISJOINT: not a missing scenario but a missing column, and
521
+ // the thing to fix before writing any of the scenarios below.
522
+ systemic: report.systemic.map((sys) => ({ ...sys, producedBy: producer.get(sys.key) ?? null })),
523
+ uncovered: report.uncovered.map((u) => ({
524
+ id: u.id,
525
+ share: u.share,
526
+ count: corpus.entries.find((e) => fingerprintId(e.fingerprint) === u.id)?.count ?? null,
527
+ differsBy: u.diff,
528
+ fingerprint: u.fingerprint,
529
+ scenario: skeleton(u.fingerprint, covered, sample),
530
+ })),
531
+ unreachable: report.unreachable,
532
+ keysDiffer: report.keysDiffer,
533
+ // Said in the payload, not only in the prose: a state reached through `emit` is reported as
534
+ // uncovered even though a flow does exercise it, because only a browser knows the result.
535
+ caveats: { emitOnlySteps: emits, unreachableIsWeak: true },
536
+ observedSteps: observed,
537
+ failAbove: threshold,
538
+ pass: over.length === 0,
539
+ },
540
+ null,
541
+ 2,
542
+ ),
543
+ );
544
+ process.exit(over.length ? 1 : 0);
545
+ }
546
+
547
+ if (!report.uncovered.length) {
548
+ say(
549
+ ` ${color.green('✓')} ${color.dim('coverage'.padEnd(20))} ` +
550
+ color.dim(`every recorded state is previewed · ${corpus.entries.length} state(s), ${total} occurrence(s)`),
551
+ );
552
+ } else {
553
+ say(
554
+ color.dim(
555
+ ` ${report.uncovered.length} of ${corpus.entries.length} recorded state(s) are previewed by no flow, ` +
556
+ `most-seen first:\n`,
557
+ ),
558
+ );
559
+ for (const u of report.uncovered) {
560
+ say(` ${color.yellow((u.share * 100).toFixed(1).padStart(5) + '%')} ${u.diff}`);
561
+ say(color.dim(` ${skeleton(u.fingerprint, covered, sample)}`));
562
+ // The id, so the third answer to an uncovered state is reachable without deriving it by hand.
563
+ if (argv.ids) say(color.dim(` ${u.id}`));
564
+ }
565
+ say(
566
+ color.dim(
567
+ `\n Three answers, not one: write a scenario, ACCEPT it (\`--accept <id>\`, and \`--ids\` prints them),` +
568
+ `\n or fix the application — an error state at 3% is a 3% error rate, not a missing preview.`,
569
+ ),
570
+ );
571
+ }
572
+
573
+ if (observed !== null) {
574
+ say(color.dim(`\n ${observed} flow step(s) observed at runtime — their real region states count as previewed.`));
575
+ }
576
+ if (emits) {
577
+ say(
578
+ color.dim(
579
+ `\n ${emits} flow step(s) act through \`emit\`, whose result only a browser can know — a state reached` +
580
+ `\n that way is reported above as uncovered even though a flow does exercise it. \`--runtime\` observes them.`,
581
+ ),
582
+ );
583
+ }
584
+ if (report.unreachable.length) {
585
+ say(
586
+ color.dim(
587
+ `\n ${report.unreachable.length} previewed state(s) never recorded. Rare, seasonal or aspirational states` +
588
+ `\n look like this, so it is worth reading rather than acting on.`,
589
+ ),
590
+ );
591
+ }
592
+
593
+
594
+ const over = threshold != null ? report.uncovered.filter((u) => u.share * 100 >= threshold) : [];
595
+ if (over.length) {
596
+ say(`\n${color.red(color.bold('FAIL'))}${color.dim(` ${over.length} uncovered state(s) at or above ${threshold}%`)}`);
597
+ process.exit(1);
598
+ }
599
+ say(
600
+ `\n${color.green(color.bold('PASS'))}` +
601
+ color.dim(` ${report.covered} covered, ${report.uncovered.length} to triage${threshold == null ? ' (advisory — pass --fail-above to gate)' : ''}`),
602
+ );
603
+ process.exit(0);
604
+ }