@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.
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,581 @@
1
+ // An island's runtime checks: the harness, the browser lane, scenarios, viewports, axe.
2
+ // Split out of commands/verify.mjs; verify.mjs re-exports the public surface.
3
+ import { spawnSync } from 'node:child_process';
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import { dirname, resolve } from 'node:path';
6
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
+ import { SyntaxKind } from 'ts-morph';
8
+ import { reachableAppSources } from '../../lib/bundlability.mjs';
9
+ import { scenarioInputs } from '../../lib/data-flow.mjs';
10
+ import { readEffectEntries } from '../../lib/effects.mjs';
11
+ import { lostMarker, parseEvidence } from '../../lib/evidence-json.mjs';
12
+ import { lagoonEnv, nodeAliasEnv } from '../../lib/node-aliases.mjs';
13
+ import { sourceFileAt } from '../../lib/ts-project.mjs';
14
+ import { HOST, MOTU_CHECKOUT, REPO_ROOT, ensureNoInstallLinks, lagoonA11y, lagoonAliases, lagoonViewports, paths } from '../../lib/util.mjs';
15
+ import { axeLagoon, differentiateLagoon, responsiveLagoon, runLagoon } from '../../playwright-lagoon.mjs';
16
+ import { declaredReach, portalNote, reportRuntimeDiagnostics } from './reach.mjs';
17
+ import { environmentalCause, lagoonFailure, step } from './report.mjs';
18
+ import { CHROMIUM_INSTALL } from '../../lib/framework.mjs';
19
+
20
+ export const HARNESS = resolve(dirname(fileURLToPath(import.meta.url)), '../../runtime-harness.mjs');
21
+
22
+ // The CLI package root — used as cwd for the harness so `--import tsx` and the @motujs/* workspace
23
+ // symlinks resolve from packages/cli/node_modules (tsx is not hoisted to the repo root).
24
+ export const CLI_PKG = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
25
+
26
+ /**
27
+ * Every harness call, spawned the same way.
28
+ *
29
+ * The mount path got the alias loader, the project root and the lagoon env; the differentiation and
30
+ * scenario-reading calls did not, so on any project whose islands import through an alias they failed
31
+ * — and `runtimeDifferentiationCheck` returned SILENTLY on a non-zero exit, so `data-flow` simply
32
+ * never appeared in the report. A check that vanishes when it breaks is worse than one that fails.
33
+ */
34
+ export async function spawnHarness(args) {
35
+ ensureNoInstallLinks(REPO_ROOT, MOTU_CHECKOUT);
36
+ const ALIAS_REGISTER = resolve(dirname(fileURLToPath(import.meta.url)), '../../alias-register.mjs');
37
+ return spawnSync(process.execPath, ['--import', 'tsx', '--import', pathToFileURL(ALIAS_REGISTER).href, ...args], {
38
+ encoding: 'utf8',
39
+ cwd: CLI_PKG,
40
+ env: {
41
+ ...lagoonEnv(),
42
+ ...process.env,
43
+ MOTU_PROJECT_ROOT: REPO_ROOT,
44
+ MOTU_NODE_ALIASES: await nodeAliasEnv(),
45
+ },
46
+ });
47
+ }
48
+
49
+ /** Fast in-process lagoon mount under happy-dom for one fit (used with --fast). */
50
+ export async function runtimeCheckFast(report, tag, fixturesPath, fit) {
51
+ // The alias hook goes AFTER tsx so tsx compiles what it resolves.
52
+ const ALIAS_LOADER = resolve(dirname(fileURLToPath(import.meta.url)), '../../alias-register.mjs');
53
+ const args = ['--import', 'tsx', '--import', pathToFileURL(ALIAS_LOADER).href, HARNESS, tag, fixturesPath || '', fit];
54
+ // Node has to be able to resolve @motujs/* from the project's own files before it can load them.
55
+ ensureNoInstallLinks(REPO_ROOT, MOTU_CHECKOUT);
56
+ const res = await step('mount(fast)', async () =>
57
+ spawnSync(process.execPath, args, {
58
+ encoding: 'utf8',
59
+ cwd: CLI_PKG,
60
+ // cwd is the CLI package so `--import tsx` resolves; MOTU_PROJECT_ROOT says which project to load.
61
+ env: { ...lagoonEnv(), ...process.env, MOTU_PROJECT_ROOT: REPO_ROOT, MOTU_NODE_ALIASES: await nodeAliasEnv() },
62
+ }),
63
+ );
64
+ if (res.status !== 0) {
65
+ // The LAST stderr line is a stack frame; the useful one is what the harness said it died of.
66
+ const lines = (res.stderr || '').trim().split('\n').filter(Boolean);
67
+ const why = lines.find((l) => /harness-fatal|^Error|^[A-Za-z]*Error:/.test(l)) ?? lines.pop() ?? 'no output';
68
+
69
+ // TWO COPIES OF REACT is this path's own failure, not the island's.
70
+ //
71
+ // `--fast` is plain node: the project resolves `react` from its own node_modules and the framework
72
+ // packages resolve theirs, so a component that calls a hook meets a null dispatcher. The browser
73
+ // lagoon does not have this problem — vite dedupes — and the island that fails here passes there,
74
+ // which is exactly what happened to `promo-box` the first time this ran. Reporting it as a defect
75
+ // would send an agent to rewrite a component that is fine.
76
+ if (/Invalid hook call|Cannot read properties of null \(reading 'use[A-Z]/.test(res.stderr || '')) {
77
+ report.inconclusive(
78
+ 'lagoon-render',
79
+ `--fast cannot mount this island: two copies of React (the project's and the framework's) meet ` +
80
+ `in one tree, so hooks see a null dispatcher. Re-run without --fast; the browser lagoon dedupes`,
81
+ );
82
+ return;
83
+ }
84
+ report.error('lagoon-render', `lagoon mount failed (fit=${fit}) — ${why.trim()}`);
85
+ return;
86
+ }
87
+ const jsonLine = (res.stdout || '').trim().split('\n').filter(Boolean).pop();
88
+ let parsed;
89
+ try {
90
+ parsed = JSON.parse(jsonLine);
91
+ } catch {
92
+ report.error('lagoon-render', `lagoon produced no result (fit=${fit})`);
93
+ return;
94
+ }
95
+ if (parsed.ok) report.ok('lagoon-render', `renders in the lagoon (happy-dom, fit=${fit})`);
96
+ else if (!parsed.mounted) report.error('lagoon-render', `island tag <${tag}> did not upgrade (fit=${fit})`);
97
+ else report.warn('lagoon-render', `mounts but renders nothing from default props (fit=${fit}) — confirm this island's empty state is intentional (e.g. a pure projection)`);
98
+ reportRuntimeDiagnostics(report, parsed, `fit=${fit}`);
99
+ }
100
+
101
+ /**
102
+ * Data-flow (differentiation) check. Opt-in: only runs when the island's fixtures export two or more
103
+ * `scenarios` (distinct store seeds). Drives each seed across the archipelago boundary and asserts the
104
+ * rendered output DIFFERS — proving inputs actually flow across the seam (criteria -> contract ->
105
+ * render), not merely that the wiring type-checks. Uses the real-browser lagoon by default (so Vite
106
+ * resolves the island's assets); `--fast` uses the in-process happy-dom harness. Silent when fewer than
107
+ * two scenarios are declared.
108
+ */
109
+ export function readScenarios(fixturesPath) {
110
+ const args = ['--import', 'tsx', HARNESS, '', fixturesPath, 'native', 'scenarios'];
111
+ // Node has to be able to resolve @motujs/* from the project's own files before it can load them.
112
+ ensureNoInstallLinks(REPO_ROOT, MOTU_CHECKOUT);
113
+ const res = spawnSync(process.execPath, args, {
114
+ encoding: 'utf8',
115
+ cwd: CLI_PKG,
116
+ // cwd is the CLI package so `--import tsx` resolves; MOTU_PROJECT_ROOT says which project to load.
117
+ env: { ...process.env, MOTU_PROJECT_ROOT: REPO_ROOT },
118
+ });
119
+ if (res.status !== 0) return [];
120
+ const jsonLine = (res.stdout || '').trim().split('\n').filter(Boolean).pop();
121
+ // parseEvidence, not JSON.parse: the harness marks Dates on the way out so they arrive as Dates
122
+ // rather than ISO strings. Before that, any island formatting a date threw on mount and every
123
+ // seeded scenario reported "rendered NOTHING" — see lib/evidence-json.mjs.
124
+ return parseEvidence(jsonLine)?.scenarios ?? [];
125
+ }
126
+
127
+ export async function runtimeDifferentiationCheck(report, tag, fixturesPath, port, fast) {
128
+ if (!fixturesPath) return;
129
+ const scenarios = readScenarios(fixturesPath);
130
+ if (scenarios.length < 2) return; // the check is opt-in
131
+
132
+ if (fast) {
133
+ // In-process happy-dom: mount once per seed and diff (works when the registry has no vite-only imports).
134
+ const res = await spawnHarness([HARNESS, tag, fixturesPath, 'native', 'differentiate']);
135
+ if (res.status !== 0) {
136
+ const why = (res.stderr || '').trim().split('\n').find((l) => /harness-fatal|Error/.test(l)) ?? 'no output';
137
+ report.inconclusive('data-flow', `could not compare scenarios: ${why.trim()}`);
138
+ return;
139
+ }
140
+ const jsonLine = (res.stdout || '').trim().split('\n').filter(Boolean).pop();
141
+ let parsed;
142
+ try {
143
+ parsed = JSON.parse(jsonLine);
144
+ } catch {
145
+ return;
146
+ }
147
+ reportDifferentiation(report, parsed);
148
+ return;
149
+ }
150
+
151
+ try {
152
+ const r = await step('data-flow', () => differentiateLagoon({ tag, port, scenarios }));
153
+ reportDifferentiation(report, r);
154
+ } catch (err) {
155
+ const msg = String(err?.message || err).split('\n')[0];
156
+ if (!/Executable doesn't exist|playwright install/i.test(msg)) {
157
+ report.warn('data-flow', `differentiation check could not run: ${msg}`);
158
+ }
159
+ }
160
+ }
161
+
162
+ /**
163
+ * A scenario's scripted interactions must have DONE something — the guard that keeps
164
+ * `Scenario.interactions` from becoming the untyped browser-test suite the region-flow model
165
+ * deliberately refuses (see `RegionStep`'s doc comment in `@motujs/runtime/mock`).
166
+ *
167
+ * Sibling of `flow-mutation`'s "a step that cannot fail is not a check". The rule here is NOT "did
168
+ * the render change?" — the properties interactions exist for often assert that it did NOT (a panel
169
+ * keeping its last-good data through a failed refetch renders identically on purpose). Load-bearing
170
+ * means the render moved OR the interaction caused work. Neither means somebody clicked a journey
171
+ * whose end state a plain seed could have described, and it should be a seed.
172
+ *
173
+ * A WARNING, not an error: with no fake-fetch in play there is no request signal, so an interaction
174
+ * that legitimately only moves state the capture cannot see is indistinguishable from a decorative
175
+ * one, and the report says which case it could not tell apart.
176
+ */
177
+ export function reportInteractions(report, r) {
178
+ const inert = r.inertInteractions;
179
+ if (!inert) return; // the `--fast` lane runs no interactions — nothing to judge
180
+ if (inert.length === 0) {
181
+ report.ok('interaction-effective', 'every scripted interaction moved the render or caused work');
182
+ return;
183
+ }
184
+ for (const { name, requestsObservable } of inert) {
185
+ report.warn(
186
+ 'interaction-effective',
187
+ `"${name}" scripted an interaction that changed nothing observable — ` +
188
+ (requestsObservable
189
+ ? 'no render change and no request. A seed describing the same end state is the honest form'
190
+ : 'no render change, and this project has no fake-fetch request signal to check against. ' +
191
+ 'Either it is decorative, or what it moved is not captured — assert on what it produced'),
192
+ );
193
+ }
194
+ }
195
+
196
+ export function reportDifferentiation(report, r) {
197
+ if (!r) return;
198
+ // Never silent: a comparison that did not happen is a skip with its reason, not an absent line —
199
+ // an absent line reads exactly like a check this island does not have.
200
+ if (r.differentiates == null) {
201
+ report.skip(
202
+ 'data-flow',
203
+ r.mounted === false
204
+ ? 'the island never mounted in the lagoon, so no scenario was driven — see lagoon-render'
205
+ : 'no comparison was made, so distinct output is not proved',
206
+ );
207
+ return;
208
+ }
209
+ reportInteractions(report, r);
210
+ if (r.differentiates) {
211
+ // Duplicates are not a failure — two seeds may legitimately normalise to one output — but they are
212
+ // not evidence either, and the ok line used to cover for them.
213
+ if (r.distinctOutputs != null && r.distinctOutputs < (r.comparedCount ?? r.scenarioCount)) {
214
+ report.warn(
215
+ 'data-flow',
216
+ `${r.scenarioCount} scenarios produced only ${r.distinctOutputs} distinct render(s) — ` +
217
+ `${(r.comparedCount ?? r.scenarioCount) - r.distinctOutputs} of them show nothing the others do not`,
218
+ );
219
+ }
220
+ // A pass that rests on what the island PORTALED (a dialog on <body>) says so — otherwise it reads
221
+ // like the island's own subtree differed, and the next person cannot tell which it was.
222
+ const withPortals = (r.portals ?? []).filter((n) => n > 0);
223
+ const portaled = withPortals.length
224
+ ? ` · ${portalNote(withPortals.reduce((a, b) => a + b, 0))} in ${withPortals.length} of ${r.scenarioCount} scenario(s)`
225
+ : '';
226
+ report.ok(
227
+ 'data-flow',
228
+ `distinct inputs produce distinct output (${r.scenarioCount} scenarios) — data flows past the seam`,
229
+ { n: r.distinctOutputs ?? r.scenarioCount, of: `distinct render(s)${portaled}` },
230
+ );
231
+ } else if (r.blankButRendered?.length) {
232
+ // A DECLARATION THAT IS NO LONGER TRUE. `blank` says "renders nothing, on purpose"; an island that
233
+ // now renders something there either grew a state its author did not expect or the seed stopped
234
+ // meaning what it said. Either way the comparison would be built on a claim that is false.
235
+ report.error(
236
+ 'data-flow',
237
+ `${r.blankButRendered.length} scenario(s) declared \`blank\` rendered something — ${r.blankButRendered.join(', ')}. ` +
238
+ `Remove \`blank\` if the render is right, or fix what it renders`,
239
+ );
240
+ } else if (r.empty?.length) {
241
+ // AN EMPTY SCENARIO IS NOT AN IDENTICAL ONE. The comparison needs every scenario to have rendered
242
+ // something, so one that renders nothing sinks the check — and saying "rendered identically" then
243
+ // points at the wrong thing entirely. Name the scenarios, because an island whose empty state IS
244
+ // its correct answer (a summary of nothing, a bar with nothing to act on) cannot take part in this
245
+ // check at all, and the author needs to know that rather than hunt a bind gap.
246
+ report.error(
247
+ 'data-flow',
248
+ `${r.empty.length} of ${r.scenarioCount} scenario(s) rendered NOTHING — ${r.empty.join(', ')}. ` +
249
+ `Distinct output cannot be compared against a blank one. Either the island is not receiving that ` +
250
+ `seed, or its empty state is deliberate — then say so on the scenario: \`blank: '<why it renders nothing>'\``,
251
+ );
252
+ } else {
253
+ report.error(
254
+ 'data-flow',
255
+ `${r.scenarioCount} scenarios rendered identically — inputs don't reach the output: the seam is wired but data doesn't flow (bind gap, or fixtures aren't request-keyed with \`match\`)`,
256
+ );
257
+ }
258
+ }
259
+
260
+ /** Real-browser lagoon mount via Playwright/Chromium for one fit (the default). */
261
+ export async function runtimeCheckBrowser(report, tag, fit, port) {
262
+ try {
263
+ const r = await step('mount', () => runLagoon({ tag, fit, port }));
264
+ if (r.ok) report.ok('lagoon-render', `renders in the real-browser lagoon (fit=${fit})`);
265
+ else if (!r.mounted) report.error('lagoon-render', `island tag <${tag}> did not upgrade (fit=${fit})`);
266
+ else report.warn('lagoon-render', `mounts but renders nothing from default props (fit=${fit}) — confirm this island's empty state is intentional (e.g. a pure projection)`);
267
+ reportRuntimeDiagnostics(report, r, `fit=${fit}`, declaredReach({ tag }));
268
+ } catch (err) {
269
+ const msg = lagoonFailure(err);
270
+ const env = environmentalCause(err);
271
+ if (/Executable doesn't exist|playwright install/i.test(msg)) {
272
+ report.inconclusive('lagoon-render', `Chromium not installed — run ${CHROMIUM_INSTALL}, or use --fast for the happy-dom mount`);
273
+ } else if (env) {
274
+ report.inconclusive('lagoon-render', `could not boot the lagoon (fit=${fit}): ${env} — the environment, not the island`);
275
+ } else {
276
+ report.error('lagoon-render', `lagoon failed (fit=${fit}): ${msg}`);
277
+ }
278
+ }
279
+ }
280
+
281
+ /** Mount the island against a backend that fails every call (500), asserting it survives — renders and
282
+ * leaks no unhandled rejection / console error (i.e. it catches its own fetch failures). */
283
+ export async function runtimeErrorCheck(report, tag, port) {
284
+ try {
285
+ const r = await step('error-resilience', () => runLagoon({ tag, fit: 'native', port, forceError: 500, checkRemount: false }));
286
+ if (!r.mounted) {
287
+ report.warn('error-resilient', 'island did not mount under forced backend errors');
288
+ } else if (r.diagnostics.length === 0) {
289
+ report.ok('error-resilient', 'survives a backend error (renders, no unhandled rejection)');
290
+ } else {
291
+ report.error('error-resilient', `unhandled backend error — the island must catch its own calls: ${r.diagnostics[0]}`);
292
+ }
293
+ } catch (err) {
294
+ const msg = lagoonFailure(err);
295
+ // A missing browser is already reported by the normal lagoon check; don't double-report.
296
+ if (!/Executable doesn't exist|playwright install/i.test(msg)) {
297
+ report.error('error-resilient', `error mount failed: ${msg}`);
298
+ }
299
+ }
300
+ }
301
+
302
+ // Known adapter packages -> their verify contribution's export specifier. Discovery is by the adapter
303
+ // the island actually imports (read from element.ts), resolved via the package's `./verify` export.
304
+ export const ADAPTER_VERIFY = {
305
+ '@motujs/adapter-angularjs': '@motujs/adapter-angularjs/verify',
306
+ '@motujs/adapter-next': '@motujs/adapter-next/verify',
307
+ };
308
+
309
+ // Fallback discovery for hosts whose islands don't import an adapter at the mount point.
310
+ export const HOST_ADAPTER_VERIFY = { next: '@motujs/adapter-next/verify' };
311
+
312
+ /** Extract the structured `contract.coupling` from an island's element.ts (AST, not regex). */
313
+ export function extractCoupling(elementPath) {
314
+ const sf = sourceFileAt(elementPath, { allowJs: true });
315
+ const strAt = (obj, name) => {
316
+ const init = obj.getProperty(name)?.getInitializer();
317
+ return init && init.getKind() === SyntaxKind.StringLiteral ? init.getText().replace(/['"]/g, '') : undefined;
318
+ };
319
+ for (const obj of sf.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)) {
320
+ // MECHANISM AND BOUNDARY, READ APART. `mount` says how the element attaches to a legacy scope;
321
+ // the names it RESOLVES from that scope are boundary facts and live in `contract.effects` as
322
+ // `scope:…`, beside the modules and tables. One list for everything an island reaches.
323
+ const contract = obj.getProperty('contract')?.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression);
324
+ const mount = obj.getProperty('mount')?.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression);
325
+ // Through the shared reader, so an authored `{ scope: 'search' }` and the canonical `scope:search`
326
+ // this check wants are the same fact read once. Reading the array's TEXT rather than its AST nodes
327
+ // keeps one implementation for the island file, the source module and the generated contracts.
328
+ const effectsProp = contract?.getProperty('effects');
329
+ const effects = effectsProp ? readEffectEntries(effectsProp.getText()) : undefined;
330
+ const hostScope = effects?.filter((e) => e.startsWith('scope:')).map((e) => e.slice('scope:'.length));
331
+ if (!mount && !hostScope?.length) continue;
332
+ return {
333
+ adopt: mount && strAt(mount, 'adopt'),
334
+ inheritScope: mount && strAt(mount, 'inheritScope'),
335
+ hostScopeKey: mount && strAt(mount, 'hostScopeKey'),
336
+ hostScope: hostScope?.length ? hostScope : undefined,
337
+ };
338
+ }
339
+ return {};
340
+ }
341
+
342
+ /**
343
+ * Adapter-owned verify layer. Core verify is framework-neutral; the one coupling it cannot judge is an
344
+ * island reaching into the ocean's own framework scope. Discover the adapter the island imports (from
345
+ * element.ts) and hand its verify contribution the STRUCTURED coupling we extract by AST — the CLI owns
346
+ * the parsing (it has ts-morph), the adapter owns the semantics.
347
+ */
348
+ export async function adapterChecks(report, kebab, componentPath) {
349
+ const elementPath = paths.elementFile(kebab);
350
+ if (!existsSync(elementPath)) return;
351
+ const text = readFileSync(elementPath, 'utf8');
352
+ // Discovery is by the adapter the island IMPORTS — right for AngularJS, where the mount point is
353
+ // adapter-specific. A React host's mount point imports no adapter (the adapter lives at the
354
+ // composition root), so fall back to the configured host: the coupling is a property of where the
355
+ // island is going, not of what element.ts happens to import.
356
+ const pkgs = [...text.matchAll(/from\s+['"](@motujs\/adapter-[\w-]+)['"]/g)].map((m) => m[1]);
357
+ const specifier = pkgs.map((p) => ADAPTER_VERIFY[p]).find(Boolean) ?? HOST_ADAPTER_VERIFY[HOST];
358
+ if (!specifier) return; // no adapter ships a verify contribution for this island/host
359
+ const coupling = extractCoupling(elementPath);
360
+ // Hand over the component source too: some host boundaries (Next's server/client split) live in the
361
+ // component, not in the mount point. The CLI reads; the adapter judges.
362
+ const source = componentPath && existsSync(componentPath) ? readFileSync(componentPath, 'utf8') : undefined;
363
+ const graph = componentPath ? reachableAppSources(componentPath, { aliased: lagoonAliases() ?? [] }) : [];
364
+ try {
365
+ const mod = await import(specifier);
366
+ for (const f of mod.checkCoupling({ coupling, source, elementSource: text, graph })) report[f.level](f.check, f.msg);
367
+ } catch (err) {
368
+ // INCONCLUSIVE, NOT A WARNING. A warning does not affect the exit code, so an adapter that failed
369
+ // to load meant its checks silently did not run and the island still reported PASS — the exact
370
+ // case exit 2 exists for ("a check could not run — retry, do NOT repair"). It is an environment
371
+ // failure, not a finding about the island.
372
+ report.inconclusive('adapter-verify', `could not run ${specifier}: ${String(err?.message || err)} — the ${specifier} checks did not run`);
373
+ }
374
+ }
375
+
376
+ /**
377
+ * axe, per declared scenario, scoped to the island's own subtree.
378
+ *
379
+ * Serious violations fail; the rest are reported. The line is `impact`: 'critical' and 'serious' are
380
+ * axe's own words for "a person cannot use this", which is not a matter of taste.
381
+ */
382
+ export async function a11yCheck(report, tag, kebab, port) {
383
+ let findings;
384
+ try {
385
+ findings = await step('a11y', async () => axeLagoon({ tag, port, scenarios: await islandScenarios(kebab) }));
386
+ } catch (err) {
387
+ report[environmentalCause(err) ? 'inconclusive' : 'warn']('a11y', `could not run axe: ${err.message}`);
388
+ return;
389
+ }
390
+ const policy = lagoonA11y();
391
+ const kept = findings.filter((f) => !policy.ignore.has(f.id));
392
+ if (!kept.length) {
393
+ report.ok('a11y', `no axe violations in any declared scenario${policy.ignore.size ? ` (${policy.ignore.size} rule(s) ignored by config)` : ''}`);
394
+ return;
395
+ }
396
+ // 'never' (the default) reports everything and fails on nothing; 'serious' and 'critical' promote at
397
+ // that severity and above.
398
+ const rank = { minor: 0, moderate: 1, serious: 2, critical: 3 };
399
+ const bar = policy.fail === 'never' ? Infinity : rank[policy.fail] ?? Infinity;
400
+ const say = (f) => `${f.impact}: ${f.help} — ${f.where || '?'} (${f.nodes} node(s), scenario "${f.scenario}")`;
401
+ for (const f of kept) {
402
+ if ((rank[f.impact] ?? 0) >= bar) report.error('a11y', say(f));
403
+ else report.warn('a11y', say(f));
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Scenario seeds have to cross into the browser, and the crossing is JSON.
409
+ *
410
+ * A `Set`, a `Map` or a function in a seed arrives as `{}` — so the island renders against a value of
411
+ * the wrong SHAPE. When that throws (`favoriteIds.has is not a function`), the mount dies and EVERY
412
+ * scenario renders empty, which the differentiation check then reports as "scenarios rendered
413
+ * identically" and the responsive check as "renders nothing" — three misleading findings, none of them
414
+ * naming the cause. Name it here instead, once, before those checks run.
415
+ *
416
+ * The fix is always at the component: take the ITERABLE, not the Set, and rebuild it inside.
417
+ *
418
+ * A `Date` USED TO BE ON THAT LIST and no longer is: the evidence hop marks and revives them
419
+ * (lib/evidence-json.mjs), because a seed describing anything scheduled is made of dates and telling
420
+ * an author to strip them is telling them not to seed their own domain.
421
+ *
422
+ * IT WALKS THE WHOLE SEED. Looking only at top-level values is what let this class through for as long
423
+ * as it did: nobody seeds a bare Set, they seed a ROW that contains one — and what the author sees
424
+ * instead of this message is the three misleading findings above. Found on a seeded session object
425
+ * whose dates were two levels down, in a project whose whole domain is meetings.
426
+ */
427
+ export async function seedTransportCheck(report, kebab) {
428
+ const scenarios = await islandScenarios(kebab);
429
+ if (!scenarios.length) return;
430
+ // Either the real object (when the evidence was loaded in-process) or the marker the harness left
431
+ // where one used to be (when it came back through JSON). Both, because both loaders are live.
432
+ const kind = (v) =>
433
+ v instanceof Set ? 'Set' : v instanceof Map ? 'Map' : typeof v === 'function' ? 'function' : lostMarker(v);
434
+ /** Every untransportable value inside `value`, as `path (Kind)`. Cycle-safe; arrays keep their index. */
435
+ const walk = (value, path, seen, out) => {
436
+ const k = kind(value);
437
+ if (k) {
438
+ out.push(`${path} (${k})`);
439
+ return;
440
+ }
441
+ if (!value || typeof value !== 'object' || value instanceof Date) return;
442
+ if (seen.has(value)) return;
443
+ seen.add(value);
444
+ if (Array.isArray(value)) {
445
+ value.forEach((v, i) => walk(v, `${path}[${i}]`, seen, out));
446
+ return;
447
+ }
448
+ for (const [key, v] of Object.entries(value)) walk(v, `${path}.${key}`, seen, out);
449
+ };
450
+ const bad = [];
451
+ for (const s of scenarios) {
452
+ for (const [key, value] of Object.entries(scenarioInputs(s))) {
453
+ const found = [];
454
+ walk(value, key, new Set(), found);
455
+ for (const f of found) bad.push(`${s.name ?? '(unnamed)'} → ${f}`);
456
+ }
457
+ }
458
+ if (bad.length) {
459
+ report.error(
460
+ 'seed-transport',
461
+ `seed value(s) that do not survive the trip into the browser: ${bad.join(', ')} — they arrive as {}, ` +
462
+ `which breaks EVERY scenario, not just this one. Take an iterable/plain value in the component and rebuild the Set inside.`,
463
+ );
464
+ } else {
465
+ // The count goes in `seen`, not the message: that is what makes `report.ok`'s "0 examined is a
466
+ // skip" rule apply, so a run with no scenarios reports a SKIP instead of a green tick over nothing.
467
+ report.ok('seed-transport', 'every scenario seed crosses into the browser intact', { n: scenarios.length, of: 'scenario seed(s)' });
468
+ }
469
+ }
470
+
471
+ /**
472
+ * The values an island's scenarios never show it.
473
+ *
474
+ * `data-flow` proves the scenarios DIFFER; nothing asks whether they differ in the ways that matter.
475
+ * Two classes, both read from the VALUES the scenarios supply rather than from types, so this needs no
476
+ * type-checker and says nothing about a prop no scenario sets:
477
+ * - a prop always given a non-empty array — the empty case is the one components forget;
478
+ * - a boolean prop that only ever takes one value — the other branch is never rendered.
479
+ *
480
+ * WHAT IT DOES NOT CATCH, stated because the case that prompted it is exactly this. An unguarded array
481
+ * access reachable only with `compactMode: true` AND an empty list survived this check, and rightly:
482
+ * each prop is well covered on its own — the region has an empty-list scenario and both values of the
483
+ * boolean — and the hole is the CROSSING nothing renders. Per-prop coverage cannot see a per-pair gap,
484
+ * and checking every pair over eight inputs would report more combinations than anyone would read.
485
+ * The message says "cross it with the other inputs" because that is the part a human still owns.
486
+ *
487
+ * Deliberately not general property testing. These two are cheap, common and specific; everything
488
+ * beyond them is a research project that would drown the report.
489
+ */
490
+ export function inputCoverageCheck(report, scenarios) {
491
+ if (!Array.isArray(scenarios) || scenarios.length === 0) return;
492
+ const seen = new Map();
493
+ for (const sc of scenarios) {
494
+ for (const [prop, value] of Object.entries(sc?.seed || sc?.afterMount ? scenarioInputs(sc) : sc?.props ?? {})) {
495
+ if (!seen.has(prop)) seen.set(prop, []);
496
+ seen.get(prop).push(value);
497
+ }
498
+ }
499
+ if (!seen.size) return;
500
+
501
+ const gaps = [];
502
+ for (const [prop, values] of seen) {
503
+ if (values.every((v) => Array.isArray(v)) && !values.some((v) => v.length === 0)) {
504
+ gaps.push(`${prop} is never seeded empty`);
505
+ continue;
506
+ }
507
+ if (values.every((v) => typeof v === 'boolean') && new Set(values).size === 1) {
508
+ gaps.push(`${prop} is only ever seeded ${values[0]}`);
509
+ }
510
+ }
511
+ if (!gaps.length) {
512
+ report.ok('input-coverage', 'every seeded input is shown in more than one shape', {
513
+ n: seen.size,
514
+ of: 'seeded input(s)',
515
+ });
516
+ return;
517
+ }
518
+ report.warn(
519
+ 'input-coverage',
520
+ `${gaps.length}/${seen.size} seeded input(s) are only ever seeded one way: ${gaps.join(', ')} — the state ` +
521
+ `nothing renders is the one that breaks. Add a scenario for it, and cross it with the other inputs ` +
522
+ `rather than varying one at a time`,
523
+ );
524
+ }
525
+
526
+ /** An island's declared scenarios, loaded from its evidence file (node strips the types). */
527
+ export async function islandScenarios(kebab) {
528
+ const file = paths.fixturesFile(kebab);
529
+ if (!existsSync(file)) return [];
530
+ try {
531
+ const mod = await import(`file://${file}?t=${Date.now()}`);
532
+ const scenarios = Array.isArray(mod.scenarios) ? mod.scenarios : [];
533
+ // A plain node import cannot resolve a `.js` specifier that points at a `.ts` sibling — which is
534
+ // the convention every file here uses. It THREW, this returned [], and the island quietly lost
535
+ // every scenario: `data-flow` still saw five (it loads through tsx) while `responsive` reported
536
+ // one. Two loaders for one file, disagreeing in silence. Cross-check against the tsx loader and
537
+ // take the fuller answer, so evidence can import a shared module like any other code.
538
+ const viaTsx = readScenarios(file);
539
+ return viaTsx.length > scenarios.length ? viaTsx : scenarios;
540
+ } catch {
541
+ return readScenarios(file);
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Renders at every declared width, and fits.
547
+ *
548
+ * An error, not a warning: a page the member has to scroll sideways is broken, and the project rule
549
+ * ("always implement mobile AND desktop") had no enforcement at all until this.
550
+ */
551
+ export async function responsiveCheck(report, tag, kebab, port) {
552
+ const viewports = lagoonViewports();
553
+ if (!viewports.length) return;
554
+ let measured;
555
+ try {
556
+ measured = await step('responsive', async () =>
557
+ responsiveLagoon({ tag, port, viewports, scenarios: await islandScenarios(kebab) }));
558
+ } catch (err) {
559
+ report.warn('responsive', `could not measure viewports: ${err.message}`);
560
+ return;
561
+ }
562
+ const overflowing = measured.filter((m) => m.overflow > 1);
563
+ // Overflow is an error — a page the member has to pan sideways is broken. Rendering nothing is a
564
+ // WARNING: an island's empty state can legitimately be empty, and only the author knows which.
565
+ if (overflowing.length) {
566
+ report.error(
567
+ 'responsive',
568
+ `overflows horizontally at ${overflowing
569
+ .map((m) => `${m.name} (${m.width}px, +${m.overflow}px${m.scenario === 'default' ? '' : `, "${m.scenario}"`})`)
570
+ .join(', ')}`,
571
+ );
572
+ }
573
+ if (measured.every((m) => !m.rendered)) {
574
+ report.warn('responsive', 'renders nothing at any declared viewport, in any scenario — is there evidence that shows it?');
575
+ }
576
+ if (!overflowing.length && measured.some((m) => m.rendered)) {
577
+ const widths = [...new Set(measured.map((m) => `${m.name} ${m.width}px`))].join(', ');
578
+ const states = new Set(measured.map((m) => m.scenario)).size;
579
+ report.ok('responsive', `fits every declared viewport (${widths}) in ${states} scenario(s)`);
580
+ }
581
+ }