@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,760 @@
1
+ // A region's flows: catalogue, render coverage, mutation, what-if reach, live wiring.
2
+ // Split out of commands/verify.mjs; verify.mjs re-exports the public surface.
3
+ import { reportFlowCalls } from '../../lib/flow-calls.mjs';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { existsSync } from 'node:fs';
6
+ import { readRegions } from '../../lib/eject.mjs';
7
+ import { REPO_ROOT, TAG_PREFIX, paths } from '../../lib/util.mjs';
8
+ import { undrivenWrites } from '../../lib/writes-coverage.mjs';
9
+ import { probeWiring, runRegionFlows } from '../../playwright-lagoon.mjs';
10
+ import { CLI_PKG, HARNESS, islandScenarios, readScenarios } from './island-runtime.mjs';
11
+ import { declaredReach, operationReachCheck, operationUniverse, provenanceCheck } from './reach.mjs';
12
+ import { emittedLiveCheck, reportStoreComplaints, sourcesLiveCheck } from './region-sources.mjs';
13
+ import { environmentalCause, isVerbose, step } from './report.mjs';
14
+
15
+ /**
16
+ * A CATALOGUE region's declared members are the members the data actually produces.
17
+ *
18
+ * Static by design and it needs no browser: the two inputs are the app's captured payloads and the
19
+ * enum its own codegen emits. That is the whole reversal — a UI decided by data looked like the case
20
+ * motu could say nothing about, and it is the case where the answer can be READ instead of inferred
21
+ * from JSX. `<id>.evidence.ts` supplies them via `export const capture = { universe, present }`.
22
+ */
23
+ export async function catalogueCheck(report, id, region) {
24
+ const members = region.islands.filter((i) => i.member);
25
+ if (!members.length) {
26
+ report.warn('catalogue', 'region is declared `membership: \'catalogue\'` but no island declares a `member` — nothing ties a declaration to a data row');
27
+ return;
28
+ }
29
+ const file = paths.archipelagoEvidence(id);
30
+ if (!existsSync(file)) {
31
+ report.warn(
32
+ 'catalogue',
33
+ `${members.length} member type(s) declared and no capture to check them against — export \`capture\` ` +
34
+ `from ${paths.rel(file)} pointing at the app's own fixtures, or the declaration is a guess`,
35
+ );
36
+ return;
37
+ }
38
+
39
+ const res = spawnSync(process.execPath, ['--import', 'tsx', HARNESS, '', file, 'native', 'catalogue'], {
40
+ encoding: 'utf8',
41
+ cwd: CLI_PKG,
42
+ env: { ...process.env, MOTU_PROJECT_ROOT: REPO_ROOT, MOTU_CATALOGUE_DECLARED: JSON.stringify(members.map((i) => i.member)) },
43
+ });
44
+ let parsed = {};
45
+ try {
46
+ parsed = JSON.parse((res.stdout || '').trim().split('\n').filter(Boolean).pop());
47
+ } catch {
48
+ parsed = { error: (res.stderr || '').trim().split('\n').pop() || 'harness produced no output' };
49
+ }
50
+ if (parsed.error || !parsed.report) {
51
+ // An evidence file carrying flows but no `capture` is the NORMAL state, not a broken read: the two
52
+ // answer different questions (does the region move, vs are its declared members the real ones).
53
+ const missing = /no `capture` export/.test(parsed.error ?? '');
54
+ report.warn(
55
+ 'catalogue',
56
+ missing
57
+ ? `${members.length} member type(s) declared and no capture to check them against — add a \`capture\` ` +
58
+ `export to ${paths.rel(file)} pointing at the app's own fixtures, or the declaration is a guess`
59
+ : `capture could not be read from ${paths.rel(file)}: ${parsed.error ?? 'no report'}`,
60
+ );
61
+ return;
62
+ }
63
+ const { covered, uncovered, speculative, unreachable, coverage } = parsed.report;
64
+ const presentTypes = covered.length + uncovered.length;
65
+
66
+ if (unreachable.length)
67
+ report.error(
68
+ 'catalogue',
69
+ `${unreachable.join(', ')} — not in the app's own enum of member types, so no data row can ever summon ` +
70
+ `${unreachable.length > 1 ? 'them' : 'it'}; the island is unreachable, not merely unexercised`,
71
+ );
72
+ if (uncovered.length)
73
+ report.error(
74
+ 'catalogue',
75
+ `${uncovered.join(', ')} — the capture contains ${uncovered.length > 1 ? 'these member types' : 'this member type'} and no island declares ` +
76
+ `${uncovered.length > 1 ? 'them' : 'it'}; the region renders less than the page does, and the lagoon frame is short by that much`,
77
+ );
78
+ for (const t of speculative)
79
+ report.warn('catalogue', `${t} declared but absent from the capture — no evidence has ever exercised it`);
80
+ if (!uncovered.length && !unreachable.length)
81
+ report.ok(
82
+ 'catalogue',
83
+ `${covered.length}/${presentTypes} member type(s) in the capture are declared (${(coverage * 100).toFixed(0)}%)`,
84
+ { n: presentTypes, of: 'member type(s) in the capture' },
85
+ );
86
+ }
87
+
88
+ /**
89
+ * Every slot a region declares should be asserted by some flow's `expectRender`.
90
+ *
91
+ * The gap this closes was found by running two agents in parallel. Both implemented their island
92
+ * correctly; the MERGE went wrong — each had extended the lagoon frame's slot→row lookup, whose
93
+ * fallback was another island's row, so resolving the conflict naively made one agent's widget render
94
+ * as the other's. `archipelago verify --runtime` was byte-identical between the correct and the broken
95
+ * resolution, because the frame is arrangement: not declared, and therefore not checked.
96
+ *
97
+ * What distinguishes them is an assertion on the island's OWN rendered output. A slot no flow looks at
98
+ * can be wired to anything and stay green.
99
+ *
100
+ * A WARNING, not an error, and deliberately: the rule is new, and regions written before it would go
101
+ * red wholesale — which teaches people to ignore the report rather than to fix it. It states the count
102
+ * so the backlog is visible.
103
+ */
104
+ export function renderCoverageCheck(report, id) {
105
+ const region = readRegions(paths.archipelagosDir).find((r) => r.id === id);
106
+ const slots = (region?.islands ?? []).filter((i) => !i.planned).map((i) => i.slot);
107
+ if (!slots.length) return;
108
+
109
+ const asserted = new Set();
110
+ for (const scenario of readScenariosFor(id)) {
111
+ for (const st of scenario.steps ?? []) {
112
+ for (const slot of Object.keys(st.expectRender ?? {})) asserted.add(slot);
113
+ }
114
+ }
115
+ const uncovered = slots.filter((s) => !asserted.has(s));
116
+ if (!uncovered.length) {
117
+ report.ok('render-coverage', `every declared slot is asserted by a flow`, { n: slots.length, of: 'slot(s) asserted' });
118
+ return;
119
+ }
120
+ report.warn(
121
+ 'render-coverage',
122
+ `${uncovered.length}/${slots.length} slot(s) no flow asserts: ${uncovered.join(', ')} — nothing distinguishes ` +
123
+ `"this slot renders its own island" from "it renders someone else's data". Add a step with ` +
124
+ `\`expectRender: { '<slot>': '<text the island itself produces>' }\``,
125
+ );
126
+ }
127
+
128
+ /**
129
+ * Every key a region declares an island WRITES should be moved by some flow.
130
+ *
131
+ * `wiring-live` is precise about what it proves: the region APPLIES a declared write when the event is
132
+ * fired. It fires that event ITSELF, and so does a flow's `emit` — both go through the lagoon's emit
133
+ * seam, not through the component. So neither can tell you the component still produces the event.
134
+ *
135
+ * Measured, and it corrected the guess that prompted this check: silencing a component's `onProgress`
136
+ * in a real region changed nothing at any tier, EVEN THOUGH a flow drove `week-progress`. Driving a
137
+ * coupling is not exercising the thing that emits it. That miss belongs to the runtime `emitted-live`,
138
+ * which observes what actually fired; this check cannot catch it and does not claim to.
139
+ *
140
+ * What this one answers is narrower and still worth having: is this coupling exercised AT ALL? A
141
+ * declared write no flow ever drives has never been shown to reach its key in a running region — the
142
+ * declaration is unproven rather than merely unobserved. Static, because it is a question about the
143
+ * evidence files, so it belongs in the fast loop.
144
+ *
145
+ * A WARNING, for the same reason `render-coverage` is one: the rule is new, and every region written
146
+ * before it would go red wholesale, which teaches people to ignore the report rather than fix it.
147
+ */
148
+ export function writesCoveredCheck(report, id) {
149
+ const islands = declaredWrites(id);
150
+ // Slot → the events that slot declares it writes on. A slot with no `writes` is not a finding.
151
+ const declared = [];
152
+ for (const island of islands) {
153
+ for (const event of Object.keys(island.writes ?? {})) {
154
+ const target = island.writes[event];
155
+ const key = typeof target === 'string' ? target : (target?.key ?? Object.keys(target ?? {})[0] ?? '?');
156
+ declared.push({ slot: island.slot, event, key });
157
+ }
158
+ }
159
+ if (!declared.length) return;
160
+
161
+ // Driven by an `emit`, or asserted where only its producer could have put it — see `undrivenWrites`.
162
+ const undriven = undrivenWrites(declared, readScenariosFor(id));
163
+ if (!undriven.length) {
164
+ report.ok('writes-covered', 'every declared write is driven or asserted from its producer by a flow', {
165
+ n: declared.length,
166
+ of: 'declared write(s)',
167
+ });
168
+ return;
169
+ }
170
+ report.warn(
171
+ 'writes-covered',
172
+ `${undriven.length}/${declared.length} declared write(s) no flow drives: ` +
173
+ `${undriven.map((d) => `${d.slot} → ${d.key} (on "${d.event}")`).join(', ')} — the coupling is declared and ` +
174
+ `never exercised, so the component could stop emitting it and nothing here would change. Add a step with ` +
175
+ `\`emit: { slot: '<slot>', event: '<event>' }\` and an \`expect\` on what ANOTHER island then shows — or ` +
176
+ `drive the island's own act and \`expect\` the key, with no seed or \`provide\` handing it a value`,
177
+ );
178
+ }
179
+
180
+ /**
181
+ * A step that cannot fail is not a check.
182
+ *
183
+ * Every green flow in this project has, at some point, been a tautology. The worst one asserted that
184
+ * providing `editingWidgetId` made one island render "editing" and the other "idle" — and passed for
185
+ * hours against stand-in components that printed those words because I had written them. Nothing
186
+ * mechanical caught it; swapping in the app's real components did.
187
+ *
188
+ * TWO RULES, one static and one that runs:
189
+ *
190
+ * BY CONSTRUCTION — a step whose `expect` names only keys that same step `provide`d asserts that the
191
+ * lagoon stored what it was handed. That cannot fail unless the lagoon itself is broken, and it is
192
+ * decidable without a browser.
193
+ *
194
+ * BY MUTATION — change the STIMULUS and re-run. If the assertion still holds, it does not depend on
195
+ * the input, so the step is asserting a constant. This is the `data-flow` idea (a scenario set whose
196
+ * members render identically fails) applied per step to a region's flows.
197
+ *
198
+ * What neither rule catches, and it should be said rather than implied: an assertion on a stand-in's
199
+ * invented vocabulary is sensitive to its stimulus and mutates correctly. Only rendering the
200
+ * application's own component makes that one visible.
201
+ */
202
+ /** The region's declared scenarios, by the same two paths `regionFlowCheck` uses. */
203
+ export function readScenariosFor(id) {
204
+ const file = paths.archipelagoEvidence(id);
205
+ if (!existsSync(file)) return [];
206
+ const viaTsx = readScenarios(file);
207
+ return Array.isArray(viaTsx) ? viaTsx : [];
208
+ }
209
+
210
+ /**
211
+ * The mutants for a scenario set, and the findings that need no browser at all.
212
+ *
213
+ * Split out so the mutants can ride ALONG with the real flows in one page session. They used to be a
214
+ * second `runRegionFlows` call, which meant a second lagoon boot — and booting is most of what a flow
215
+ * check costs. One boot, both sets, partitioned by name afterwards.
216
+ */
217
+ export function buildMutants(report, scenarios) {
218
+ let vacuous = 0;
219
+ for (const scenario of scenarios) {
220
+ for (const [i, step] of (scenario.steps ?? []).entries()) {
221
+ const expected = Object.keys(step.expect ?? {});
222
+ const provided = Object.keys(step.provide ?? {});
223
+ if (!expected.length || !provided.length) continue;
224
+ if (expected.every((k) => provided.includes(k)) && !Object.keys(step.expectRender ?? {}).length) {
225
+ vacuous++;
226
+ report.error(
227
+ 'flow-mutation',
228
+ `"${scenario.name ?? 'flow'}" step ${i + 1} expects only ${expected.join(', ')} — the key(s) it just ` +
229
+ `provided. That asserts the lagoon stored what it was handed, not that the region did anything. ` +
230
+ `End on a key another island produces, or on \`expectRender\` of an island that is not the one driven`,
231
+ );
232
+ }
233
+ }
234
+ }
235
+
236
+ const mutants = [];
237
+ let coverageOnly = 0;
238
+ for (const scenario of scenarios) {
239
+ for (const [i, step] of (scenario.steps ?? []).entries()) {
240
+ if (!Object.keys(step.expect ?? {}).length && !Object.keys(step.expectRender ?? {}).length) continue;
241
+ const mutated = mutateStimulus(step);
242
+ if (!mutated) {
243
+ coverageOnly++;
244
+ continue;
245
+ }
246
+ const name = `${MUTANT_PREFIX}${scenario.name ?? 'flow'} § ${i + 1}`;
247
+ mutants.push({ name, seed: scenario.seed, steps: [...(scenario.steps ?? []).slice(0, i), mutated] });
248
+ // A SECOND MUTANT OF THE SAME TYPE, consulted only when the crude one CRASHES the region. The
249
+ // crude value stays first — it cannot reproduce the original by accident — but on shlink every
250
+ // search step's `null` threw inside the list, so a crash was the only answer it ever gave.
251
+ const typed = mutateStimulus(step, 'typed');
252
+ if (typed) mutants.push({ name: `${name}${TYPED_SUFFIX}`, seed: scenario.seed, steps: [...(scenario.steps ?? []).slice(0, i), typed] });
253
+ }
254
+ }
255
+ return { mutants, coverageOnly, vacuous };
256
+ }
257
+
258
+ /**
259
+ * The same step, driven differently.
260
+ *
261
+ * Deliberately crude: a value the region cannot mistake for the real one. Subtlety would only make it
262
+ * possible for a mutant to reproduce the original behaviour by accident, which turns a tautology into
263
+ * a pass — the exact failure this check exists to remove.
264
+ */
265
+ export function mutateStimulus(original, kind = 'crude') {
266
+ // A mutant is judged on what the step RENDERS and STORES, never on `expectCalls`: "asks for
267
+ // nothing" rarely depends on the input, so judging it would read every such step as a constant —
268
+ // and a mutant failing only because its calls changed would be credited as a kill.
269
+ const { expectCalls: _calls, ...step } = original;
270
+ void _calls;
271
+ // TYPED: another value of the SAME type — a different string, number or boolean. Only for scalars:
272
+ // an object or array has no "other value" that is not a guess about its shape. `null` when nothing
273
+ // in the step can be mutated that way, so the caller sends no typed mutant at all.
274
+ const typedOf = (v) =>
275
+ typeof v === 'string'
276
+ ? v === '__motu_mutant__' ? '__motu_mutant_2__' : '__motu_mutant__'
277
+ : typeof v === 'number' && Number.isFinite(v)
278
+ ? v + 7919
279
+ : typeof v === 'boolean'
280
+ ? !v
281
+ : undefined;
282
+ if (step.provide) {
283
+ if (kind === 'typed') {
284
+ const entries = Object.entries(step.provide).map(([k, v]) => [k, typedOf(v)]);
285
+ if (entries.some(([, v]) => v === undefined)) return null;
286
+ return { ...step, provide: Object.fromEntries(entries), __mutant: true };
287
+ }
288
+ const provide = Object.fromEntries(
289
+ Object.entries(step.provide).map(([k, v]) => [k, v === null || v === undefined ? '__motu_mutant__' : null]),
290
+ );
291
+ return { ...step, provide, __mutant: true };
292
+ }
293
+ if (step.emit) {
294
+ if (kind === 'typed') {
295
+ const detail = typedOf(step.emit.detail);
296
+ return detail === undefined ? null : { ...step, emit: { ...step.emit, detail }, __mutant: true };
297
+ }
298
+ return {
299
+ ...step,
300
+ emit: { ...step.emit, detail: step.emit.detail === null ? '__motu_mutant__' : null },
301
+ __mutant: true,
302
+ };
303
+ }
304
+ return null;
305
+ }
306
+
307
+ /** Marks the same-type mutant that stands in when the crude one crashes. */
308
+ export const TYPED_SUFFIX = ' · typed';
309
+
310
+ /** Null-island mutants: every flow replayed with one slot's island nulled. */
311
+ export const NULL_PREFIX = 'µ null · ';
312
+
313
+ /**
314
+ * DOES THE EVIDENCE DEPEND ON THIS ISLAND? (constraint-loop item 2b)
315
+ *
316
+ * `flow-mutation` asks whether a STEP depends on its stimulus. Nothing asked whether the region's
317
+ * evidence depends on an ISLAND: `render-coverage` only asks whether a flow looks at a slot. A slot
318
+ * whose only assertion is text its default render also shows is covered and unconstrained — an island
319
+ * ignoring every input passes. So each asserted slot is nulled twice — rendering from its DEFAULTS
320
+ * (deaf to the region) and rendering NOTHING — and every flow is replayed. At least one must fail.
321
+ *
322
+ * Only slots some flow asserts: an unasserted slot is `render-coverage`'s finding already.
323
+ */
324
+ export function buildNullIslands(region, scenarios) {
325
+ const asserted = new Set(scenarios.flatMap((s) => (s.steps ?? []).flatMap((st) => Object.keys(st.expectRender ?? {}))));
326
+ const islands = (region?.islands ?? []).filter((i) => !i.planned && asserted.has(i.slot));
327
+ const slots = islands.map((i) => i.slot);
328
+ // `defaults` only where the island READS the region: a pure producer (a search box binding nothing)
329
+ // renders from its defaults ALWAYS, so that mutant could never differ and would accuse every
330
+ // producer. Its residual — the handler `emit` never drives — is `motu assurance`'s `runtime·emit`.
331
+ // `bind` arrives in either form — the declared array, or the `{ prop: key }` object `readRegions`
332
+ // normalises it to (`{}` when the island binds nothing). Count bound KEYS, not entries.
333
+ const boundKeys = (b) =>
334
+ [].concat(b ?? []).flatMap((e) => (typeof e === 'string' ? [e] : e && typeof e === 'object' ? Object.values(e) : []));
335
+ const reads = (i) => boundKeys(i.bind).length > 0 || (i.reads ?? []).length > 0;
336
+ // THE VERDICT IS "DID ANY FLOW FAIL?", so each (slot, mode) is a kill group: the runner stops the
337
+ // group at its first failing step (`__killGroup`). Flows that look at the slot — assert its render,
338
+ // emit from it, click in it — go first, because they are the ones that fail when it is nulled; the
339
+ // rest only run when those all held, which is exactly the case the verdict has to see in full.
340
+ const touches = (s, slot) =>
341
+ (s.steps ?? []).some((st) => slot in (st.expectRender ?? {}) || st.emit?.slot === slot);
342
+ const mutants = [];
343
+ for (const isl of islands) {
344
+ const slot = isl.slot;
345
+ const ordered = [...scenarios.filter((s) => touches(s, slot)), ...scenarios.filter((s) => !touches(s, slot))];
346
+ for (const mode of reads(isl) ? ['defaults', 'nothing'] : ['nothing']) {
347
+ for (const s of ordered) {
348
+ mutants.push({
349
+ ...s,
350
+ name: `${NULL_PREFIX}${slot} · ${mode} :: ${s.name ?? 'flow'}`,
351
+ __nullIsland: { slot, mode },
352
+ __killGroup: `null:${slot}:${mode}`,
353
+ // The flow this replays, so the runner can give each step what the real step needed.
354
+ __of: s.name ?? 'flow',
355
+ });
356
+ }
357
+ }
358
+ }
359
+ return { slots, mutants };
360
+ }
361
+
362
+ /** The verdict: a nulled slot under which every flow still holds is not constrained by the evidence. */
363
+ export function reportNullIslands(report, flows, { slots, mutants }) {
364
+ if (!slots.length) return;
365
+ const mine = flows.filter((f) => String(f.scenario).startsWith(NULL_PREFIX));
366
+ if (mine.length && mine.every((f) => f.unsupported)) {
367
+ report.skip(
368
+ 'null-island',
369
+ `this region's lagoon mounts islands as custom elements, which cannot be nulled yet — whether the ` +
370
+ `evidence depends on each island is not answered here (the React mount path answers it)`,
371
+ );
372
+ return;
373
+ }
374
+ const held = (slot, mode) => {
375
+ const names = mutants.filter((m) => m.__nullIsland.slot === slot && m.__nullIsland.mode === mode).map((m) => m.name);
376
+ const steps = flows.filter((f) => names.includes(f.scenario));
377
+ return steps.length > 0 && steps.every((f) => f.ok);
378
+ };
379
+ const ran = (slot) => flows.some((f) => String(f.scenario).startsWith(`${NULL_PREFIX}${slot} · `));
380
+ let constrained = 0;
381
+ for (const slot of slots) {
382
+ if (!ran(slot)) continue;
383
+ const nothing = held(slot, 'nothing');
384
+ const defaults = held(slot, 'defaults');
385
+ if (nothing) {
386
+ report.error(
387
+ 'null-island',
388
+ `${slot}: every flow still holds with this slot's island rendering NOTHING — the evidence does not ` +
389
+ `constrain it at all. Assert something only this island renders, after something moves it`,
390
+ );
391
+ } else if (defaults) {
392
+ report.error(
393
+ 'null-island',
394
+ `${slot}: every flow still holds with this island rendering from its DEFAULTS, deaf to the region — ` +
395
+ `the evidence only checks what it shows with no data. Assert what it renders from a key that moves`,
396
+ );
397
+ } else constrained++;
398
+ }
399
+ const examined = slots.filter(ran).length;
400
+ if (examined && constrained === examined)
401
+ report.ok('null-island', `every asserted slot's island is constrained: nulling it fails a flow`, { n: examined, of: 'slot(s) nulled' });
402
+ else if (!examined) report.inconclusive('null-island', 'no null-island mutant came back from the run, so nothing is proved');
403
+ }
404
+
405
+ /**
406
+ * `--verbose`: the flow lane split by what rides it. One `region-flow` number hid that the real flows
407
+ * are the smallest part of it — the mutants that prove they could fail are most of the time.
408
+ */
409
+ function printLaneProfile(run) {
410
+ const kinds = [
411
+ ['null-island', (n) => n.startsWith(NULL_PREFIX)],
412
+ ['flow-mutation', (n) => n.startsWith(MUTANT_PREFIX)],
413
+ ['world-differs', (n) => n.startsWith(WORLD_MUTANT_PREFIX)],
414
+ ['flows', () => true],
415
+ ];
416
+ const sums = new Map(kinds.map(([k]) => [k, { n: 0, ms: 0, steps: 0 }]));
417
+ for (const t of run.timings) {
418
+ const [kind] = kinds.find(([, is]) => is(String(t.scenario)));
419
+ const s = sums.get(kind);
420
+ s.n++;
421
+ s.ms += t.ms;
422
+ s.steps += t.steps;
423
+ }
424
+ const parts = [...sums].filter(([, s]) => s.n).map(([k, s]) => `${k} ${s.n}×/${s.steps} steps ${(s.ms / 1000).toFixed(1)}s`);
425
+ const rest = Math.max(0, (run.scenariosMs ?? 0) - run.timings.reduce((n, t) => n + t.ms, 0));
426
+ process.stderr.write(` ${parts.join(' · ')}${rest > 500 ? ` · between scenarios ${(rest / 1000).toFixed(1)}s` : ''}\n`);
427
+ }
428
+
429
+ /** Mutant scenarios are tagged so one run can carry both sets and tell them apart afterwards. */
430
+ export const MUTANT_PREFIX = 'µ mutant · ';
431
+ export const WORLD_MUTANT_PREFIX = 'µ world · ';
432
+
433
+ /** Report what the mutants did — no browser here; they ran with the real flows. */
434
+ export function reportMutants(report, flows, { mutants, coverageOnly, vacuous }) {
435
+ if (!mutants.length) {
436
+ report.skip('flow-mutation', 'no step carries both a stimulus and an assertion, so there is nothing to mutate');
437
+ return;
438
+ }
439
+ const lastOf = new Map();
440
+ for (const f of flows) {
441
+ if (!String(f.scenario).startsWith(MUTANT_PREFIX)) continue;
442
+ const prev = lastOf.get(f.scenario);
443
+ if (!prev || f.step > prev.step) lastOf.set(f.scenario, f);
444
+ }
445
+ // COUNT WHAT CAME BACK, not what was sent. Reporting `mutants.length` killed regardless of results
446
+ // meant that when the mutants failed to come back at all — a tag that did not survive the round
447
+ // trip, say — the check printed a confident green for work it never saw. Which is the exact failure
448
+ // `report.ok(…, seen)` exists to prevent, reproduced inside the check that polices tautologies.
449
+ if (lastOf.size !== mutants.length) {
450
+ report.inconclusive(
451
+ 'flow-mutation',
452
+ `sent ${mutants.length} mutant(s) and got ${lastOf.size} back — the run did not answer for all of ` +
453
+ `them, so nothing is proved about the ones missing`,
454
+ );
455
+ return;
456
+ }
457
+ // A mutant that BROKE the region proves nothing. Crude values are deliberate — they must not
458
+ // reproduce the original behaviour by accident — but emitting one can crash the island receiving it,
459
+ // and a crash is not the assertion discriminating. It used to count only an error with NO mismatch
460
+ // as broken; a crash that EMPTIES the slot is a mismatch too, so on shlink a step asserting a
461
+ // constant (the search box's own placeholder, after searching) was credited as killed. A step that
462
+ // raised a page error is broken, whatever its assertions then saw.
463
+ const broke = (f) => !f.ok && (Boolean(f.crashed) || (f.error && !(f.mismatches ?? []).length));
464
+ const why = (f) => f.crashed ?? f.error ?? 'the region broke';
465
+ const label = (name) => String(name).replace(MUTANT_PREFIX, '');
466
+ let killed = 0;
467
+ let survivedAny = false;
468
+ for (const [name, crude] of lastOf) {
469
+ if (name.endsWith(TYPED_SUFFIX)) continue; // judged with its crude mutant, below
470
+ const typed = lastOf.get(`${name}${TYPED_SUFFIX}`);
471
+ if (crude.ok) {
472
+ survivedAny = true;
473
+ report.error(
474
+ 'flow-mutation',
475
+ `"${label(name)}" still holds when its input is changed — the assertion does not depend on what the ` +
476
+ `step does, so it is asserting a constant. Assert something the stimulus moves`,
477
+ );
478
+ continue;
479
+ }
480
+ if (!broke(crude)) {
481
+ killed++;
482
+ continue;
483
+ }
484
+ // The crude mutant crashed. The same-type one decides, when there is one and it did not crash too.
485
+ if (typed && !broke(typed)) {
486
+ if (!typed.ok) {
487
+ killed++;
488
+ continue;
489
+ }
490
+ // AN ERROR, as when the crude mutant itself survives — one rule, whether or not the app happens to
491
+ // crash on `null`. Two inputs that land in the same case (two terms that both match nothing) are
492
+ // still an assertion the stimulus does not move; the step has to assert something only its own
493
+ // input produces.
494
+ survivedAny = true;
495
+ report.error(
496
+ 'flow-mutation',
497
+ `"${label(name)}" still holds when its input is changed to another value of the same type (the crude ` +
498
+ `mutant crashed the region: ${why(crude)}) — the assertion does not depend on what the step does. ` +
499
+ `Assert something only this input produces`,
500
+ );
501
+ continue;
502
+ }
503
+ report.warn(
504
+ 'flow-mutation',
505
+ `"${label(name)}" broke the region when its input was mutated (${why(crude)})` +
506
+ (typed ? ' — on a crude and on a same-type input alike' : '') +
507
+ ` — the assertion was never evaluated, so this step's discrimination is unproven`,
508
+ );
509
+ }
510
+ const steps = [...lastOf.keys()].filter((n) => !n.endsWith(TYPED_SUFFIX)).length;
511
+ if (!survivedAny && !vacuous)
512
+ report.ok(
513
+ 'flow-mutation',
514
+ `${killed}/${steps} step(s) fail on their assertion when mutated` +
515
+ (coverageOnly ? `; ${coverageOnly} coverage step(s) have no input to mutate` : ''),
516
+ { n: killed, of: 'mutant(s) killed' },
517
+ );
518
+ }
519
+
520
+ /**
521
+ * The region's declared flows end where they say they do.
522
+ *
523
+ * Optional: a region with no `<id>.evidence.ts` is reported as having none, rather than passing
524
+ * quietly — a check that says nothing is indistinguishable from one that did not run.
525
+ */
526
+ export async function regionFlowCheck(report, id, port, region) {
527
+ const file = paths.archipelagoEvidence(id);
528
+ if (!existsSync(file)) {
529
+ report.warn('region-flow', 'no declared flows — add `<id>.evidence.ts` with the couplings this region promises');
530
+ return;
531
+ }
532
+ let scenarios = [];
533
+ try {
534
+ const mod = await import(`file://${file}?t=${Date.now()}`);
535
+ scenarios = Array.isArray(mod.scenarios) ? mod.scenarios : [];
536
+ } catch {
537
+ // Same trap as the island evidence: a plain node import cannot resolve a `.js` specifier pointing
538
+ // at a `.ts` sibling, which is the convention every file here uses. Fall through to the tsx
539
+ // loader rather than reporting "no flows" for a file that declares several.
540
+ scenarios = readScenarios(file);
541
+ }
542
+ if (!scenarios.length) {
543
+ report.warn('region-flow', 'declared flows could not be read, or none are declared');
544
+ return;
545
+ }
546
+ const steps = scenarios.reduce((n, s) => n + (s.steps?.length ?? 0), 0);
547
+ if (!steps) {
548
+ report.warn('region-flow', 'evidence declares no steps — a flow is a seed, an emit and an expectation');
549
+ return;
550
+ }
551
+ // The mutants ride along: one lagoon boot answers "do the flows hold?" and "could they have
552
+ // failed?" together. Booting is most of what a flow check costs, and this was paying it twice.
553
+ const mutation = buildMutants(report, scenarios);
554
+ const nulls = buildNullIslands(region, scenarios);
555
+ // WORLD-DIFFERS, by mutation: each flow that names a world, replayed WITHOUT it. If it still holds,
556
+ // its assertions do not depend on anything the world answers — the world is decoration for it.
557
+ const worldMutants = scenarios
558
+ .filter((s) => s.world)
559
+ .map((s) => ({ ...s, world: undefined, name: `${WORLD_MUTANT_PREFIX}${s.name ?? 'flow'}`, __world: s.world }));
560
+
561
+ let results;
562
+ let suspects = [];
563
+ try {
564
+ // What the chart needs: the real flows, and every island's scenarios keyed by its tag.
565
+ const chartWith = { flows: scenarios, scenarios: await regionWhatIfs(region) };
566
+ const run = await step('region-flow', () =>
567
+ runRegionFlows({ id, port, scenarios: [...scenarios, ...mutation.mutants, ...worldMutants, ...nulls.mutants], chartWith }),
568
+ );
569
+ if (isVerbose() && run.timings?.length) printLaneProfile(run);
570
+ results = (run.flows ?? []).filter(
571
+ (f) => ![MUTANT_PREFIX, WORLD_MUTANT_PREFIX, NULL_PREFIX].some((p) => String(f.scenario).startsWith(p)),
572
+ );
573
+ for (const wm of worldMutants) {
574
+ const steps = (run.flows ?? []).filter((f) => f.scenario === wm.name);
575
+ const flowName = wm.name.slice(WORLD_MUTANT_PREFIX.length);
576
+ // The mutant can only say something when the flow it mutates HOLDS in its world: a failing flow
577
+ // fails without its world too, and that is not evidence the world changes anything.
578
+ const real = (run.flows ?? []).filter((f) => f.scenario === flowName);
579
+ if (real.length && !real.every((f) => f.ok)) {
580
+ report.skip('world-differs', `"${flowName}" does not hold in world "${wm.__world}", so whether the world matters is not answerable yet`);
581
+ } else if (steps.length && steps.every((f) => f.ok)) {
582
+ report.warn('world-differs', `"${flowName}" still holds WITHOUT world "${wm.__world}" — none of its assertions depends on what that world answers. Assert what the world changes, or drop the world`);
583
+ } else if (steps.length) {
584
+ report.ok('world-differs', `"${flowName}" fails without world "${wm.__world}" — the world is what it shows`);
585
+ }
586
+ }
587
+ reportMutants(report, run.flows ?? [], mutation);
588
+ reportNullIslands(report, run.flows ?? [], nulls);
589
+ suspects = run.suspects ?? [];
590
+ reportStoreComplaints(report, run.diagnostics, 'declared flows');
591
+ provenanceCheck(report, id, region, run.provenance ?? [], run.outbound ?? []);
592
+ // Beside provenance because it reads the SAME ledger: provenance says what was asked for, this
593
+ // says whether it exists. No extra page, no navigation — the lane reuses one.
594
+ operationReachCheck(report, run.outbound ?? [], operationUniverse(), declaredReach({ regionId: id }));
595
+ sourcesLiveCheck(report, id, run.channels, region, run.held ?? []);
596
+ emittedLiveCheck(report, id, run.renderOutputs);
597
+ whatIfReachCheck(report, run.chart);
598
+ } catch (err) {
599
+ // A ReferenceError or a TypeError here is a BUG IN THIS FILE, not a region that could not be
600
+ // driven — and reporting it as a warning meant the flows silently stopped running twice today
601
+ // while the check still passed. Fail loudly for those; keep the warning for real inability.
602
+ if (err instanceof ReferenceError || err instanceof TypeError) throw err;
603
+ report[environmentalCause(err) ? 'inconclusive' : 'warn'](
604
+ 'region-flow',
605
+ `could not run declared flows: ${err.message}`,
606
+ );
607
+ return;
608
+ }
609
+ for (const s of suspects) {
610
+ report.warn(
611
+ 'laundering',
612
+ `the host wrote "${s.key}" (read by ${s.readers.join(', ')}) ${s.gapMs}ms after ${s.after.slot} emitted ` +
613
+ `"${s.after.event}" — if that value is derived from what ${s.after.slot} did, declare it as an output ` +
614
+ `instead of feeding it from the page`,
615
+ );
616
+ }
617
+ for (const r of results.filter((x) => !x.ok)) {
618
+ const detail = r.error
619
+ ? r.error
620
+ : r.mismatches.map((m) => `${m.key}: expected ${JSON.stringify(m.expected)}, got ${JSON.stringify(m.actual)}`).join('; ');
621
+ report.error('region-flow', `"${r.scenario}" step ${r.step}: ${detail}`);
622
+ }
623
+ const passed = results.filter((r) => r.ok).length;
624
+ if (passed === results.length)
625
+ report.ok('region-flow', `${passed} declared flow step(s) end as declared`, { n: passed, of: 'step(s)' });
626
+ reportFlowCalls(
627
+ report,
628
+ results.map((r) => ({ where: `"${r.scenario}" step ${r.step}`, calls: r.calls, undeclared: r.undeclared })),
629
+ );
630
+ }
631
+
632
+ /** Each island's scenarios in a region, keyed by its element tag — what the chart places. */
633
+ export async function regionWhatIfs(region) {
634
+ const out = {};
635
+ for (const island of region?.islands ?? []) {
636
+ const tag = island.element;
637
+ if (!tag || out[tag]) continue;
638
+ const kebab = tag.startsWith(TAG_PREFIX) ? tag.slice(TAG_PREFIX.length) : tag;
639
+ out[tag] = await islandScenarios(kebab);
640
+ }
641
+ return out;
642
+ }
643
+
644
+ /**
645
+ * WHICH WHAT-IFS A FLOW REACHES — the chart, reported.
646
+ *
647
+ * An island scenario played in its region is a what-if: the island is TOLD a state. A flow is the
648
+ * region getting somewhere by its own wiring. Often they are the same situation, and the chart (the
649
+ * lagoon's `chartRegion`, the same function the dock's Chart runs) finds the pairs by replaying the
650
+ * flows and comparing the store. What is left over is the finding:
651
+ * - UNREACHED: no flow ends in that situation — a flow is missing, or the app never gets there and
652
+ * the island renders a state that does not exist. Named with the nearest step and what differs.
653
+ * - CONTEXT: a flow reaches the situation only with other context (another `me`). For a what-if
654
+ * whose point IS that context ("a guest"), no flow runs that way.
655
+ * Warnings, never errors: a what-if no flow reaches can be deliberate (an error state the lagoon
656
+ * cannot provoke), and whether it is, is the author's call. What a check owes them is the knowing.
657
+ */
658
+ export function whatIfReachCheck(report, chart) {
659
+ if (!chart) {
660
+ report.skip('what-if-reach', 'this lagoon build predates the chart — rebuild it to place what-ifs against flows');
661
+ return;
662
+ }
663
+ if (chart.error) {
664
+ report.warn('what-if-reach', `could not chart the region: ${chart.error}`);
665
+ return;
666
+ }
667
+ const placed = chart.whatIfs.filter((w) => w.status !== 'not-comparable');
668
+ const reached = placed.filter((w) => w.status === 'reached' || w.status === 'seeded');
669
+ const unreached = placed.filter((w) => w.status === 'unreached');
670
+ const context = placed.filter((w) => w.status === 'context');
671
+ for (const w of unreached) {
672
+ const near = w.nearest
673
+ ? ` — nearest: "${w.nearest.flow}" step ${w.nearest.step}, where ${w.nearest.differs.join(', ')} still differ${w.nearest.differs.length === 1 ? 's' : ''}`
674
+ : '';
675
+ report.warn('what-if-reach', `${w.slot} · "${w.name}" is shown only by telling the island; no flow reaches it${near}`);
676
+ }
677
+ for (const w of context) {
678
+ const r = w.reachedBy[0];
679
+ const empty = r.emptyHere ?? [];
680
+ const set = r.absent.filter((k) => !empty.includes(k));
681
+ const parts = [];
682
+ // Two directions, said the right way round: the what-if leaves a key empty that every flow sets
683
+ // ("a guest"), or sets one every flow leaves empty ("in the directory", where no flow picks a section).
684
+ if (empty.length) parts.push(`no flow runs with ${empty.join(', ')} empty, as this what-if does`);
685
+ if (set.length) parts.push(`no flow sets ${set.join(', ')}, as this what-if does`);
686
+ report.warn('what-if-reach', `${w.slot} · "${w.name}" is reached by "${r.flow}" step ${r.step} only in another context — ${parts.join('; ')}`);
687
+ }
688
+ if (!unreached.length && !context.length) {
689
+ report.ok('what-if-reach', `every what-if a flow could reach is reached by one`, { n: reached.length, of: 'what-if(s) placed' });
690
+ }
691
+ }
692
+
693
+ /**
694
+ * Every declared wire carries something.
695
+ *
696
+ * The runtime half of the ownership work: `RegionWiringOk` proves an event NAME resolves to an island
697
+ * that declares it, and that is all a type can do. Whether firing it actually moves the key it claims
698
+ * to write is a question only a run answers — and a wire that resolves, compiles and moves nothing is
699
+ * exactly what a broken change looks like from outside.
700
+ */
701
+ export async function wiringProbe(report, id, port) {
702
+ const islands = declaredWrites(id);
703
+ if (!islands.length) return;
704
+ let results;
705
+ try {
706
+ const probe = await step('wiring-live', () => probeWiring({ id, port, islands }));
707
+ results = probe.results;
708
+ reportStoreComplaints(report, probe.diagnostics, 'wiring probe');
709
+ } catch (err) {
710
+ report[environmentalCause(err) ? 'inconclusive' : 'warn'](
711
+ 'wiring-live',
712
+ `could not probe declared writes: ${err.message}`,
713
+ );
714
+ return;
715
+ }
716
+ const noSeam = results.filter((r) => r.reason === 'no-seam');
717
+ const unmounted = results.filter((r) => r.reason === 'not-mounted');
718
+ const dead = results.filter((r) => r.moved === false);
719
+ const live = results.filter((r) => r.moved === true);
720
+
721
+ if (noSeam.length) {
722
+ report.skip('wiring-live', 'this mount path has no emit seam — probing declared writes needs the React lagoon');
723
+ return;
724
+ }
725
+ for (const r of unmounted) {
726
+ report.error('wiring-live', `${r.slot} declares it writes "${r.key}", but this region mounts no island under that slot`);
727
+ }
728
+ for (const r of dead) {
729
+ report.error('wiring-live', `${r.slot} declares it writes "${r.key}" on "${r.event}", but firing it changed nothing`);
730
+ }
731
+ if (live.length && !dead.length && !unmounted.length) {
732
+ // Deliberately precise about what this proves: the region APPLIES each declared write. Whether the
733
+ // component ever emits it is a different question, and one only a declared interaction can ask
734
+ // (nothing here touches the DOM, on purpose).
735
+ report.ok(
736
+ 'wiring-live',
737
+ `${live.length} declared write(s) reach their key: ${live.map((r) => `${r.slot} → ${r.key}`).join(', ')}`,
738
+ { n: live.length, of: 'declared write(s)' },
739
+ );
740
+ }
741
+ }
742
+
743
+ /** `writes` per island, read from the config's text — the same reader eject uses. */
744
+ export function declaredWrites(id) {
745
+ const file = paths.archipelagoFile(id);
746
+ if (!existsSync(file)) return [];
747
+ return readRegions(paths.archipelagosDir)
748
+ .find((r) => r.id === id)
749
+ // `planned` ISLANDS ARE NOT HERE YET, and both readers of this list ask a question only a built
750
+ // island can answer. `wiring-live` mounts the slot and fires the event — a slot nobody has
751
+ // written mounts nothing, so it reported "this region mounts no island under that slot" as an
752
+ // ERROR against a region that is behaving exactly as declared. `writes-covered` asks for a flow
753
+ // driving the write, and no flow can drive an island that does not exist.
754
+ //
755
+ // That is the whole point of the flag: ownership still counts a planned entry — a second claim on
756
+ // its keys fails as if it were built — while the checks that ask "does it exist, does it mount,
757
+ // is it placed" skip it. This list had never been told, so demo-app's surveyed booking region was
758
+ // red in the runtime tier for having a survey.
759
+ ?.islands.filter((i) => !i.planned && Object.keys(i.writes ?? {}).length) ?? [];
760
+ }