@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,271 @@
1
+ // `motu archipelago verify` — the command and the per-region 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 { readRegions } from '../../lib/eject.mjs';
5
+ import { conditionalSlots, exclusiveDeclared } from '../../lib/lagoon-declares.mjs';
6
+ import { paths } from '../../lib/util.mjs';
7
+ import { pageRenderLagoon, runArchipelagoLagoon } from '../../playwright-lagoon.mjs';
8
+ import { cssChecks } from './island-static.mjs';
9
+ import { declaredReach, reportBrowserOverride, reportRuntimeDiagnostics } from './reach.mjs';
10
+ import { frameIsPageCheck } from './region-composition.mjs';
11
+ import { archipelagoConfigChecks } from './region-config.mjs';
12
+ import { catalogueCheck, regionFlowCheck, renderCoverageCheck, wiringProbe, writesCoveredCheck } from './region-flows.mjs';
13
+ import { channelSourceCheck, exclusiveCheck, regionAuditCheck, regionRefCheck } from './region-sources.mjs';
14
+ import { environmentalCause, makeReport, printReport, printSweep, setProgressScope, setVerbose, step, summaryOf, sweepExitCode, verifyExitCode } from './report.mjs';
15
+ import { CHROMIUM_INSTALL } from '../../lib/framework.mjs';
16
+
17
+ export async function archipelagoVerifyCommand(argv) {
18
+ const id = argv._[0];
19
+ if (!id && !argv.all) {
20
+ console.error('usage: motu archipelago verify <id|--all> [--no-runtime] [--json]');
21
+ process.exit(2);
22
+ }
23
+
24
+ if (argv.all) {
25
+ const ids = existsSync(paths.archipelagosDir)
26
+ ? readdirSync(paths.archipelagosDir, { withFileTypes: true })
27
+ .filter((e) => e.isDirectory() && existsSync(paths.archipelagoFile(e.name)))
28
+ .map((e) => e.name)
29
+ : [];
30
+ const results = [];
31
+ for (const one of ids) results.push(await runArchipelagoVerify(argv, one));
32
+ if (argv.json) {
33
+ console.log(JSON.stringify({ archipelagos: results.map(summaryOf) }, null, 2));
34
+ } else {
35
+ printSweep('motu archipelago verify — all regions', results);
36
+ }
37
+ process.exit(sweepExitCode(results));
38
+ }
39
+
40
+ const result = await runArchipelagoVerify(argv, id);
41
+ if (argv.json) {
42
+ console.log(JSON.stringify({ archipelago: result.kebab, pass: result.errors.length === 0, findings: result.report.findings }, null, 2));
43
+ } else {
44
+ printReport(result.report, `motu archipelago verify — ${result.kebab}`, result.errors, result.warns);
45
+ }
46
+ process.exit(verifyExitCode(result));
47
+ }
48
+
49
+ export async function runArchipelagoVerify(argv, id) {
50
+ setVerbose(argv?.verbose);
51
+ setProgressScope(id);
52
+ const report = makeReport();
53
+ const hasLayout = archipelagoConfigChecks(report, id);
54
+ // Static, and project-wide in what it reads: a cycle is a property of the graph, not of one file.
55
+ if (existsSync(paths.archipelagoFile(id))) regionRefCheck(report, id);
56
+ if (existsSync(paths.archipelagoFile(id))) exclusiveCheck(report, id);
57
+
58
+ // The inbound seam: a channel that orchestrates must install the app's logic, not restate it.
59
+ const region = readRegions(paths.archipelagosDir).find((r) => r.id === id);
60
+ if (region) channelSourceCheck(report, id, region);
61
+
62
+ // Is what the lagoon draws the page's own code, or a second copy of it?
63
+ frameIsPageCheck(report, id, region);
64
+
65
+ // The other half of what a source is worth: a flow drives it through the screen, and the branches
66
+ // no screen tells apart need a test that drives it directly.
67
+
68
+ // Static, so an uncovered slot shows up in the fast loop rather than behind a browser.
69
+ renderCoverageCheck(report, id);
70
+ writesCoveredCheck(report, id);
71
+
72
+ // Membership as data: static, so it runs whether or not a browser was asked for.
73
+ if (region?.membership === 'catalogue') await catalogueCheck(report, id, region);
74
+
75
+ // Region styling: until islands own their CSS, the shared sheet is the region's stylesheet — lint it.
76
+ if (existsSync(paths.sharedStyles)) {
77
+ cssChecks(report, readFileSync(paths.sharedStyles, 'utf8'), paths.rel(paths.sharedStyles));
78
+ }
79
+
80
+ // Same line as the islands: static by default, browser on request (see runIslandVerify).
81
+ if ((argv.runtime === true || argv.audit === true) && argv.fast) {
82
+ // `--fast` means NO BROWSER, and a region's runtime checks are browser-only: flows, mutation and
83
+ // the region render all drive the lagoon. Running them anyway made `--fast` an island-only flag
84
+ // whose regions still booted chromium — 43s of an 87s run, most of what was left after the islands
85
+ // got cheaper. Skipped, and SAID, because a flag that quietly does half of what it claims is worse
86
+ // than one that costs more.
87
+ report.skip(
88
+ 'region-runtime',
89
+ 'flows, mutation and the region render need a browser — re-run without --fast before handing over',
90
+ );
91
+ } else if (argv.runtime === true || argv.audit === true) {
92
+ reportBrowserOverride(report);
93
+ // FIRST, before anything that drives the mountpoints view. The lane reuses one page and a warm
94
+ // re-aim keeps whatever view it already had, so running this after the wiring probe measured a
95
+ // DIAGNOSTIC layout and reported a page that overflows by 197px as fitting every viewport. The
96
+ // trap this file already documents, walked into by the check that needed the region view most.
97
+ if (hasLayout && argv.audit === true) await regionAuditCheck(report, id, 5300 + Math.floor(Math.random() * 400));
98
+ if (hasLayout) await wiringProbe(report, id, 5300 + Math.floor(Math.random() * 400));
99
+ // THE COMPOSED PAGE, at each declared viewport. Island `responsive` mounts each island alone, so
100
+ // every one of them fits and the arrangement still overflows — a fixed-rail grid fits nothing on
101
+ // a phone and no island in it is at fault. Only under `--audit`, beside the island checks whose
102
+ // answer changes when the RENDERING changes rather than when a key moves.
103
+ // One call, both questions: do the flows hold, and could they have failed.
104
+ if (hasLayout) await regionFlowCheck(report, id, 5300 + Math.floor(Math.random() * 400), region);
105
+ if (!hasLayout) {
106
+ // NAME WHERE THE ARRANGEMENT WAS LOOKED FOR. The old wording ("islands are placed individually
107
+ // across the host") describes the PAGE, and was read as a claim about the lagoon frame — so an
108
+ // author who had just written a `layout` saw motu deny it and had no idea which of the three
109
+ // places it looks at came up empty.
110
+ report.warn(
111
+ 'lagoon-render',
112
+ 'no arrangement for this region, so the composed render was skipped. Looked for: `root:` or ' +
113
+ `\`layout:\` on ${paths.rel(paths.archipelagoFile(id))}, and a \`layout\` for '${id}' in the ` +
114
+ 'lagoon overrides (`regions` or the kind-first `layout` map). Without one the islands are ' +
115
+ 'only ever placed individually by the host, and the arrangement is not checked anywhere',
116
+ );
117
+ } else {
118
+ const port = 5300 + Math.floor(Math.random() * 400);
119
+ try {
120
+ const r = await step('region-mount', () => runArchipelagoLagoon({ id, port }));
121
+ if (r.region) {
122
+ const unmounted = r.islands.filter((i) => !i.tag);
123
+ if (r.islands.length === 0) {
124
+ report.error('lagoon-render', 'archipelago region rendered no island slots');
125
+ } else if (unmounted.length === 0) {
126
+ // "all N" used to mean "all N I happened to find". A slot the arrangement never places —
127
+ // an island behind a closed drawer, a conditional branch — simply was not in the list, so
128
+ // the check congratulated itself on a region missing an island. Say what is declared.
129
+ const declaredRegion = readRegions(paths.archipelagosDir).find((x) => x.id === id);
130
+ // A planned island has nothing to mount yet; demanding it here would make the survey red.
131
+ const declared =
132
+ declaredRegion?.membership === 'catalogue'
133
+ ? []
134
+ : (declaredRegion?.islands ?? []).filter((i) => !i.planned).map((i) => i.slot);
135
+ const placed = new Set(r.islands.map((i) => i.slot).filter(Boolean));
136
+ // AN ALTERNATIVE IS NOT UNPLACED. In an exclusive group the lagoon shows one member, like the
137
+ // page does; the others not being on screen is the declaration working, not a slot lost.
138
+ const alternatives = new Set(exclusiveIslandSlots(id).flatMap((g) => g.slots));
139
+ const unplaced = declared.filter((slot) => !placed.has(slot) && !alternatives.has(slot));
140
+ report.ok('lagoon-render', `region + all ${r.islands.length} island(s) mounted in the lagoon`, {
141
+ n: r.islands.length,
142
+ of: 'mounted island(s)',
143
+ });
144
+ if (unplaced.length) {
145
+ report.warn(
146
+ 'lagoon-render',
147
+ `${unplaced.join(', ')} declared but not placed by this arrangement — behind an overlay or a ` +
148
+ `conditional branch. Declared wires are still driven (the probe uses the mountpoints view), ` +
149
+ `but nothing here shows it to a human: confirm that is what the page does.`,
150
+ );
151
+ }
152
+ } else {
153
+ report.error('lagoon-render', `slot(s) with no island mounted: ${unmounted.map((i) => i.slot || '?').join(', ')}`);
154
+ }
155
+ } else {
156
+ report.error('lagoon-render', `archipelago <${id}> did not render — unknown id or boot failure`);
157
+ }
158
+ reportRuntimeDiagnostics(report, r, 'region', declaredReach({ regionId: id }));
159
+
160
+ // THE PAGE ITSELF — the only check here that renders the application rather than the region.
161
+ // Reuses the same pooled page: the whole runtime lane is one browser re-aimed per check, and
162
+ // a second boot for this would double the cost of a region's run for one navigation.
163
+ const pr = await step('page-render', () => pageRenderLagoon({ id, port }));
164
+ if (!pr.declared) {
165
+ report.ok('page-render', `no \`page\` declared for '${id}' — the application's own page is not checked`, {
166
+ n: 0,
167
+ of: 'page(s)',
168
+ });
169
+ } else if (pr.crashed) {
170
+ report.error(
171
+ 'page-render',
172
+ `the application's own page threw while rendering:\n ${pr.crashed.split('\n').slice(0, 6).join('\n ')}`,
173
+ );
174
+ } else {
175
+ const declaredRegion2 = readRegions(paths.archipelagosDir).find((x) => x.id === id);
176
+ const declared2 =
177
+ declaredRegion2?.membership === 'catalogue'
178
+ ? []
179
+ : (declaredRegion2?.islands ?? []).filter((i) => !i.planned).map((i) => i.slot);
180
+ const reached = new Set(pr.slots.filter((x) => x.len > 0).map((x) => x.slot));
181
+ // DEDUPED, because a slot name appears both in the root's mapping and in the island entry,
182
+ // so the raw list said "1/4" and named `login-form` twice for a region with two slots.
183
+ const slots2 = [...new Set(declared2)];
184
+ // AND A CONDITIONAL SLOT IS NOT A MISS. The region declares its own either/or with
185
+ // `when`/`unless`; a slot the page did not take because the region says it is an alternative
186
+ // is the page behaving, not a finding.
187
+ const conditional = conditionalSlots(paths.archipelagoFile(id));
188
+ // So is a member of an EXCLUSIVE group — the page passes one alternative and null for the
189
+ // rest, by declaration. What an exclusive group CAN fail on is the opposite: two of its
190
+ // islands on the page at once, which the archipelago says is a screen that cannot exist.
191
+ const groups = exclusiveIslandSlots(id);
192
+ for (const g of groups) {
193
+ const both = g.slots.filter((slot) => reached.has(slot));
194
+ if (both.length > 1) {
195
+ report.error(
196
+ 'page-render',
197
+ `the page renders ${both.join(' AND ')} at once, and the archipelago declares ` +
198
+ `[${g.group.join(', ')}] exclusive — a screen it says cannot exist. Open ?region=${id}&view=page`,
199
+ );
200
+ }
201
+ }
202
+ const alternatives = new Set(groups.flatMap((g) => g.slots));
203
+ const missing = slots2.filter((slot) => !reached.has(slot) && !conditional.has(slot) && !alternatives.has(slot));
204
+ if (!reached.size) {
205
+ // NOTHING AT ALL. The page mounted and reached no declared slot, so whatever it drew, it
206
+ // was not this region. That is a failure however the page is written.
207
+ report.error(
208
+ 'page-render',
209
+ `the page renders and reaches NONE of its ${slots2.length} declared slot(s) — it mounted ` +
210
+ `without throwing and drew none of the region. Open ?region=${id}&view=page to see what ` +
211
+ `it did instead`,
212
+ );
213
+ } else if (missing.length) {
214
+ // A WARNING, LIKE `lagoon-render`'s, AND FOR THE SAME REASON. A slot the page did not reach
215
+ // is sometimes exactly right: two slots can be ALTERNATIVES — peps' login passes the error
216
+ // banner or the form and never both — and no check can tell a deliberate either/or from an
217
+ // accidentally unreachable branch. What is not right is not knowing, so it is named.
218
+ //
219
+ // It was an error for one run, which made a correctly-written exclusive region red. An
220
+ // error nobody can act on is how a check teaches people to ignore it.
221
+ report.warn(
222
+ 'page-render',
223
+ `the page renders and reaches ${reached.size}/${slots2.length} declared slot(s); not ` +
224
+ `reached: ${missing.join(', ')} — either an alternative the page deliberately did not ` +
225
+ `take (an error banner instead of a form), or a branch that never runs. Open ` +
226
+ `?region=${id}&view=page and confirm which`,
227
+ );
228
+ } else {
229
+ report.ok('page-render', `the application's own page renders and reaches all ${slots2.length} slot(s)`, {
230
+ n: slots2.length,
231
+ of: 'slot(s) reached by the page',
232
+ });
233
+ }
234
+ }
235
+ } catch (err) {
236
+ const msg = String(err?.message || err).split('\n')[0];
237
+ const env = environmentalCause(err);
238
+ if (/Executable doesn't exist|playwright install/i.test(msg)) {
239
+ report.inconclusive('lagoon-render', `Chromium not installed — run ${CHROMIUM_INSTALL}`);
240
+ } else if (env) {
241
+ report.inconclusive('lagoon-render', `could not boot the lagoon: ${env} — the environment, not the region`);
242
+ } else {
243
+ report.error('lagoon-render', `lagoon failed: ${msg}`);
244
+ }
245
+ }
246
+ }
247
+ }
248
+
249
+ return {
250
+ kebab: id,
251
+ report,
252
+ errors: report.findings.filter((f) => f.level === 'error'),
253
+ warns: report.findings.filter((f) => f.level === 'warn'),
254
+ };
255
+ }
256
+
257
+ /**
258
+ * The island SLOTS in each exclusive group, keyed by group — the members `page-render` and
259
+ * `lagoon-render` can see, since both count mounted islands. A host slot or a pass-through prop in the
260
+ * group is invisible to them; the type of `<X.Root>` and its runtime report cover those.
261
+ */
262
+ export function exclusiveIslandSlots(id) {
263
+ const declared = exclusiveDeclared(paths.archipelagoFile(id));
264
+ if (!declared) return [];
265
+ const slotOf = (prop) => {
266
+ const e = declared.slots[prop];
267
+ if (typeof e === 'string') return e;
268
+ return e && !e.region ? (e.slot ?? null) : null;
269
+ };
270
+ return declared.exclusive.map((group) => ({ group, slots: group.map(slotOf).filter(Boolean) }));
271
+ }
@@ -0,0 +1,338 @@
1
+ // The report every verify builds, how it prints, and what its exit code means.
2
+ // Split out of commands/verify.mjs; verify.mjs re-exports the public surface.
3
+ import { unchangedSinceLastRun } from '../../lib/finding-memory.mjs';
4
+ import { readLiveUrl } from '../../lib/live-url.mjs';
5
+ import { color } from '../../lib/util.mjs';
6
+
7
+ /**
8
+ * Say what is happening while it happens.
9
+ *
10
+ * The runtime checks drive a real browser, and a run that prints nothing until the end is
11
+ * indistinguishable from a run that has hung — which is exactly how it read at fifteen islands. With
12
+ * `--verbose` each step names itself as it starts and reports what it cost, so a slow check is
13
+ * identifiable rather than merely suspected.
14
+ */
15
+ export let progressLabel = '';
16
+
17
+ export let verbose = false;
18
+
19
+ export function setProgressScope(label) {
20
+ progressLabel = label;
21
+ }
22
+
23
+ /** Turned on once per command (`--verbose`), rather than threaded through every check's arguments. */
24
+ export function setVerbose(on) {
25
+ verbose = !!on;
26
+ }
27
+
28
+ /** Whether `--verbose` is on, for a check that has a finer profile of its own to print. */
29
+ export const isVerbose = () => verbose;
30
+
31
+ /**
32
+ * Total time inside instrumented steps. Read by `motu check --verbose` to print the REMAINDER.
33
+ *
34
+ * A list of timed parts reads as a partition of the run, and it is not one: after the flow session was
35
+ * merged into `regionFlowCheck` it stopped being wrapped, and a `--runtime` profile accounted for 13.8s
36
+ * of 31.8 — 57% invisible, while every printed line looked precise. A profile that cannot say what it
37
+ * missed is a profile you will optimise the wrong half of.
38
+ */
39
+ export let stepMsTotal = 0;
40
+
41
+ export const profiledMs = () => stepMsTotal;
42
+
43
+ export async function step(name, run) {
44
+ const started = Date.now();
45
+ if (!verbose) {
46
+ try {
47
+ return await run();
48
+ } finally {
49
+ stepMsTotal += Date.now() - started;
50
+ }
51
+ }
52
+ const at = Date.now();
53
+ process.stderr.write(color.dim(` · ${progressLabel ? `${progressLabel} ` : ''}${name}…`));
54
+ try {
55
+ return await run();
56
+ } finally {
57
+ stepMsTotal += Date.now() - at;
58
+ process.stderr.write(color.dim(` ${((Date.now() - at) / 1000).toFixed(1)}s\n`));
59
+ }
60
+ }
61
+
62
+ export function makeReport() {
63
+ const findings = [];
64
+ return {
65
+ findings,
66
+ error: (check, msg, line) => findings.push({ level: 'error', check, msg, line }),
67
+ warn: (check, msg, line) => findings.push({ level: 'warn', check, msg, line }),
68
+ /**
69
+ * A claim that HELD — and, optionally, how much was looked at to say so.
70
+ *
71
+ * `seen` is not decoration. The worst bug of the Twenty adoption was `removal-check` printing
72
+ * "no motu references in the host application" over a fully integrated app: it scanned the wrong
73
+ * directories, examined ZERO files, and reported that as success. A check that looked at nothing
74
+ * cannot have held, so passing `seen: 0` is converted to a `skip` here rather than trusted —
75
+ * every call site gets the invariant whether or not its author thought about emptiness.
76
+ */
77
+ ok: (check, msg, seen) => {
78
+ const n = Array.isArray(seen) ? seen.length : typeof seen === 'object' && seen ? seen.n : seen;
79
+ const of = typeof seen === 'object' && seen && !Array.isArray(seen) ? seen.of : undefined;
80
+ if (n === 0) {
81
+ findings.push({
82
+ level: 'skip',
83
+ check,
84
+ msg: `nothing to look at${of ? ` (no ${of})` : ''} — "${msg}" is not a result this run can claim`,
85
+ examined: 0,
86
+ });
87
+ return;
88
+ }
89
+ findings.push({ level: 'ok', check, msg, ...(n === undefined ? {} : { examined: n, examinedOf: of }) });
90
+ },
91
+ /**
92
+ * A rule that does not apply to this project's posture.
93
+ *
94
+ * Deliberately NOT `ok`: a rule silently reported as passing is indistinguishable from a rule that
95
+ * ran, so the two host modes could drift apart with every report still green. `skip` always states
96
+ * the reason.
97
+ */
98
+ skip: (check, msg) => findings.push({ level: 'skip', check, msg }),
99
+ /**
100
+ * COULD NOT LOOK — the check did not run, for a reason that is not the code's fault.
101
+ *
102
+ * A port that never opened, a browser that is not installed, a dev server that lost a race. A
103
+ * human shrugs and re-runs. An AGENT reads `✗` and repairs a bug that does not exist, and with
104
+ * several agents that is several confident wrong repairs — so this is the third outcome the
105
+ * report needs, distinct from both "holds" and "contradicted", and the run exits 2 rather than 1
106
+ * so a loop can tell "retry" from "fix".
107
+ */
108
+ inconclusive: (check, msg) => findings.push({ level: 'inconclusive', check, msg }),
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Condense a lagoon boot failure into one actionable line.
114
+ *
115
+ * startLagoon attaches vite's own stdout/stderr to the error, but taking only the first line threw it
116
+ * away — so every misconfiguration surfaced as "vite did not open port N in time", which says nothing
117
+ * about the cause. Keep the headline, then append the first line of vite output that actually names
118
+ * the problem.
119
+ */
120
+ /**
121
+ * Was this the ENVIRONMENT failing, rather than the code?
122
+ *
123
+ * Decided on two signals, and the second one matters more than the first: a known environmental
124
+ * signature, AND the absence of any application cause in the output. `lagoonFailure` already appends
125
+ * the first line of vite's output that names a problem — when that line exists, something in the
126
+ * project is broken and this is a finding, whatever the headline says.
127
+ */
128
+ /*
129
+ * RESOURCE EXHAUSTION BELONGS HERE, and its absence was measured rather than guessed. On a machine
130
+ * running several agents at once, Vite's dev server died with
131
+ * `ENOSPC: System limit for number of file watchers reached` — the box was out of inotify watches,
132
+ * nothing to do with the island — and `island verify --runtime` reported it as exit 1, FAIL. That is
133
+ * precisely the outcome the three-code design exists to prevent: an unattended agent reads `✗` and
134
+ * repairs a bug that does not exist. Two arms of one bench run hit it.
135
+ *
136
+ * ENOSPC is listed with its siblings (EMFILE/ENFILE — descriptors, ENOMEM — memory) because they are
137
+ * the same class: the machine ran out of something, and no edit to the project changes that.
138
+ */
139
+ export const ENVIRONMENTAL =
140
+ /did not open port .* in time|Executable doesn't exist|playwright install|EADDRINUSE|ECONNREFUSED|ETIMEDOUT|Target page, context or browser has been closed|browserType\.launch|ENOSPC|System limit for number of file watchers|EMFILE|ENFILE|ENOMEM|JavaScript heap out of memory/i;
141
+
142
+ export function environmentalCause(err) {
143
+ const full = String(err?.message || err);
144
+ if (!ENVIRONMENTAL.test(full)) return null;
145
+ // An application cause anywhere in the output disqualifies it: the port never opened BECAUSE the
146
+ // project does not build, and that is a finding.
147
+ // …but the environmental line itself is not an application cause, and it usually LOOKS like one:
148
+ // node prints `Error: ENOSPC: System limit for number of file watchers reached`, which matches the
149
+ // `^Error` disqualifier and sent the whole classification back to FAIL. Skip any line that is
150
+ // itself a known environmental signature before deciding the project is at fault.
151
+ const appCause = full
152
+ .split('\n')
153
+ .find(
154
+ (l) =>
155
+ /^(Error|\s*failed to|.*ERR_[A-Z_]+|.*Cannot find)/.test(l) &&
156
+ !l.includes('did not open port') &&
157
+ !ENVIRONMENTAL.test(l),
158
+ );
159
+ return appCause ? null : full.split('\n')[0];
160
+ }
161
+
162
+ export function lagoonFailure(err) {
163
+ const full = String(err?.message || err);
164
+ const headline = full.split('\n')[0];
165
+ const detail = full
166
+ .split('\n')
167
+ .find((l) => /^(Error|\s*failed to|.*ERR_[A-Z_]+|.*Cannot find)/.test(l) && !l.includes('did not open port'));
168
+ return detail ? `${headline} — ${detail.trim()}` : headline;
169
+ }
170
+
171
+ /**
172
+ * 0 pass · 1 contradicted · 2 could not look.
173
+ *
174
+ * The third code is the whole point: an unattended loop must be able to tell "the declarations are
175
+ * wrong, fix them" from "the check never ran, try again". Collapsing them into 1 is what makes an
176
+ * agent repair a bug that does not exist.
177
+ */
178
+ export function verifyExitCode(result) {
179
+ if (result.errors.length) return 1;
180
+ return result.report.findings.some((f) => f.level === 'inconclusive') ? 2 : 0;
181
+ }
182
+
183
+ /**
184
+ * A SWEEP's exit code: the worst verdict any member reached.
185
+ *
186
+ * Both sweeps used to gate on `errors.length === 0` alone, which made them the one path where an
187
+ * inconclusive run reported success — no Chromium, every runtime check unanswered, `--all` exits 0
188
+ * and CI goes green on a question nobody asked. The single-target form has always used
189
+ * `verifyExitCode`; this is the same rule applied across members.
190
+ *
191
+ * An error still beats an inconclusive, exactly as it does for one member: a run that found something
192
+ * wrong has an answer, and that answer is 1.
193
+ */
194
+ export function sweepExitCode(results) {
195
+ const codes = results.map(verifyExitCode);
196
+ if (codes.includes(1)) return 1;
197
+ return codes.includes(2) ? 2 : 0;
198
+ }
199
+
200
+ /** `--json` shape for one member of a sweep. */
201
+ export function summaryOf(r) {
202
+ return { name: r.kebab, tag: r.tag, pass: r.errors.length === 0, findings: r.report.findings };
203
+ }
204
+
205
+ /**
206
+ * A sweep's result: one line per member, and findings only for those that have something to say.
207
+ *
208
+ * A full report per island is unreadable at fourteen of them, and what a sweep answers is "is anything
209
+ * wrong, and where" — so a clean island costs one line.
210
+ */
211
+ /** Per-sweep tally of how much of this section the reader has already seen. */
212
+ export const sweepSeen = { total: 0, repeated: 0 };
213
+
214
+ export function printSweep(title, results) {
215
+ console.log(color.bold(`\n${title}\n`));
216
+ for (const r of results) {
217
+ const mark = r.errors.length ? color.red('✗') : r.warns.length ? color.yellow('!') : color.green('✓');
218
+ const tally = r.errors.length
219
+ ? color.red(`${r.errors.length} error(s)`) + color.dim(`, ${r.warns.length} warning(s)`)
220
+ : color.dim(`${r.warns.length} warning(s)`);
221
+ console.log(` ${mark} ${color.dim(r.kebab.padEnd(20))} ${tally}`);
222
+ for (const f of r.report.findings) {
223
+ if (f.level !== 'error' && f.level !== 'warn') continue;
224
+ const m = f.level === 'error' ? color.red('✗') : color.yellow('!');
225
+ const again = unchangedSinceLastRun(f) ? color.dim(' · unchanged') : '';
226
+ sweepSeen.total++;
227
+ if (again) sweepSeen.repeated++;
228
+ console.log(` ${m} ${color.dim(f.check.padEnd(18))} ${f.msg}${again}${f.line ? color.dim(` (line ${f.line})`) : ''}`);
229
+ }
230
+ }
231
+ const failed = results.filter((r) => r.errors.length).length;
232
+ const warned = results.reduce((n, r) => n + r.warns.length, 0);
233
+ console.log('');
234
+ const head = failed
235
+ ? color.red(color.bold('FAIL')) + ` ${failed}/${results.length} with errors`
236
+ : color.green(color.bold('PASS')) + ` ${results.length}/${results.length} clean`;
237
+ // WHAT MOVED, on the line a reader piping through `tail` will actually see.
238
+ const { total, repeated } = sweepSeen;
239
+ const delta =
240
+ total && repeated === total
241
+ ? color.dim(' · all unchanged since your last run')
242
+ : repeated
243
+ ? color.dim(` · ${repeated} unchanged, ${total - repeated} new since your last run`)
244
+ : '';
245
+ console.log(head + color.dim(` · ${warned} warning(s) total`) + delta);
246
+ sweepSeen.total = 0;
247
+ sweepSeen.repeated = 0;
248
+ }
249
+
250
+ /** Pretty-print a report's findings + the PASS/FAIL summary line (shared by island + archipelago verify). */
251
+ export function printReport(report, title, errors, warns) {
252
+ console.log(color.bold(`\n${title}\n`));
253
+ const line = (f) => {
254
+ const mark =
255
+ f.level === 'error'
256
+ ? color.red('✗')
257
+ : f.level === 'warn'
258
+ ? color.yellow('!')
259
+ : f.level === 'skip'
260
+ ? color.dim('–')
261
+ : f.level === 'inconclusive'
262
+ ? color.yellow('?')
263
+ : color.green('✓');
264
+ const at = f.line ? color.dim(` (line ${f.line})`) : '';
265
+ // WHAT IT LOOKED AT, on the line itself. A reader skimming a wall of green cannot tell a check
266
+ // that examined forty files from one that examined none, and an agent reporting the former while
267
+ // doing the latter is the failure this whole report exists to catch.
268
+ const seen =
269
+ f.examined === undefined ? '' : color.dim(` · ${f.examined} ${f.examinedOf ?? 'examined'}`);
270
+ // UNCHANGED SINCE THE LAST RUN OF THIS SAME COMMAND. Half of everything read in the check loop
271
+ // was a repeat; saying so is what lets a reader skip it and look at the delta instead.
272
+ const again = f.level === 'ok' || f.level === 'skip' ? '' : unchangedSinceLastRun(f) ? color.dim(' · unchanged') : '';
273
+ console.log(` ${mark} ${color.dim(f.check.padEnd(18))} ${f.msg}${seen}${again}${at}`);
274
+ };
275
+
276
+ // THE PASSES COLLAPSE; EVERYTHING ELSE DOES NOT.
277
+ //
278
+ // One island under `--runtime` printed 24 rows, 20 of them green, and `motu check --runtime` does
279
+ // that once per island — so the findings worth reading were a fifth of the output and got skimmed
280
+ // past. Warnings, errors, skips and inconclusives keep their own line, because each is something to
281
+ // act on or to know was not run.
282
+ //
283
+ // The ids are still NAMED, not just counted. `report.ok(check, msg, 0)` already becomes a `skip`, so
284
+ // a check that examined nothing is never inside this line — but a check that never RAN would leave
285
+ // no trace at all, and the whole point of the seen-count is that silence must be legible. Listing
286
+ // them keeps a missing check visible; `--verbose` prints the full rows with their counts.
287
+ const passed = report.findings.filter((f) => f.level === 'ok');
288
+ for (const f of report.findings) if (verbose || f.level !== 'ok') line(f);
289
+ if (!verbose && passed.length) {
290
+ console.log(` ${color.green('✓')} ${color.dim('passed'.padEnd(18))} ${passed.length} check(s)`);
291
+ // A pass that counted an island's PORTALED output (lib/island-render.mjs) keeps that on its name
292
+ // even collapsed — "data-flow" passing on a dialog is a different claim from passing on the subtree.
293
+ const named = (f) => (/\bportals?\b/.test(`${f.examinedOf ?? ''} ${f.msg}`) ? `${f.check} (incl. portal)` : f.check);
294
+ console.log(` ${color.dim(passed.map(named).join(', '))}`);
295
+ }
296
+ console.log('');
297
+ const unknown = report.findings.filter((f) => f.level === 'inconclusive');
298
+ // THE DELTA, ON THE VERDICT LINE. Every agent observed in the bench piped this report through
299
+ // `tail`, so the end is the only part reliably read — which makes it where "what moved" belongs.
300
+ const actionable = report.findings.filter((f) => f.level === 'error' || f.level === 'warn');
301
+ const repeated = actionable.filter((f) => unchangedSinceLastRun(f)).length;
302
+ const delta =
303
+ repeated && repeated < actionable.length
304
+ ? color.dim(` · ${repeated} unchanged, ${actionable.length - repeated} new since your last run`)
305
+ : repeated && repeated === actionable.length
306
+ ? color.dim(' · all unchanged since your last run')
307
+ : '';
308
+ if (errors.length) {
309
+ console.log(color.red(color.bold('FAIL')) + ` ${errors.length} error(s), ${warns.length} warning(s)` + delta);
310
+ } else if (unknown.length) {
311
+ // NOT a pass: nothing contradicted the declarations, but some of them were never examined.
312
+ console.log(
313
+ color.yellow(color.bold('INCONCLUSIVE')) +
314
+ color.dim(` ${unknown.length} check(s) could not run, ${warns.length} warning(s) — retry, do not repair`),
315
+ );
316
+ } else {
317
+ console.log(color.green(color.bold('PASS')) + color.dim(` ${warns.length} warning(s)`));
318
+ }
319
+ printLagoonAvailability();
320
+ }
321
+
322
+ /**
323
+ * WHERE A PERSON CAN LOOK AT THIS RIGHT NOW — printed under every verdict, pass or fail.
324
+ *
325
+ * A green report describes files. The thing the work is FOR is a rendered region, and for the whole
326
+ * of a bench run no agent ever handed one over: each finished on a passing check and a summary, and
327
+ * the person who asked for the work had nothing to open. The rules already said to look; nothing in
328
+ * the loop ever mentioned it, so it was the step that quietly did not happen.
329
+ *
330
+ * This is a NUDGE, not a gate, and deliberately so. Failing a static check because no dev server is
331
+ * running would make `motu check` depend on a process and a network — untrue to what it examines, and
332
+ * unrunnable in CI. So: the URL when there is one, and how to get one when there is not.
333
+ */
334
+ export function printLagoonAvailability() {
335
+ const url = readLiveUrl();
336
+ if (url) console.log(color.dim(' look at it: ') + url);
337
+ else console.log(color.dim(' no live lagoon — start one with: motu lagoon serve --watch --detach'));
338
+ }