@motujs/cli 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/bundled/github/agents/archipelago-build.agent.md +298 -0
- package/bundled/github/agents/island-create.agent.md +261 -0
- package/bundled/github/agents/island-extract.agent.md +360 -0
- package/bundled/github/agents/island-locate.agent.md +208 -0
- package/bundled/github/agents/motu-checks.agent.md +158 -0
- package/bundled/github/agents/motu-evidence.agent.md +267 -0
- package/bundled/github/agents/motu-regions.agent.md +304 -0
- package/bundled/github/host-rules.md +43 -0
- package/bundled/github/prompts/archipelago-build.prompt.md +19 -0
- package/bundled/github/prompts/island-create.prompt.md +37 -0
- package/bundled/github/prompts/island-extract.prompt.md +59 -0
- package/bundled/github/prompts/island-locate.prompt.md +43 -0
- package/bundled/packages/adapters/angularjs/vite.mjs +43 -0
- package/bundled/packages/adapters/next/lagoon/next-stubs.tsx +108 -0
- package/bundled/packages/adapters/next/vite.mjs +121 -0
- package/bundled/packages/adapters/vite/vite.mjs +179 -0
- package/package.json +64 -3
- package/src/alias-loader.mjs +63 -0
- package/src/alias-register.mjs +28 -0
- package/src/asset-stub.cjs +3 -0
- package/src/asset-stub.mjs +5 -0
- package/src/cli.mjs +78 -0
- package/src/commands/adopt-root.mjs +194 -0
- package/src/commands/archipelago-init.mjs +314 -0
- package/src/commands/archipelago.mjs +138 -0
- package/src/commands/assurance.mjs +40 -0
- package/src/commands/check.mjs +346 -0
- package/src/commands/codegen.mjs +29 -0
- package/src/commands/coverage.mjs +358 -0
- package/src/commands/create.mjs +277 -0
- package/src/commands/defaults.mjs +129 -0
- package/src/commands/diff.mjs +595 -0
- package/src/commands/fixtures.mjs +153 -0
- package/src/commands/init.mjs +473 -0
- package/src/commands/integrate.mjs +208 -0
- package/src/commands/integration.mjs +1349 -0
- package/src/commands/lagoon-try.mjs +272 -0
- package/src/commands/lagoon.mjs +1419 -0
- package/src/commands/link.mjs +110 -0
- package/src/commands/region-coverage.mjs +604 -0
- package/src/commands/removal-check.mjs +878 -0
- package/src/commands/skills.mjs +156 -0
- package/src/commands/snapshot.mjs +456 -0
- package/src/commands/sync.mjs +38 -0
- package/src/commands/verify/island-runtime.mjs +581 -0
- package/src/commands/verify/island-static.mjs +586 -0
- package/src/commands/verify/island.mjs +135 -0
- package/src/commands/verify/reach.mjs +426 -0
- package/src/commands/verify/region-composition.mjs +391 -0
- package/src/commands/verify/region-config.mjs +330 -0
- package/src/commands/verify/region-flows.mjs +760 -0
- package/src/commands/verify/region-sources.mjs +516 -0
- package/src/commands/verify/region.mjs +271 -0
- package/src/commands/verify/report.mjs +338 -0
- package/src/commands/verify/screens-runtime.mjs +123 -0
- package/src/commands/verify.mjs +18 -0
- package/src/lagoon-build.mjs +25 -0
- package/src/lagoon-dev.mjs +32 -0
- package/src/lib/alias-core.mjs +49 -0
- package/src/lib/archipelagos.mjs +111 -0
- package/src/lib/assurance.mjs +144 -0
- package/src/lib/baselines.mjs +107 -0
- package/src/lib/bundlability.mjs +123 -0
- package/src/lib/changed.mjs +157 -0
- package/src/lib/component-props.mjs +145 -0
- package/src/lib/config.mjs +451 -0
- package/src/lib/contracts.mjs +244 -0
- package/src/lib/data-flow.mjs +47 -0
- package/src/lib/declared-sources.mjs +101 -0
- package/src/lib/dock-inject.mjs +47 -0
- package/src/lib/effects.mjs +97 -0
- package/src/lib/eject.mjs +715 -0
- package/src/lib/evidence-json.mjs +74 -0
- package/src/lib/expect-calls.mjs +53 -0
- package/src/lib/finding-memory.mjs +0 -0
- package/src/lib/flow-calls.mjs +53 -0
- package/src/lib/framework.mjs +98 -0
- package/src/lib/host-rules.mjs +55 -0
- package/src/lib/host-sources.mjs +121 -0
- package/src/lib/island-placement.mjs +64 -0
- package/src/lib/island-render.mjs +105 -0
- package/src/lib/islands.mjs +112 -0
- package/src/lib/lagoon-bundle.mjs +138 -0
- package/src/lib/lagoon-declares.mjs +553 -0
- package/src/lib/lagoon-materialize.mjs +217 -0
- package/src/lib/lagoon-vite.mjs +788 -0
- package/src/lib/live-url.mjs +53 -0
- package/src/lib/node-aliases.mjs +73 -0
- package/src/lib/operations.mjs +145 -0
- package/src/lib/provenance-plugin.mjs +74 -0
- package/src/lib/region-refs.mjs +40 -0
- package/src/lib/remote.mjs +171 -0
- package/src/lib/scaffold.mjs +606 -0
- package/src/lib/screens.mjs +266 -0
- package/src/lib/snapshots.mjs +85 -0
- package/src/lib/stubs.mjs +114 -0
- package/src/lib/ts-project.mjs +42 -0
- package/src/lib/util.mjs +626 -0
- package/src/lib/worlds.mjs +236 -0
- package/src/lib/write-if-changed.mjs +35 -0
- package/src/lib/writes-coverage.mjs +32 -0
- package/src/playwright-lagoon.mjs +2305 -0
- package/src/run.mjs +385 -0
- package/src/runtime-harness.mjs +225 -0
- 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
|
+
}
|