@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,135 @@
1
+ // `motu island verify` — the command and the per-island run `check` composes.
2
+ // Split out of commands/verify.mjs; verify.mjs re-exports the public surface.
3
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
4
+ import { listIslands } from '../../lib/islands.mjs';
5
+ import { LEGACY_FIT, islandComponentExport, islandComponentPath, names, paths } from '../../lib/util.mjs';
6
+ import { a11yCheck, adapterChecks, inputCoverageCheck, readScenarios, responsiveCheck, runtimeCheckBrowser, runtimeCheckFast, runtimeDifferentiationCheck, runtimeErrorCheck, seedTransportCheck } from './island-runtime.mjs';
7
+ import { configChecks, cssChecks, islandOwnCss, staticChecks } from './island-static.mjs';
8
+ import { reportBrowserOverride } from './reach.mjs';
9
+ import { makeReport, printReport, printSweep, setProgressScope, setVerbose, summaryOf, sweepExitCode, verifyExitCode } from './report.mjs';
10
+
11
+ export async function verifyCommand(argv) {
12
+ const name = argv._[0];
13
+ if (!name && !argv.all) {
14
+ console.error('usage: motu island verify <name|--all> [--no-runtime] [--fast] [--standalone] [--json]');
15
+ process.exit(2);
16
+ }
17
+
18
+ // --all: every island in the project, one after another. Sequential on purpose — each runtime check
19
+ // boots a lagoon on its own port, and a CI box does not thank you for fourteen browsers at once.
20
+ if (argv.all) {
21
+ const islands = listIslands(paths.islandsDir);
22
+ const results = [];
23
+ for (const island of islands) results.push(await runIslandVerify(argv, island.kebab));
24
+ if (argv.json) {
25
+ console.log(JSON.stringify({ islands: results.map(summaryOf) }, null, 2));
26
+ } else {
27
+ printSweep('motu island verify — all islands', results);
28
+ }
29
+ process.exit(sweepExitCode(results));
30
+ }
31
+
32
+ const result = await runIslandVerify(argv, name);
33
+ if (argv.json) {
34
+ console.log(JSON.stringify({ island: result.kebab, tag: result.tag, pass: result.errors.length === 0, findings: result.report.findings }, null, 2));
35
+ } else {
36
+ printReport(result.report, `motu island verify — ${result.kebab} (${result.tag})`, result.errors, result.warns);
37
+ }
38
+ process.exit(verifyExitCode(result));
39
+ }
40
+
41
+ /** One island's checks, as data — printing and exiting belong to the caller (see `--all`). */
42
+ export async function runIslandVerify(argv, name) {
43
+ setVerbose(argv?.verbose);
44
+ const { pascal, kebab, tag } = names(name);
45
+ setProgressScope(kebab);
46
+ // Follow element.ts to the component it mounts: on a React host the island wraps a component the
47
+ // app already owns, which does not live under ui/.
48
+ const componentPath = islandComponentPath(kebab, pascal);
49
+ // What the component is actually CALLED where it lives — see islandComponentExport.
50
+ const componentName = islandComponentExport(kebab, pascal);
51
+ const uiDir = paths.uiDir(kebab);
52
+ const isReactIsland = existsSync(componentPath);
53
+ // An AngularJS island has no React `.tsx` — its component is a `*.ng.ts` spec (in ui/<kebab>/).
54
+ const isAdapterIsland =
55
+ !isReactIsland &&
56
+ existsSync(uiDir) &&
57
+ readdirSync(uiDir).some((f) => f.endsWith('.ng.ts'));
58
+ const report = makeReport();
59
+ if (!isReactIsland && !isAdapterIsland) {
60
+ report.error('component', `no island component at ${paths.rel(componentPath)}`);
61
+ return { kebab, tag, report, errors: report.findings, warns: [] };
62
+ }
63
+
64
+ let componentProps = null;
65
+ let resolvedTag = tag;
66
+ if (isReactIsland) {
67
+ componentProps = staticChecks(report, componentPath, componentName);
68
+ resolvedTag = configChecks(report, kebab, componentName, tag, argv.standalone, componentProps, componentPath) ?? tag;
69
+ } else {
70
+ // A SKIP, NOT AN OK. This says the React-only checks did not run — which is the report model's own
71
+ // definition of a skip ("a rule silently reported as passing is indistinguishable from a rule that
72
+ // ran"). Reported as `ok` it added a green tick for checks that never happened.
73
+ report.skip('adapter-island', `AngularJS island (no React component) — the React checks do not apply; running config-lite + adapter + runtime`);
74
+ }
75
+
76
+ // Adapter-owned checks (e.g. the AngularJS host-scope contract) fold in regardless of island kind.
77
+ await adapterChecks(report, kebab, isReactIsland ? componentPath : null);
78
+
79
+ // CSS lint of the island's OWN stylesheet if it owns one (opt-in; the shared sheet is linted at
80
+ // region scope by `motu archipelago verify`).
81
+ const ownCss = islandOwnCss(kebab);
82
+ if (ownCss) cssChecks(report, readFileSync(ownCss, 'utf8'), paths.rel(ownCss));
83
+
84
+ // OPT-IN, not default. `verify` answers "has this island drifted from what it declares?", and that
85
+ // question is static: props against the component, contract against the generated one, imports,
86
+ // registration. Everything below drives a real browser once per scenario × viewport, which is a
87
+ // different question ("does it still behave?") and a different cost — a few seconds per island, so a
88
+ // migrated project pays minutes for a check most edits do not need. It belongs in `--runtime`
89
+ // (and in `motu check --runtime`), where someone asked for it.
90
+ if (argv.runtime === true || argv.audit === true) {
91
+ // Randomize the base port so parallel/back-to-back verifies don't collide on a strict port.
92
+ let port = 5300 + Math.floor(Math.random() * 400);
93
+ const fixturesPath = existsSync(paths.fixturesFile(kebab)) ? paths.fixturesFile(kebab) : '';
94
+ // Reading the evidence costs a tsx spawn, so this rides with the tiers that already load it rather
95
+ // than slowing the static sweep. It needs no browser and runs under `--fast` too.
96
+ if (fixturesPath) inputCoverageCheck(report, readScenarios(fixturesPath));
97
+ // The island lane drives a browser too, unless --fast put it under happy-dom.
98
+ if (!argv.fast) reportBrowserOverride(report);
99
+ // 'legacy' fit re-mounts the island under the host's legacy skin. Skip it where there is no
100
+ // legacy skin — it would verify the same thing twice and double the wall clock.
101
+ for (const fit of LEGACY_FIT ? ['native', 'legacy'] : ['native']) {
102
+ if (argv.fast) {
103
+ await runtimeCheckFast(report, resolvedTag, fixturesPath, fit);
104
+ } else {
105
+ await runtimeCheckBrowser(report, resolvedTag, fit, port++);
106
+ }
107
+ }
108
+ // Error-resilience mount (real browser only; the happy-dom --fast path can't host the lagoon).
109
+ if (!argv.fast) await runtimeErrorCheck(report, resolvedTag, port++);
110
+ // A seed only helps if it SURVIVES the trip. Checked before the checks that use one, because
111
+ // every one of them fails misleadingly otherwise.
112
+ if (!argv.fast) await seedTransportCheck(report, kebab);
113
+ // Data-flow differentiation (opt-in via declared `scenarios`).
114
+ await runtimeDifferentiationCheck(report, resolvedTag, fixturesPath, port++, Boolean(argv.fast));
115
+ // Every declared viewport — the phone included, which nothing checked before.
116
+ // AUDIT, not dev loop. Layout at every viewport and axe over every scenario answer "is this fit
117
+ // for people", which changes when the RENDERING changes — not on the edit that moved a key. They
118
+ // are the two most expensive per-island checks and they belong where their answer is acted on:
119
+ // before integrating, and in CI as a non-regression gate. `--audit` asks for them.
120
+ if (!argv.fast && argv.audit) await responsiveCheck(report, resolvedTag, kebab, port++);
121
+ // Accessibility, in the browser that is already open.
122
+ if (!argv.fast && argv.audit) await a11yCheck(report, resolvedTag, kebab, port++);
123
+ if (!argv.fast && !argv.audit) {
124
+ report.skip('audit', 'responsive + a11y not run — they are the `--audit` gate (before integrating, and in CI)');
125
+ }
126
+ }
127
+
128
+ return {
129
+ kebab,
130
+ tag: resolvedTag,
131
+ report,
132
+ errors: report.findings.filter((f) => f.level === 'error'),
133
+ warns: report.findings.filter((f) => f.level === 'warn'),
134
+ };
135
+ }
@@ -0,0 +1,426 @@
1
+ // What an island or region ASKS for — declared reach against observed calls, and the operation universe.
2
+ // Split out of commands/verify.mjs; verify.mjs re-exports the public surface.
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { coversEffect, isDataKind, readEffectEntries } from '../../lib/effects.mjs';
5
+ import { readRegions } from '../../lib/eject.mjs';
6
+ import { listIslands } from '../../lib/islands.mjs';
7
+ import { askKind, indexOperations, readOperationNames, resolveOperation } from '../../lib/operations.mjs';
8
+ import { REPO_ROOT, names, paths } from '../../lib/util.mjs';
9
+ import { browserOverrideNote } from '../../playwright-lagoon.mjs';
10
+ import { declaredSources } from './region-sources.mjs';
11
+
12
+ /** A data entry — the kinds the runtime can observe, as opposed to a module or a host-scope name. */
13
+ export const isDataReach = isDataKind;
14
+
15
+ /**
16
+ * WHAT EACH OWNER SAYS IT REACHES, keyed the way `DataReach.by` keys what it observed.
17
+ *
18
+ * One concept, declared where the reaching happens. An island that hits a backend directly declares it
19
+ * in its own `contract.effects`, beside the host modules it stands down — a data entry is what an
20
+ * module entry USED to imply, before wire mocking pushed the stub below the import graph and the
21
+ * module check (direct imports only) stopped being able to see it. A declared SOURCE reads inside a
22
+ * channel, at region level and outside any island's window, so it declares its own `reaches` and the
23
+ * runtime attributes to it directly — charging a source's tables to whichever island was rendering
24
+ * would report a correct declaration as a violation.
25
+ */
26
+ export function declaredReach({ tag = null, regionId = null } = {}) {
27
+ const out = {};
28
+ // FROM THE ISLAND FILE, not from `contracts.generated.ts`.
29
+ //
30
+ // The generated contract's `effects` is DERIVED — read from the component's own imports — so it only
31
+ // ever holds module entries. A `{ table: … }` is a decision nobody can infer from an import, so it is
32
+ // hand-written in the island file and is invisible here otherwise: this read the generated map and
33
+ // would have reported every island's data reach as undeclared, forever, while the declaration sat in
34
+ // the file next to it. Caught by round-tripping one through `motu sync`.
35
+ const byTag = new Map();
36
+ for (const i of listIslands(paths.islandsDir)) {
37
+ const text = readFileSync(i.element, 'utf8');
38
+ const tag = (text.match(/\btag:\s*'([^']+)'/) ?? text.match(/\bisland\(\s*'([^']+)'/))?.[1] ?? `${names(i.kebab).tag}`;
39
+ const declared = text.match(/effects:\s*\[([\s\S]*?)\]/)?.[1];
40
+ if (declared !== undefined) byTag.set(tag, readEffectEntries(declared));
41
+ }
42
+ const addIsland = (t) => {
43
+ const entries = (byTag.get(t) ?? []).filter(isDataReach);
44
+ if (entries.length) out[`island:${t}`] = [...entries].sort();
45
+ };
46
+ if (tag) addIsland(tag);
47
+ if (regionId) {
48
+ const region = readRegions(paths.archipelagosDir).find((r) => r.id === regionId);
49
+ for (const i of region?.islands ?? []) if (i.element) addIsland(i.element);
50
+ for (const [name, src] of Object.entries(declaredSources(regionId))) {
51
+ const entries = (src.reaches ?? []).filter(isDataReach);
52
+ if (entries.length) out[`source:${name}`] = [...entries].sort();
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /**
59
+ * Observed reach against declared reach — the half `data-reach` could not do while it was a readout.
60
+ *
61
+ * WARNINGS, both ways, and deliberately not errors: every project that adopts this lights up on the
62
+ * day it lands, and the module half of `effects` — the declaration this extends — is warnings-only for exactly
63
+ * that reason. An entry observed under an owner that declared nothing at all is NOT reported: the
64
+ * declaration is opt-in per island and per source, and reporting its absence would make adopting the
65
+ * feature indistinguishable from failing it.
66
+ */
67
+ export function reportReachDrift(report, observed, declared, label) {
68
+ const owners = Object.keys(declared);
69
+ if (!owners.length) return;
70
+ let clean = 0;
71
+ for (const owner of owners) {
72
+ const saw = [...new Set(observed?.[owner] ?? [])].sort();
73
+ const said = declared[owner];
74
+ // `{ table: 'shots' }` covers every operation on that table and `{ route: '/api/x' }` every method
75
+ // on that route — naming a dependency without pinning how it is used is a legitimate declaration.
76
+ const missing = saw.filter((e) => !said.some((d) => coversEffect(d, e)));
77
+ const stale = said.filter((d) => !saw.some((e) => coversEffect(d, e)));
78
+ if (missing.length) {
79
+ report.warn('data-reach', `${owner} reaches undeclared: ${missing.join(', ')} (${label}) — add it to ${owner.startsWith('source:') ? '`reaches`' : '`contract.effects`'}`);
80
+ }
81
+ if (stale.length) {
82
+ // NOT "declares what it does not use" — that is the module half's wording, and it can say it
83
+ // because it reads the component's imports STATICALLY. This half is observed at runtime, so an
84
+ // unreached declaration has two causes and only one of them is a stale claim: the review
85
+ // region's own accept route is declared, real, and simply not driven by any flow. Say what was
86
+ // actually seen and name both readings, or the honest answer to a true warning is to delete a
87
+ // correct declaration.
88
+ report.warn(
89
+ 'data-reach',
90
+ `${owner} declares reach that nothing exercised: ${stale.join(', ')} (${label}) — either the ` +
91
+ `declaration is stale, or no flow drives that path`,
92
+ );
93
+ }
94
+ if (!missing.length && !stale.length) clean++;
95
+ }
96
+ if (clean) report.ok('data-reach', `${clean} owner(s) reach exactly what they declare (${label})`, clean);
97
+ }
98
+
99
+ /** `1 portal` / `2 portals` — the body-level nodes an island's own mount portaled and a check counted. */
100
+ export function portalNote(n) {
101
+ return `${n} portal${n === 1 ? '' : 's'}`;
102
+ }
103
+
104
+ /** Map the runtime result's diagnostics + remount signal onto report findings (shared by both engines). */
105
+ export function reportRuntimeDiagnostics(report, r, label, declared = null) {
106
+ const diagnostics = r?.diagnostics ?? [];
107
+ if (diagnostics.length === 0) {
108
+ report.ok('no-console-errors', `no console errors / unhandled rejections (${label})`);
109
+ } else {
110
+ report.error('no-console-errors', `console error / unhandled rejection (${label}): ${diagnostics[0]}`);
111
+ }
112
+
113
+ // Both renders include what the island portaled (lib/island-render.mjs), and the line says so.
114
+ const portaled = r?.portals ? ` · ${portalNote(r.portals)}` : '';
115
+ if (r?.remountIdentical === true) {
116
+ report.ok('remount-stable', `re-mount produces identical output (${label}${portaled})`);
117
+ } else if (r?.remountIdentical === false) {
118
+ report.error('remount-stable', `re-mount changed the output (${label}${portaled}) — likely accidental module-level state`);
119
+ }
120
+ // remountIdentical == null → the island never rendered; the mount error already covers it.
121
+
122
+ // Only present when the lagoon's fake fetch (`@motujs/runtime/postgrest-fetch`) is in play — a project
123
+ // still on module-alias stubs never populates this, so `undefined` skips it rather than reporting a
124
+ // false pass for a mechanism that never ran. `seen` is the TOTAL request count, not the unscoped
125
+ // one: a run that made zero requests has zero unscoped requests too, and `report.ok`'s own "0 seen
126
+ // is a skip, not an ok" rule is what tells those two apart, the same as every other check here.
127
+ // IS THIS ISLAND STANDING ON ITS STUBS? Two halves of one question, under one id.
128
+ //
129
+ // They were `fixture-coverage` and `network-sealed`, and the second's own comment called it "the
130
+ // counterpart" to the first: one asks whether the requests the fake fetch SAW were declared, the
131
+ // other whether any request got PAST the stubs entirely. Both are errors, both mean the island is
132
+ // standing on something that is not there, and reading them as separate results invited fixing one.
133
+ //
134
+ // The preconditions differ and that is why this is not a concatenation: the escape half applies to
135
+ // EVERY runtime run, including a project still on module-alias stubs where no fake fetch is
136
+ // installed and `fixtureCoverage` is undefined. So each half reports only when it has something to
137
+ // say, and the single `ok` names whichever halves actually held.
138
+ if (r?.fixtureCoverage !== undefined || r?.networkEscapes !== undefined) {
139
+ const unscoped = r?.fixtureCoverage?.unscoped ?? [];
140
+ const escapes = r?.networkEscapes ?? [];
141
+ // THE THIRD HALF, and the one that made the name only two-thirds true. `escapes` counts requests
142
+ // that left for a non-loopback host; an unstubbed APP route is SAME-ORIGIN, so it 404s against the
143
+ // dev server and neither of the other two ever saw it. Recorded at the delegate seam inside
144
+ // `installFakeFetch`, which is the one place that knows a request was not claimed.
145
+ const unanswered = r?.fixtureCoverage?.unanswered ?? [];
146
+ if (unscoped.length) {
147
+ const first = unscoped[0];
148
+ report.error(
149
+ 'stubs-sealed',
150
+ `${unscoped.length} request(s) matched no declared table/fixture (${label}): ` +
151
+ `${first.method} ${first.path} — ${first.reason}`,
152
+ );
153
+ }
154
+ if (escapes.length) {
155
+ const first = escapes[0];
156
+ const hosts = [...new Set(escapes.map((e) => { try { return new URL(e.url).host; } catch { return e.url; } }))];
157
+ report.error(
158
+ 'stubs-sealed',
159
+ `${escapes.length} request(s) escaped the lagoon to ${hosts.join(', ')} (${label}): ` +
160
+ `${first.method} ${first.url} — a host module reached a real backend, so this island is not ` +
161
+ `standing on its stubs. The failure is normally invisible: the island catches it and renders empty`,
162
+ );
163
+ }
164
+ if (unanswered.length) {
165
+ const first = unanswered[0];
166
+ report.error(
167
+ 'stubs-sealed',
168
+ `${unanswered.length} request(s) reached the dev server instead of a stub (${label}): ` +
169
+ `${first.method} ${first.url} — ${first.why}. No fixture claimed it, so the island is standing on ` +
170
+ `nothing. Add the path to \`appRoutes\` (and a fixture for it), or point \`baseUrl\` at its origin`,
171
+ );
172
+ }
173
+ if (!unscoped.length && !escapes.length && !unanswered.length) {
174
+ // `seen` is the FAKE-FETCH request count, not the escape count: zero escapes proves nothing on
175
+ // its own (an island that made no backend call at all also escapes nothing). Passing the count
176
+ // of requests actually answered lets `report.ok`'s "0 examined is a skip" rule tell the two
177
+ // apart — which is precisely "is the fake fetch wired, or is this check vacuous?".
178
+ const answered = r?.fixtureCoverage !== undefined;
179
+ report.ok(
180
+ 'stubs-sealed',
181
+ answered
182
+ ? `every request matched a declared table/fixture, and nothing escaped the lagoon (${label})`
183
+ : `nothing escaped the lagoon — every backend call was answered locally (${label})`,
184
+ r?.fixtureCoverage?.seen,
185
+ );
186
+ }
187
+ }
188
+
189
+ // WHAT DOES THIS ISLAND NEED FROM THE BACKEND? Observed, not declared — see `DataReach` in
190
+ // `@motujs/runtime/postgrest-fetch` for why this exists at all: mocking at the wire made an island's
191
+ // data dependency transitive, so the module half of `effects` stopped being able to see it. A
192
+ // table-and-RPC list is a better answer than the module name it replaces, and it is already the
193
+ // vocabulary `.assay/operations.json` uses — which is where a motu↔assay drift check would compare.
194
+ //
195
+ // IT NOW HAS SOMETHING TO COMPARE AGAINST. This was a readout, on the argument that "declaring a
196
+ // per-island table list before assay's comparison exists would invent a format that then has to
197
+ // survive contact with it". What changed is the format question: the entries below are already
198
+ // assay's granularity (tables read/written, functions called), so the declaration is not an
199
+ // invention — and the readout alone left a real hole. An island that reached a table nobody expected
200
+ // failed `fixture-coverage`, and the fix was to add the table to the lagoon's `tables:` — so the
201
+ // FIXTURE SET was doing double duty as the declaration, and widening it was a one-line edit made by
202
+ // whoever was trying to get to green. `reportReachDrift` is the missing half; it stays a WARNING.
203
+ if (r?.fixtureCoverage?.reach !== undefined) {
204
+ const { tables, rpcs, functions = [], routes = [] } = r.fixtureCoverage.reach;
205
+ const tableNames = Object.keys(tables).sort();
206
+ const parts = [
207
+ ...tableNames.map((t) => `${t}(${[...tables[t]].sort().join('/')})`),
208
+ ...[...rpcs].sort().map((fn) => `rpc:${fn}`),
209
+ // Named apart from `rpc:` because they are different things to whatever compares this against
210
+ // a backend ledger: an edge function is its own deployable, an app route its own handler.
211
+ ...[...functions].sort().map((fn) => `fn:${fn}`),
212
+ ...[...routes].sort().map((r2) => `route:${r2}`),
213
+ ];
214
+ report.ok('data-reach', `reaches ${parts.join(', ')} (${label})`, {
215
+ n: tableNames.length + rpcs.length + functions.length + routes.length,
216
+ of: 'backend dependencies',
217
+ });
218
+ if (declared) reportReachDrift(report, r.fixtureCoverage.reach.by, declared, label);
219
+ }
220
+
221
+ }
222
+
223
+ /**
224
+ * WHERE THE REGION'S INPUT CAME FROM — through every door, in one list.
225
+ *
226
+ * The lagoon replaces host modules so completely that nothing shows a fetch happened: no request, no
227
+ * network row, and the lens shows only the keys that resulted. Looking at a region and seeing no HTTP
228
+ * at all is accurate and tells you nothing about whether an island asked for anything.
229
+ *
230
+ * An island's I/O leaves by one of THREE doors, and "all island I/O goes through the contract" is a
231
+ * rule about an island's own file (`no-bare-fetch` reads the component, not its import graph) rather
232
+ * than about the app: a service the island imports may read a table, and `contract.effects` has
233
+ * `{ table }`/`{ rpc }`/`{ route }` kinds precisely so that can be DECLARED. Each door used to be
234
+ * observed on its own — a traced stub here, `__motuDataReach` at the wire, and the contract's own
235
+ * `CallEvent`, which no check ever printed. One question, so one readout:
236
+ *
237
+ * ✓ provenance contract: shots.list() ×2 · host-module: fetchClubFeed(11) ×4 · wire: table:shots(select)
238
+ *
239
+ * Two things stay visible that nothing else here can see, and now for all three doors: an island that
240
+ * renders content while asking for NOTHING (its data came from somewhere it did not declare), and a
241
+ * dependency it declares but never reaches.
242
+ */
243
+ export function provenanceCheck(report, id, region, calls, outbound = []) {
244
+ // The unified ledger when the page carries one; the traced-calls list is the fallback for a lagoon
245
+ // built before it existed, so an older bundle still reports what it always did.
246
+ // `args ? name(args) : name` — a wire reach's declared form (`table:shots(select)`) has no argument
247
+ // half, and appending `()` to it would print an operation as an empty call.
248
+ const label = (o) => (o.args ? `${o.name}(${o.args})` : o.name);
249
+ const asks = outbound.length
250
+ ? outbound.map((o) => ({ via: o.via, label: label(o), owner: o.owner }))
251
+ : calls.map((c) => ({ via: 'host-module', label: `${c.fn}(${(c.args ?? []).join(', ')})`, owner: `island:${c.island ?? '?'}` }));
252
+ if (!asks.length) {
253
+ report.skip(
254
+ 'provenance',
255
+ 'no outbound calls observed — the islands asked for nothing through the contract, a traced host ' +
256
+ 'module or the wire. If that is wrong, wrap a stub export in `traced(module, fn, impl)`; ' +
257
+ 'without it the lagoon shows the result and never the question',
258
+ );
259
+ return;
260
+ }
261
+ // GROUPED BY DOOR, THE SAME GROUPING THE LENS SHOWS. Which one an ask left by is the thing a reader
262
+ // is deciding about — a contract call is answered by the transport, a wire reach by fixtures, a
263
+ // host-module call by a stub — and the two surfaces disagreeing about how the seams are grouped is
264
+ // how one of them ends up trusted and the other ignored.
265
+ const order = ['contract', 'host-module', 'wire'];
266
+ const groups = new Map();
267
+ for (const a of asks) {
268
+ const counts = groups.get(a.via) ?? new Map();
269
+ counts.set(a.label, (counts.get(a.label) ?? 0) + 1);
270
+ groups.set(a.via, counts);
271
+ }
272
+ const parts = [...groups]
273
+ .sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]))
274
+ .map(([via, counts]) => {
275
+ const shown = [...counts].map(([k, n]) => (n > 1 ? `${k} \u00d7${n}` : k));
276
+ return `${via}: ${shown.join(', ')}`;
277
+ });
278
+ // WHO ASKED, named — because "nobody asked for anything" was printed for months over a region whose
279
+ // SOURCE was asking constantly, and an owner list is what makes that impossible to print again.
280
+ const owners = [...new Set(asks.map((a) => a.owner))].sort();
281
+ const unowned = asks.filter((a) => a.owner === 'unattributed').length;
282
+ const by = owners.length ? ` \u2014 by ${owners.join(', ')}` : '';
283
+ // ASKED FOR, not "islands fetched": a source asks too, at region level, and calling every ask an
284
+ // island's is the same blind spot that dropped them from the lens.
285
+ report.ok('provenance', `asked for: ${parts.join(' \u00b7 ')}${by}`, { n: asks.length, of: 'outbound call(s)' });
286
+ if (unowned) {
287
+ report.warn(
288
+ 'provenance',
289
+ `${unowned} ask(s) attributed to nobody — the request left while no island's and no source's ` +
290
+ `window was open, so nothing can say which declaration should account for it`,
291
+ );
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Say it when the browser was not the one motu pinned.
297
+ *
298
+ * A WARNING rather than a note in passing: a build motu did not pin can render and behave differently
299
+ * from the one it did, so every runtime verdict below is worth slightly less than it looks and the
300
+ * run should say so where the findings are read. Warnings do not fail `motu check`, which is the
301
+ * right weight — using the browser a machine already has is a legitimate way to run, not a fault.
302
+ */
303
+ export function reportBrowserOverride(report) {
304
+ const note = browserOverrideNote();
305
+ if (note) {
306
+ report.warn(
307
+ 'browser',
308
+ `${note} — the runtime findings below hold for THAT build; a pinned run can still differ`,
309
+ );
310
+ }
311
+ }
312
+
313
+ /**
314
+ * The backend's list of operations, or null when the project has not pointed at one.
315
+ *
316
+ * Read once per verify run. An unreadable or empty file is an ERROR OBJECT rather than an empty
317
+ * list: an empty universe would make every ask unreachable, which is a wall of red that says the
318
+ * opposite of what happened and is exactly the failure this check exists to be the opposite of.
319
+ */
320
+ export function operationUniverse() {
321
+ if (!paths.operations) return null;
322
+ const rel = paths.operations.replace(`${REPO_ROOT}/`, '');
323
+ if (!existsSync(paths.operations)) return { error: `\`operations\` names ${rel}, which does not exist` };
324
+ const { names, error } = readOperationNames(readFileSync(paths.operations, 'utf8'));
325
+ if (error) return { error: `${rel} ${error}` };
326
+ if (!names.length) return { error: `${rel} holds no operation names` };
327
+ return { names, index: indexOperations(names), rel };
328
+ }
329
+
330
+ /**
331
+ * DOES THE THING THIS REGION ASKED FOR EXIST?
332
+ *
333
+ * `provenance` above records every ask and validates none of them — it cannot, because motu has no
334
+ * view of a backend. Given a list of the backend's own operations it can: the wire door records
335
+ * `route:<METHOD> <path>` and `fn:<name>`, which is an identifier an operation is keyed by, so an
336
+ * ask resolving to nothing is a region asking for something that is not there.
337
+ *
338
+ * ## Two directions, and only one of them is an error
339
+ *
340
+ * UNREACHABLE — an ask that resolves to no operation. An error: either the path is wrong, or an
341
+ * operation exists that the backend's own declarations have never covered. Both are real, and both
342
+ * are silent today.
343
+ *
344
+ * UNCOVERED — an operation this region DECLARES IT REACHES that no flow ever asked for. A warning,
345
+ * and narrowed hard: the universe is the whole backend, and a login region will never ask for an
346
+ * admin route, so reporting the universe per region would be wrong dozens of times on day one —
347
+ * which is how a check teaches people to ignore it. The narrowing uses motu's OWN existing
348
+ * declaration (`reaches` / `contract.effects`, via `declaredReach`), so both sides are artifacts the
349
+ * project already maintains and there is no third list to rot.
350
+ *
351
+ * ## What it says nothing about
352
+ *
353
+ * A contract call is `service.method` and a traced host module is a bare function name; neither says
354
+ * which backend thing answered, and a table or an rpc is a PostgREST identifier rather than an
355
+ * operation. Those are COUNTED AND NAMED rather than dropped — quietly ignoring the doors that
356
+ * cannot be checked is how this check would come to claim more than it examined.
357
+ */
358
+ export function operationReachCheck(report, outbound, universe, declared) {
359
+ if (!universe) {
360
+ report.skip(
361
+ 'operation-reach',
362
+ 'no `operations` in motu.config.json — set it to the backend\'s own list of operations (a path, ' +
363
+ 'e.g. ".assay/operations.json") and every ask this region makes is checked against it',
364
+ );
365
+ return;
366
+ }
367
+ if (universe.error) {
368
+ // COULD NOT RUN, not a finding: exit 2, retry, do not repair.
369
+ report.inconclusive('operation-reach', `${universe.error} — nothing was compared`);
370
+ return;
371
+ }
372
+
373
+ const asks = (outbound ?? []).filter((o) => o.via === 'wire');
374
+ const resolvable = asks.filter((o) => askKind(o.name));
375
+ const unresolvable = (outbound ?? []).length - resolvable.length;
376
+
377
+ const reached = new Set();
378
+ const unreachable = new Map();
379
+ for (const ask of resolvable) {
380
+ const hit = resolveOperation(ask.name, universe.index);
381
+ if (hit) reached.add(hit);
382
+ else unreachable.set(ask.name, (unreachable.get(ask.name) ?? new Set()).add(ask.owner));
383
+ }
384
+
385
+ for (const [name, owners] of unreachable) {
386
+ report.error(
387
+ 'operation-reach',
388
+ `${[...owners].sort().join(', ')} asked for \`${name}\`, which names no operation in ` +
389
+ `${universe.rel} — either the path is wrong, or the backend has an operation nothing declares`,
390
+ );
391
+ }
392
+
393
+ // NARROWED to what this region says it reaches. Everything else in the universe is out of scope
394
+ // for this region by construction, and saying so is what keeps the number believable.
395
+ //
396
+ // Taken as a PARAMETER rather than read in here, the way `reportReachDrift` takes its declarations:
397
+ // a check that goes to the filesystem for half its input cannot be driven by a test without a
398
+ // fixture project, and the half that decides whether a finding is reported is exactly the half
399
+ // worth driving.
400
+ const declaredOps = new Set();
401
+ for (const entries of Object.values(declared ?? {})) {
402
+ for (const entry of entries) {
403
+ if (!askKind(entry)) continue;
404
+ const hit = resolveOperation(entry, universe.index);
405
+ if (hit) declaredOps.add(hit);
406
+ }
407
+ }
408
+ const uncovered = [...declaredOps].filter((op) => !reached.has(op)).sort();
409
+ if (uncovered.length) {
410
+ report.warn(
411
+ 'operation-reach',
412
+ `declared as reached and never asked for: ${uncovered.join(', ')} — the declaration is a claim ` +
413
+ 'no flow has exercised, so either a flow is missing or the declaration is stale',
414
+ );
415
+ }
416
+
417
+ const aside = [
418
+ `${universe.names.length} operation(s) declared`,
419
+ unresolvable ? `${unresolvable} ask(s) name no operation (contract, host module, table or rpc)` : '',
420
+ ].filter(Boolean).join(' · ');
421
+ report.ok(
422
+ 'operation-reach',
423
+ `${reached.size}/${resolvable.length} ask(s) resolve to a declared operation — ${aside}`,
424
+ { n: resolvable.length, of: 'operation ask(s)' },
425
+ );
426
+ }