@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,391 @@
1
+ // A region's composition: registered tags, which islands compose it, whether its frame is the page.
2
+ // Split out of commands/verify.mjs; verify.mjs re-exports the public surface.
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { placementsIn } from '../../lib/island-placement.mjs';
5
+ import { listIslands } from '../../lib/islands.mjs';
6
+ import { frameModuleFor, nestedSlots } from '../../lib/lagoon-declares.mjs';
7
+ import { sourceFileAt } from '../../lib/ts-project.mjs';
8
+ import { HOST, blankComments, paths } from '../../lib/util.mjs';
9
+ import { conditionallyPlaced, hostSources } from '../integration.mjs';
10
+
11
+ /** All island tags declared in the registry, gathered from each islands/<kebab>/element.ts `tag:`. */
12
+ export function registeredTags() {
13
+ const tags = new Set();
14
+ if (!existsSync(paths.islandsDir)) return tags;
15
+ for (const entry of listIslands(paths.islandsDir)) {
16
+ const el = entry.element;
17
+ if (!existsSync(el)) continue;
18
+ const text = readFileSync(el, 'utf8');
19
+ const m = text.match(/tag:\s*['"]([^'"]+)['"]/) ?? text.match(/\bisland\(\s*'([^']+)'/);
20
+ if (m) tags.add(m[1]);
21
+ }
22
+ return tags;
23
+ }
24
+
25
+ /** Static checks over an archipelago config file: it's registered, and every island tag it uses exists. */
26
+ /**
27
+ * The region's lagoon FRAME MODULE — the file holding its `layout` — or null.
28
+ *
29
+ * Resolved rather than guessed by filename: `overridesFor(someArchipelago, …)` names the archipelago
30
+ * it belongs to, so the module is identified by what it POINTS AT, never by a spelling that could
31
+ * disagree with the registry.
32
+ */
33
+ /**
34
+ * IS THE REGION THE PAGE, OR A DRAWING OF IT?
35
+ *
36
+ * The lagoon's whole claim is that what you look at is what ships. A frame that writes its own JSX
37
+ * breaks that claim silently, and this project proved it: `regions/forgot-password.tsx` rendered
38
+ * `<h2>On récupère ton accès</h2>` while the page rendered `Mot de passe oublié ?` — a heading that
39
+ * existed nowhere in the application, previewed for weeks under a green PASS, because every other
40
+ * check reads motu's own declarations and none of them opens the page.
41
+ *
42
+ * So a frame may contain ONLY:
43
+ * - components imported from the HOST application (the arrangement it already ships),
44
+ * - fragments,
45
+ * - `island(slot)` calls.
46
+ *
47
+ * An intrinsic element or a literal string is arrangement that exists only here. And a host component
48
+ * the frame calls must be one the PAGE composing this region actually renders — pointing at a real
49
+ * component that no page uses is the same lie with better manners.
50
+ *
51
+ * The escape hatch costs a sentence, like `rawChannel`: `inventedArrangement('why', <…/>)`. It
52
+ * downgrades to a warning that prints the reason, so a deliberate stand-in stays findable.
53
+ */
54
+ /**
55
+ * DOES THE REGION COMBINE THE SAME ISLANDS THE APPLICATION DOES?
56
+ *
57
+ * The arrangement is the page's own business — a lagoon frame is allowed to place things differently,
58
+ * and forcing one component on every page would mean restructuring pages around motu. What is NOT the
59
+ * frame's business is WHICH ISLANDS the region is made of. Get that wrong and the region previewed,
60
+ * flowed and snapshotted is a different region from the one that ships: an island nobody mounts looks
61
+ * proven, and an island the page mounts is never looked at.
62
+ *
63
+ * This is the drift `region-root` cannot see. acme's annuaire frame simply omitted `header`, and its
64
+ * actions frame omits `ambassador-card` — both under green checks, because every other check reads
65
+ * motu's declarations and the page's placements separately, and nothing compared the two.
66
+ *
67
+ * Decidable without a browser, from three sources:
68
+ * - the FRAME's `island(<slot>)` calls,
69
+ * - the archipelago's own NESTED slots (`slots: { ambassador: 'ambassador-inline' }`), which motu
70
+ * fills itself, so the frame never names them and their absence is not a finding,
71
+ * - the HOST's `<X.Island slot="…">` placements.
72
+ *
73
+ * A region composed from a declared `root` cannot drift here at all — the page passes props by name
74
+ * and motu maps them — so it is reported as such rather than compared.
75
+ */
76
+ export function islandCompositionCheck(report, id, region, composedBy, sources) {
77
+ const archPath = paths.archipelagoFile(id);
78
+ const archText = existsSync(archPath) ? blankComments(readFileSync(archPath, 'utf8')) : '';
79
+ if (/^\s*root\s*:/m.test(archText)) {
80
+ report.skip('island-composition', 'composed from the archipelago’s `root` — one declaration, nothing to compare');
81
+ return;
82
+ }
83
+
84
+ const { module } = frameModuleFor(id);
85
+ if (!module) {
86
+ report.skip('island-composition', 'no region frame — the islands are placed individually across the host');
87
+ return;
88
+ }
89
+
90
+ const declared = new Set((region?.islands ?? []).map((i) => i.slot).filter(Boolean));
91
+ const frame = new Set(
92
+ [...blankComments(readFileSync(module, 'utf8')).matchAll(/\bisland\(\s*['"`]([^'"`]+)['"`]/g)].map((m) => m[1]),
93
+ );
94
+ // Nested by DECLARATION: motu fills these into the outer island's props, in the page and in the
95
+ // lagoon alike, so a frame that does not name them is composing them all the same.
96
+ const nested = nestedSlots(paths.archipelagoFile(id));
97
+ const placed = new Set();
98
+ // PARSED, and the same parse `integrate check` uses — see `island-placement.mjs`. This asked the
99
+ // same question with a pattern, which could not see a multi-line element, a computed slot, or a
100
+ // commented-out one.
101
+ for (const code of composedBy) {
102
+ for (const slot of placementsIn(code).named) if (declared.has(slot)) placed.add(slot);
103
+ }
104
+
105
+ if (!placed.size) {
106
+ report.skip('island-composition', 'no host file placing this region’s islands could be read');
107
+ return;
108
+ }
109
+
110
+ const mounts = new Set([...frame, ...nested]);
111
+ const onlyInFrame = [...mounts].filter((slot) => !placed.has(slot));
112
+ const onlyInPage = [...placed].filter((slot) => !mounts.has(slot));
113
+
114
+ const findings = [];
115
+ if (onlyInFrame.length) {
116
+ findings.push(
117
+ `the region mounts ${onlyInFrame.join(', ')}, which the application never places — previewed, ` +
118
+ `flowed and pictured, and shipped by nothing`,
119
+ );
120
+ }
121
+ // A slot the page places CONDITIONALLY may legitimately be missing from the frame: the frame shows
122
+ // one STATE of the page, and acme's `ambassador-card` is the empty-week fallback that the normal week
123
+ // does not reach. That is a different state, not a different composition — so it is said, not failed.
124
+ // A slot placed UNCONDITIONALLY and never mounted is the real finding: it ships on every render and
125
+ // the region has never once looked at it.
126
+ // The TAG NAMES as the host actually writes them (`<Actions.Island …>`), read from the files that
127
+ // place them — the binding's name is the page's to choose, so it cannot be derived from the region.
128
+ const islandNames = [
129
+ ...new Set(composedBy.flatMap((code) => [...code.matchAll(/<([A-Z][A-Za-z0-9]*\.Island)[\s/>]/g)].map((m) => m[1]))),
130
+ ];
131
+ let conditional = new Map();
132
+ try {
133
+ conditional = conditionallyPlaced(sources, ['Island', ...islandNames], blankComments) ?? new Map();
134
+ } catch {
135
+ conditional = new Map();
136
+ }
137
+ const missedAlways = onlyInPage.filter((slot) => !conditional.has(slot));
138
+ const missedSometimes = onlyInPage.filter((slot) => conditional.has(slot));
139
+ if (missedAlways.length) {
140
+ findings.push(
141
+ `the application always places ${missedAlways.join(', ')}, which the region never mounts — it ` +
142
+ `ships on every render, and nothing here has ever looked at it`,
143
+ );
144
+ }
145
+ if (findings.length) {
146
+ report.error('island-composition', `${paths.rel(module)}: ${findings.join('; ')}`);
147
+ return;
148
+ }
149
+ if (missedSometimes.length) {
150
+ report.warn(
151
+ 'island-composition',
152
+ `${paths.rel(module)}: the application places ${missedSometimes.join(', ')} in a branch this ` +
153
+ `region's frame does not show — a different STATE of the same page, so confirm the state the ` +
154
+ `frame shows is the one worth looking at, and that the other has a scenario or a flow`,
155
+ );
156
+ return;
157
+ }
158
+ report.ok(
159
+ 'island-composition',
160
+ `the region is made of the same ${mounts.size} island(s) the application places`,
161
+ mounts.size,
162
+ );
163
+ }
164
+
165
+ /**
166
+ * The source of the `layout` export alone, or the whole file when it cannot be isolated.
167
+ *
168
+ * Falls back deliberately: a frame reached through the array form has no single named export, and
169
+ * judging nothing would be worse than judging too much.
170
+ */
171
+ export function frameDeclarationText(file, raw, exportName) {
172
+ if (!exportName) return raw;
173
+ try {
174
+ const sf = sourceFileAt(file);
175
+ const decl = sf.getVariableDeclaration(exportName) ?? sf.getFunction(exportName);
176
+ return decl ? decl.getText() : raw;
177
+ } catch {
178
+ return raw;
179
+ }
180
+ }
181
+
182
+ export function frameIsPageCheck(report, id, region) {
183
+ // THE ANSWER, when the region has one: `root` names the application's own component and `slots`
184
+ // maps its props to islands, so the page and the lagoon compose from the SAME declaration and
185
+ // neither writes the other's half. Nothing is left to compare, because nothing is duplicated.
186
+ const archText = existsSync(paths.archipelagoFile(id)) ? readFileSync(paths.archipelagoFile(id), 'utf8') : '';
187
+ const rootDecl = blankComments(archText).match(/^\s*root\s*:\s*([A-Za-z_$][\w$]*)/m);
188
+ if (rootDecl) {
189
+ const mapped = [...blankComments(archText).matchAll(/^\s{4}(\w+)\s*:\s*'([^']+)'/gm)];
190
+ report.ok(
191
+ 'region-root',
192
+ `composed from the archipelago's own \`root\` (${rootDecl[1]}) — the page and the lagoon share one declaration`,
193
+ 1 + mapped.length,
194
+ );
195
+ const { module: leftover } = frameModuleFor(id);
196
+ if (leftover && /\blayout\s*:/.test(blankComments(readFileSync(leftover, 'utf8')))) {
197
+ report.error(
198
+ 'region-root',
199
+ `${paths.rel(leftover)} still declares a \`layout\` while the archipelago declares a \`root\` — ` +
200
+ `two arrangements for one region, which is the duplication \`root\` exists to remove. Delete the frame`,
201
+ );
202
+ }
203
+ report.skip(
204
+ 'island-composition',
205
+ 'composed from the archipelago\u2019s `root` \u2014 one declaration, so the region cannot be made of ' +
206
+ 'different islands from the page',
207
+ );
208
+ return;
209
+ }
210
+
211
+ // AN OCEAN COMPOSES FROM ITS OWN `layout`, and that is a third legitimate shape rather than a
212
+ // missing `root`.
213
+ //
214
+ // `root` is a React component and `<X.Island>` is a React binding, so an AngularJS ocean can supply
215
+ // neither. What it has is a template of `<motu-island slot="…">` markers on the archipelago, which
216
+ // `<motu-archipelago name="…">` renders in the page and which the lagoon's own preference order
217
+ // already honours (`lagoon-react-mount.tsx`: root, then `layout`, then declared order). Demanding a
218
+ // React `root` from a legacy host made every ocean region a hard error — motu's own `demo-app`, the
219
+ // reference ocean, could not pass `motu check` at all.
220
+ //
221
+ // Reported with its LIMIT named. In the embedded ocean the legacy page may place its own markers
222
+ // rather than mounting the whole region, and nothing compares those two — which is the same class of
223
+ // gap a hand-written frame has, so it gets the same kind of line.
224
+ const layoutDecl = /^\s*layout\s*:/m.test(blankComments(archText));
225
+ // AND ONLY AN OCEAN. `layout` is a template of `<motu-island slot="…">` markers rendered by the
226
+ // custom element; a REACT host has no custom element to render it and `<X.Island>` is the binding
227
+ // it does have, so the same declaration composes nothing there.
228
+ //
229
+ // This branch did not ask, so a Vite or Next region declaring `layout` — which is what the
230
+ // scaffold's own commented example suggests — passed `region-root` green, passed every other static
231
+ // check, and then failed the runtime pass outright: "archipelago did not render", "reaches NONE of
232
+ // its declared slots". A cold-start agent lost its remaining budget to exactly that, and nothing in
233
+ // the CLI said `layout` was the wrong shape for its host until the expensive check ran.
234
+ //
235
+ // An ERROR rather than a warning: unlike a stage-1 overlay, this is not a shape someone can ship
236
+ // and migrate later — it does not work at all on this host.
237
+ if (layoutDecl && HOST !== 'angularjs') {
238
+ report.error(
239
+ 'region-root',
240
+ `this region declares \`layout\` in the ARCHIPELAGO — the ocean's composition, a template of ` +
241
+ `<motu-island> markers rendered by <motu-archipelago>. This project's host is \`${HOST}\`, ` +
242
+ `which has no such element: the region would pass every static check and render NOTHING.\n` +
243
+ ` TWO THINGS ARE CALLED \`layout\` AND THIS IS THE OTHER ONE. In the LAGOON OVERRIDES, ` +
244
+ `\`layout: (island) => <YourPageFrame island={island} />\` is a React frame and is correct here — ` +
245
+ `that is the one the scaffold's commented example shows. In the ARCHIPELAGO, \`layout\` is the ` +
246
+ `ocean's template and means nothing on a React host.\n` +
247
+ ` So: move it to the region's lagoon overrides, or declare \`root\` — the application's own ` +
248
+ `layout component, with \`slots\` mapping its props to islands, which is the form with no ` +
249
+ `second copy anywhere. See docs/06-composition-and-adoption.md`,
250
+ );
251
+ report.skip('island-composition', '`layout` is not a composition on this host — see region-root');
252
+ return;
253
+ }
254
+ if (layoutDecl) {
255
+ report.ok(
256
+ 'region-root',
257
+ `composed from the archipelago's own \`layout\` — the ocean's form, rendered by ` +
258
+ `<motu-archipelago> in the page and by the lagoon from the same template. Where the legacy ` +
259
+ `page places its own <motu-island> markers instead, that placement is a second description ` +
260
+ `nothing here compares`,
261
+ (region?.islands ?? []).length,
262
+ );
263
+ report.skip(
264
+ 'island-composition',
265
+ "composed from the archipelago's `layout` — one template, so the region cannot be made of " +
266
+ 'different islands from the page that mounts it',
267
+ );
268
+ return;
269
+ }
270
+
271
+ const { module, inline, exportName } = frameModuleFor(id);
272
+ // AN INLINE `layout` IS AN ARRANGEMENT, NOT A MISSING ONE. It renders — both cold-start agents
273
+ // confirmed the region painted correctly from one — so the only thing lost is this file's ability
274
+ // to OPEN the frame and run the checks below (what it draws itself, which slots it places). Say
275
+ // that, and say the one-line fix, instead of demanding a `root` the region may not need yet.
276
+ if (!module && inline) {
277
+ report.warn(
278
+ 'region-root',
279
+ `\`layout\` for this region is written inline in the lagoon overrides, so the frame checks ` +
280
+ `cannot read it — what it draws itself and which slots it places are unchecked. Move it to a ` +
281
+ `named export beside the region (\`export const ${id.replace(/[^\w$]/g, '')}Frame = (island) => …\`) ` +
282
+ `and reference it by name. The region still composes; this is the frame, not the archipelago`,
283
+ );
284
+ return;
285
+ }
286
+ if (!module) {
287
+ // A REGION WITH NO ISLANDS HAS NOTHING TO COMPOSE YET. `archipelago init` (no page) scaffolds exactly
288
+ // that, so demanding a root here would open a new project's first `motu check` on an error about
289
+ // the thing it had just been handed — the same mistake `region-type` already records. The demand
290
+ // is right the moment the region has a member.
291
+ const empty = !(region?.islands ?? []).length;
292
+ report[empty ? 'warn' : 'error'](
293
+ 'region-root',
294
+ `no \`root\` — declare the application's own component as \`root\` in the archipelago, with ` +
295
+ `\`slots\` mapping its props to this region's islands. Without it the page and the lagoon each ` +
296
+ `compose the region their own way, and nothing compares the two`,
297
+ );
298
+ return;
299
+ }
300
+ const raw = readFileSync(module, 'utf8');
301
+ // ONLY THE `layout` EXPORT, when we know which one it is.
302
+ //
303
+ // This read the whole FILE, so anything else living in the same module was judged as arrangement
304
+ // the frame draws itself. Measured on a cold adoption: a region kept its `providers` (a DI
305
+ // container, a Redux `Provider`, a router) beside its frame — the natural place for them — and
306
+ // `region-root` reported the providers' own JSX, and a string from a button inside them, as the
307
+ // frame inventing a layout. The adopter split the file to satisfy a check that was reading the
308
+ // wrong thing.
309
+ const body = blankComments(frameDeclarationText(module, raw, exportName));
310
+
311
+ const excused = body.match(/inventedArrangement\(\s*(['"`])([\s\S]*?)\1/);
312
+
313
+ // --- 1. what does the frame draw itself? -----------------------------------------------------
314
+ //
315
+ // `[^\w$.]` before the `<` is what separates JSX from a TYPE ARGUMENT: `SlotsOf<typeof x>` and
316
+ // `new Set<string>()` are not elements, and reporting them as invented arrangement would make the
317
+ // check's first impression a false one.
318
+ const intrinsic = [...new Set([...body.matchAll(/(?:^|[^\w$.])<([a-z][a-zA-Z0-9-]*)[\s/>]/gm)].map((m) => m[1]))];
319
+ // Literal text sitting between JSX tags: `>Some words<`. Whitespace and expressions are not text —
320
+ // and neither is code that happens to sit between a `=>` and the next element, so anything carrying
321
+ // punctuation only code has is skipped.
322
+ const literals = [...new Set(
323
+ [...body.matchAll(/>\s*([^<>{}\s][^<>{};=()]*?)\s*</g)].map((m) => m[1].trim()).filter((t) => /[A-Za-zÀ-ÿ]/.test(t)),
324
+ )];
325
+
326
+ // --- 2. which host components does it call, and does the page render them? -------------------
327
+ const called = [...new Set([...body.matchAll(/<([A-Z][A-Za-z0-9]*)[\s/>]/g)].map((m) => m[1]))];
328
+ // The frame's own exported components are not host components — they ARE the frame.
329
+ const ownExports = new Set([...body.matchAll(/export\s+(?:function|const)\s+([A-Z][A-Za-z0-9]*)/g)].map((m) => m[1]));
330
+ const hostCalled = called.filter((c) => !ownExports.has(c));
331
+
332
+ const sources = hostSources();
333
+ const islandNames = [...new Set((region?.islands ?? []).map((i) => i.element).filter(Boolean))];
334
+ // WHERE THIS REGION IS COMPOSED: the files placing its slots, plus one hop up — a page that renders
335
+ // a screen component which places the islands is composing this region too, and it is usually the
336
+ // page that holds the layout (acme's `/forgot-password` is exactly that shape).
337
+ const composing = new Map();
338
+ const slotRe = /<[A-Z][A-Za-z0-9]*\.Island\s[^>]*?slot=["'`]([^"'`]+)/g;
339
+ const declaredSlots = new Set((region?.islands ?? []).map((i) => i.slot).filter(Boolean));
340
+ for (const [file, text] of sources) {
341
+ const code = blankComments(text);
342
+ for (const [, slot] of code.matchAll(slotRe)) if (declaredSlots.has(slot)) composing.set(file, code);
343
+ }
344
+ for (const [file, code] of [...composing]) {
345
+ const exported = [...code.matchAll(/export\s+(?:default\s+)?(?:function|const)\s+([A-Za-z0-9_$]*)/g)].map((m) => m[1]).filter(Boolean);
346
+ for (const [other, text] of sources) {
347
+ if (composing.has(other)) continue;
348
+ const oc = blankComments(text);
349
+ if (exported.some((e) => new RegExp(`<${e}[\\s/>]`).test(oc))) composing.set(other, oc);
350
+ }
351
+ void file;
352
+ }
353
+ const composedBy = [...composing.values()];
354
+ islandCompositionCheck(report, id, region, composedBy, sources);
355
+ const orphan = hostCalled.filter((c) => !composedBy.some((code) => new RegExp(`<${c}[\\s/>]`).test(code)));
356
+
357
+ const findings = [];
358
+ if (intrinsic.length) findings.push(`draws ${intrinsic.length} element(s) of its own (<${intrinsic.join('>, <')}>)`);
359
+ if (literals.length) {
360
+ const shown = literals.slice(0, 3).map((t) => `"${t.length > 44 ? t.slice(0, 41) + '…' : t}"`);
361
+ findings.push(`writes ${literals.length} literal string(s) the page may never say (${shown.join(', ')})`);
362
+ }
363
+ if (!composedBy.length && hostCalled.length) {
364
+ findings.push(`calls ${hostCalled.join(', ')}, and no host file placing this region's slots could be found to compare against`);
365
+ } else if (orphan.length) {
366
+ findings.push(`calls ${orphan.join(', ')}, which no file composing this region renders`);
367
+ }
368
+
369
+ const where = paths.rel(module);
370
+ if (!findings.length) {
371
+ // A CLEAN FRAME IS A LEGITIMATE WAY TO COMPOSE A REGION, and saying otherwise is how a tool
372
+ // prices itself out of being adopted: moving a page to `root` is a real refactor of the host's
373
+ // own JSX, and a project cannot do ten of them before its first green run.
374
+ //
375
+ // What IS true is that a frame is a SECOND description of the page, safe only as far as the checks
376
+ // comparing it reach — `island-composition` compares which islands, nothing compares the
377
+ // arrangement. So the line names `root` without failing on it, and a project that has finished
378
+ // migrating sets `"regionRoot": "required"` and the frame becomes an error from then on.
379
+ const stronger =
380
+ `composed by a hand-written frame in ${where}, and it holds the page's own components ` +
381
+ `(${hostCalled.length ? hostCalled.join(', ') : 'islands only'}). Declaring \`root\` + \`slots\` on the ` +
382
+ `archipelago instead leaves ONE description — the page and the lagoon compose from the same map, ` +
383
+ `so they cannot differ`;
384
+ if (paths.regionRoot === 'required') report.error('region-root', `${stronger}. This project sets \`regionRoot: "required"\``);
385
+ else report.ok('region-root', stronger, hostCalled.length + composedBy.length);
386
+ return;
387
+ }
388
+ const detail = `${where}: ${findings.join('; ')} — a frame may hold only the application's own components, fragments and island(slot). What you look at in the lagoon is otherwise a drawing of the page, not the page`;
389
+ if (excused) report.warn('region-root', `${detail}. Excused: "${excused[2].trim()}"`);
390
+ else report.error('region-root', detail);
391
+ }