@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,330 @@
1
+ // A region's archipelago config: bind, writes, slots, ownership — the declaration itself.
2
+ // Split out of commands/verify.mjs; verify.mjs re-exports the public surface.
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { readRegions } from '../../lib/eject.mjs';
5
+ import { declaredSlotNames, frameModuleFor, nestedSlots } from '../../lib/lagoon-declares.mjs';
6
+ import { stubParity } from '../../lib/stubs.mjs';
7
+ import { HOST, LEGACY_FIT, paths } from '../../lib/util.mjs';
8
+ import { blocksAfter, boundKeyGroups, boundKeysIn, keysIn } from './island-static.mjs';
9
+ import { registeredTags } from './region-composition.mjs';
10
+
11
+ export function archipelagoConfigChecks(report, id) {
12
+ const archPath = paths.archipelagoFile(id);
13
+ if (!existsSync(archPath)) {
14
+ report.error('registered', `no archipelago at ${paths.rel(archPath)}`);
15
+ return;
16
+ }
17
+ const text = readFileSync(archPath, 'utf8');
18
+
19
+ // The region's declared shape (D8). Under a host that owns its own page state, the page and the
20
+ // archipelago otherwise name the same values twice with nothing linking them — acme's page called it
21
+ // `loadingReceived` while the store key was `receivedLoading`. Requiring the parameter makes the
22
+ // app's own type the single vocabulary, and a rename becomes a compile error.
23
+ //
24
+ // Skipped on an ocean: there is no app-side type to extract there — region state lives in `$scope`
25
+ // and motu declares it, so there is no second declaration and nothing to drift against.
26
+ if (LEGACY_FIT) {
27
+ report.skip('region-type', `region state is motu's on host '${HOST}' — no app-side type to reference`);
28
+ } else {
29
+ // Either declaration form carries it — `satisfies ArchipelagoConfig<R, …>` or `archipelago<R, …>()`
30
+ // — and the argument list can carry more than the region, so stop at the first `,` or `>`.
31
+ const param = text.match(/(?:ArchipelagoConfig|\barchipelago)<\s*([A-Za-z_$][\w$]*)\s*[,>]/);
32
+ if (param) {
33
+ report.ok('region-type', `bind keys are checked against \`${param[1]}\``);
34
+ } else if (!(readRegions(paths.archipelagosDir).find((r) => r.id === id)?.islands ?? []).length) {
35
+ // A REGION WITH NO ISLANDS HAS NO BIND KEYS TO CHECK. `archipelago init` (no page) scaffolds exactly
36
+ // that, so a new project's first `motu check` — run straight after the next-step the scaffolder
37
+ // prints — opened on an ERROR about the thing it had just been given. The demand is right the
38
+ // moment the region has a member; before that it is the tool failing its own output.
39
+ report.warn(
40
+ 'region-type',
41
+ 'no region type yet — declare it as `archipelago<TRegion, ElementTypes, ProducedKeys>()({…}, {…})` with a ' +
42
+ 'type EXTRACTED FROM THE APP before adding the first island, so bind keys cannot drift from it',
43
+ );
44
+ } else {
45
+ report.error(
46
+ 'region-type',
47
+ 'no region type — declare it as `archipelago<TRegion, ElementTypes, ProducedKeys>()({…}, {…})` with a type ' +
48
+ 'EXTRACTED FROM THE APP (no motu import, erases at runtime) so bind keys cannot drift from it',
49
+ );
50
+ }
51
+ }
52
+
53
+ // --- coupling: which members actually share state? -------------------------------------------
54
+ //
55
+ // An archipelago is a declared grouping of islands scattered across ONE PAGE. Most members couple
56
+ // with nothing — they are fed by props or read the backend themselves — and that is normal, not a
57
+ // smell: a page is a mix. So there is no rule here that a grouping must be coupled.
58
+ //
59
+ // What IS reported: a key written inside and read by no member. That means the coupling escapes the
60
+ // archipelago, and it is the check that found the real bug — `newReceivedCount` was written here and
61
+ // read by a button on the same page that had been left out because the boundary followed a DOM
62
+ // subtree rather than the page.
63
+ //
64
+ // A warning, not an error: under Model B the host page is a legitimate reader, so a key crossing to
65
+ // it is a design choice the rule names rather than forbids.
66
+ {
67
+ // Over CODE, not comments. `motu archipelago init` scaffolds its wiring examples as comments
68
+ // (`// bind: { someProp: 'someStoreKey' }`), so analysing the raw text reports a scaffolded TODO
69
+ // as real shared state — a green light for coupling that does not exist. Blank comments out,
70
+ // keeping newlines so any line number stays true.
71
+ const code = text
72
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
73
+ .replace(/\/\/[^\n]*/g, (m) => ' '.repeat(m.length));
74
+
75
+ // WHO OWNS WHAT (docs/plan-key-ownership.md). Three declarations, all static:
76
+ // writes: { ev: key } an island owns these, and the mapping is what makes them ejectable
77
+ // bind: { prop: key} who reads
78
+ // A `store.set` left inside an `on` handler still writes, but opaquely — it is counted as written
79
+ // and reported, because nothing can draw it or generate wiring from it.
80
+ const declaredWritten = new Set(keysIn(blocksAfter(code, 'writes:', '{')));
81
+ const opaqueWritten = new Set([...code.matchAll(/store\.set\(\s*['"]([^'"]+)['"]/g)].map((m) => m[1]));
82
+ const written = new Set([...declaredWritten, ...opaqueWritten]);
83
+ // Both forms of `bind`, PLUS declared `reads` — a key an island consumes straight from the host's
84
+ // own store, which has no prop to find. Without those, a region whose islands talk through the
85
+ // app's state looks uncoupled.
86
+ const declaredReads = [...code.matchAll(/\breads:\s*\[([^\]]*)\]/g)].flatMap((m) =>
87
+ [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1]),
88
+ );
89
+ const read = new Set([...boundKeysIn(code), ...declaredReads]);
90
+ // Host-fed is DERIVED — bound here, written by no island — with the explicit list still honoured
91
+ // for a key nothing binds.
92
+ // HOST-FED, DERIVED: read by an island, written by none. This used to union a declared
93
+ // `provides: [...]` on top, and that list is gone — it restated exactly this subtraction, had to be
94
+ // maintained by hand, and its only checks (`unowned`, `disputed`) were empty by construction:
95
+ // `unowned` subtracted a set that already covered every read key, and `disputed` needed a
96
+ // `provides` no region had written since the derivation existed.
97
+ const provided = new Set([...read].filter((k) => !written.has(k)));
98
+ const shared = [...written].filter((k) => read.has(k));
99
+
100
+ // Keys more than one island READS and no island writes: the host drives them, and two islands
101
+ // moving together because of one is a coupling even though motu owns neither end of it.
102
+ const readCount = new Map();
103
+ for (const group of [...boundKeyGroups(code), ...[...code.matchAll(/\breads:\s*\[([^\]]*)\]/g)].map((m) => [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1]))]) {
104
+ for (const k of new Set(group)) readCount.set(k, (readCount.get(k) ?? 0) + 1);
105
+ }
106
+ // n > 1, OR one island that DECLARED the read. `reads` is an explicit statement that this island
107
+ // depends on host state — the whole reason to write it down — so reporting it needs no second
108
+ // reader. Twenty's side panel is the case: one island, one key, and a coupling that is the point
109
+ // of the region.
110
+ const hostShared = [...readCount]
111
+ .filter(([k, n]) => !written.has(k) && (n > 1 || declaredReads.includes(k)))
112
+ .map(([k]) => k);
113
+
114
+ const orphanKeys = [...written].filter((k) => !read.has(k));
115
+ if (orphanKeys.length) {
116
+ report.warn(
117
+ 'coupling',
118
+ `key(s) written but read by no island here: ${orphanKeys.join(', ')} — the coupling ESCAPES this ` +
119
+ `archipelago. Either its reader is an island on this page that is not a member yet, or it is ` +
120
+ `cross-page, which the lagoon does not verify (D11) and the store does not survive`,
121
+ );
122
+ }
123
+ if (shared.length) {
124
+ report.ok('coupling', `shared state: ${shared.join(', ')}`, { n: shared.length, of: 'shared key(s)' });
125
+ } else if (hostShared.length) {
126
+ // NOT independent, and saying so mattered: Twenty's two record-page widgets both bind
127
+ // `editingWidgetId`, which the app writes, and driving the lagoon showed one flipping to
128
+ // "editing" while the other stayed idle — a visible coupling this check called independence,
129
+ // because `shared` is the intersection of ISLAND-written and island-read.
130
+ report.ok(
131
+ 'coupling',
132
+ `coupled through the host: ${hostShared.join(', ')} — read by more than one island, written by ` +
133
+ `none of them, so the application is what couples them and the lagoon must seed it to show them apart`,
134
+ { n: hostShared.length, of: 'host-driven key(s)' },
135
+ );
136
+ } else {
137
+ // Always say something. A check that reports nothing is indistinguishable from one that did not
138
+ // run — and "these islands are independent" is a real, useful answer, not an absence.
139
+ report.ok(
140
+ 'coupling',
141
+ 'no shared state between islands — a page whose islands are independent',
142
+ { n: readCount.size, of: 'key(s) read' },
143
+ );
144
+ }
145
+
146
+ // --- composition: a slot filled by another island must be one this region declares -------------
147
+ {
148
+ // PARSED, NOT MATCHED — and by the reader this file ALREADY IMPORTS and uses 400 lines above
149
+ // (`nestedSlots`, for `render-coverage`). One question, two implementations, in one file.
150
+ //
151
+ // What the regexes had to encode, and no longer do: that the region's own `slots` sits at two
152
+ // spaces of indent while a member's nested map is deeper (counting the region's made every root
153
+ // slot read as an island nested in another), and that a slot entry may take the object form
154
+ // `{ slot: 'auth-error', when: 'authError' }` where taking every quoted string read `when`'s
155
+ // region key as an undeclared slot — an error about a shape that was correct.
156
+ //
157
+ // `declaredSlotNames` reads the ISLAND ENTRIES rather than every `slot:` in the file, which the
158
+ // regex could not distinguish: it also counted the names inside the region-level map, so a slot
159
+ // referenced there and declared by no island entry passed the very check that exists to catch it.
160
+ const declaredSlots = declaredSlotNames(paths.archipelagoFile(id));
161
+ const filled = [...nestedSlots(paths.archipelagoFile(id))];
162
+ const missing = [...new Set(filled)].filter((slot) => !declaredSlots.has(slot));
163
+ if (missing.length) {
164
+ report.error(
165
+ 'composition',
166
+ `island slot(s) filled with an island this region does not declare: ${missing.join(', ')}`,
167
+ );
168
+ } else if (filled.length) {
169
+ report.ok('composition', 'every nested slot is one this region declares', { n: filled.length, of: 'nested island(s)' });
170
+ }
171
+ }
172
+
173
+ // --- ownership: every key an island READS should have exactly one declared owner --------------
174
+ //
175
+ // ONE PRODUCER, checked per island rather than over the file. `declaredWritten` is a Set, so two
176
+ // islands claiming the same key collapse into one entry and the region reported "4/4 bound key(s)
177
+ // owned" for a key with two owners — caught only later, at runtime, by the store guard.
178
+ //
179
+ // The timing is the point. Two agents working in parallel each add an island in their own branch;
180
+ // neither branch is wrong on its own, and the conflict appears where both declarations meet. If
181
+ // the region is declared UP FRONT (the survey writes every slot and owner before implementation),
182
+ // that meeting happens in each agent's own branch on their first `motu check` — which is why this
183
+ // has to be static, in the fast loop, and not a runtime gate they reach after the work is done.
184
+ // Grouped by ELEMENT, not by slot. acme caught the first version of this rule as a false positive:
185
+ // its filter panel is one island placed twice (`filters-desktop`, `filters-mobile`), both writing
186
+ // `filters` — which is exactly what "two slots, one island" has to mean, and the region's evidence
187
+ // says so on purpose. Two SLOTS may share a producer; two IMPLEMENTATIONS may not.
188
+ const writersByKey = new Map();
189
+ for (const island of readRegions(paths.archipelagosDir).find((r) => r.id === id)?.islands ?? []) {
190
+ for (const key of Object.values(island.writes ?? {})) {
191
+ const k = typeof key === 'string' ? key : null;
192
+ if (!k) continue;
193
+ writersByKey.set(k, [...(writersByKey.get(k) ?? []), { slot: island.slot, element: island.element }]);
194
+ }
195
+ }
196
+ for (const [key, writers] of writersByKey) {
197
+ const elements = [...new Set(writers.map((w) => w.element))];
198
+ if (elements.length < 2) continue;
199
+ report.error(
200
+ 'ownership',
201
+ `"${key}" is written by ${elements.length} different islands (${writers.map((w) => `${w.slot} → ${w.element}`).join(', ')}) — ` +
202
+ `a key has ONE producer. If both really write it, the producer is something they SHARE (their ` +
203
+ `container, the page) and that is what should declare it; if not, one of them is reading, not writing.\n` +
204
+ ` OPEN/CLOSE is the common shape here — one island opens a dialog and the dialog closes ` +
205
+ `itself. That is not two producers: the OWNER is whichever island the other sits inside, via ` +
206
+ `nested \`slots\`, and it keeps the key. Where the dialog cannot be nested, the host may put ` +
207
+ `the key back with \`seed(...)\` — that is sanctioned for a produced key (it re-establishes a ` +
208
+ `starting value), unlike \`provide()\`, which the region type refuses for exactly this key`,
209
+ );
210
+ }
211
+
212
+ const owned = [...read].filter((k) => provided.has(k) || written.has(k));
213
+ if (read.size) {
214
+ report.ok(
215
+ 'ownership',
216
+ `${owned.length}/${read.size} bound key(s) owned — ${[...read].filter((k) => provided.has(k)).length} host, ` +
217
+ `${[...read].filter((k) => declaredWritten.has(k)).length} island`,
218
+ );
219
+ }
220
+
221
+ // An opaque write is the static cousin of the laundering smell: it updates a key, but nothing can
222
+ // draw it in the region graph and no wiring can be generated from it when motu is removed.
223
+ const opaqueOnly = [...opaqueWritten].filter((k) => !declaredWritten.has(k));
224
+ if (opaqueOnly.length) {
225
+ report.warn(
226
+ 'ownership',
227
+ `key(s) written from a handler body: ${opaqueOnly.join(', ')} — declare \`writes: { <event>: '<key>' }\` ` +
228
+ `instead. A handler can do anything, so it cannot be drawn before it fires, and it cannot be ` +
229
+ `materialised when motu is ejected; \`on\` is for effects that are NOT store writes`,
230
+ );
231
+ }
232
+ }
233
+
234
+ // --- the lagoon's stand-ins still stand in ----------------------------------------------------
235
+ {
236
+ const results = stubParity();
237
+ const broken = results.filter((r) => r.missing.length || r.unresolved);
238
+ for (const r of broken) {
239
+ if (r.unresolved) {
240
+ report.error('host-stubs', `${r.specifier}: ${r.unresolved}`);
241
+ } else {
242
+ report.error(
243
+ 'host-stubs',
244
+ `${r.specifier}: islands import ${r.missing.map((m) => `\`${m}\``).join(', ')}, which the stub does not export — ` +
245
+ `the lagoon is standing in for a module it no longer mirrors`,
246
+ );
247
+ }
248
+ }
249
+ if (results.length && !broken.length) {
250
+ const covered = results.reduce((n, r) => n + r.needed.length, 0);
251
+ report.ok('host-stubs', `stubs cover the ${covered} export(s) the islands reach for`, { n: results.length, of: 'stub(s)' });
252
+ }
253
+ }
254
+
255
+ const registryText = existsSync(paths.archipelagosRegistry) ? readFileSync(paths.archipelagosRegistry, 'utf8') : '';
256
+ const archRegistered = [`./${id}/${id}.archipelago`].some(
257
+ (s) => registryText.includes(`${s}'`) || registryText.includes(`${s}.js'`) ||
258
+ registryText.includes(`${s}"`) || registryText.includes(`${s}.js"`),
259
+ );
260
+ if (archRegistered) {
261
+ report.ok('registered', 'registered in ARCHIPELAGOS');
262
+ } else {
263
+ report.error('registered', `not wired into ${paths.rel(paths.archipelagosRegistry)}`);
264
+ }
265
+
266
+ // Every `element: 'x-…'` the config references must be a registered island tag — EXCEPT the ones
267
+ // the survey declared and nobody has built yet. Those are the price of declaring a region up front
268
+ // so parallel work conflicts early; without this split the region is red in every branch until the
269
+ // last island lands, and a red region teaches everyone to ignore it.
270
+ const known = registeredTags();
271
+ const declared = readRegions(paths.archipelagosDir).find((r) => r.id === id)?.islands ?? [];
272
+ const plannedTags = new Set(declared.filter((i) => i.planned).map((i) => i.element));
273
+ const used = [...text.matchAll(/element:\s*['"]([^'"]+)['"]/g)].map((m) => m[1]).filter((t) => !plannedTags.has(t));
274
+ const unknown = [...new Set(used)].filter((t) => !known.has(t));
275
+
276
+ // The flag REMOVES ITSELF. Once the island exists, `planned` is no longer a promise, it is a stale
277
+ // claim — and a survey that quietly becomes a list of things nobody built is worse than no survey.
278
+ const built = [...plannedTags].filter((t) => known.has(t));
279
+ if (built.length) {
280
+ report.error(
281
+ 'planned',
282
+ `${built.join(', ')} is registered but still marked \`planned: true\` — drop the flag; the checks that ` +
283
+ `skip a planned island (does it exist, does it mount, is it placed) are exactly the ones it now needs`,
284
+ );
285
+ }
286
+ const pending = [...plannedTags].filter((t) => !known.has(t));
287
+ if (pending.length) {
288
+ report.warn(
289
+ 'planned',
290
+ `${pending.length} island(s) declared but not built: ${pending.join(', ')} — their ownership is being ` +
291
+ `enforced (a second claim on their keys fails), their existence is not checked`,
292
+ );
293
+ }
294
+ if (used.length === 0) {
295
+ // "declares no islands" is false when it declares four and nobody has built them yet — and a
296
+ // wrong message is how a reader learns to stop reading the report.
297
+ report[plannedTags.size ? 'skip' : 'warn'](
298
+ 'islands-registered',
299
+ plannedTags.size
300
+ ? `every declared island is still \`planned\` (${plannedTags.size}) — nothing to look for yet`
301
+ : 'archipelago declares no islands',
302
+ );
303
+ } else if (unknown.length === 0) {
304
+ report.ok('islands-registered', `all ${new Set(used).size} island tag(s) are registered`, {
305
+ n: new Set(used).size,
306
+ of: 'declared tag(s)',
307
+ });
308
+ } else {
309
+ report.error('islands-registered', `unknown island tag(s): ${unknown.join(', ')} — not in ELEMENT_REGISTRY`);
310
+ }
311
+
312
+ // Does this region have an arrangement to render?
313
+ //
314
+ // Two places it can live, and checking only the first is what made removing a duplicated template
315
+ // silently disable the whole-region render — the check that catches slot/config drift:
316
+ // 1. a `layout:` template in the archipelago config (the ocean's answer), or
317
+ // 2. the region's lagoon frame, i.e. the APPLICATION's own layout component, which is the right
318
+ // answer under a React host and the one that cannot drift.
319
+ // Genuinely layout-less still means "islands placed individually across the host", and still skips.
320
+ // `root:` — the region declares the APPLICATION's own component, which the lagoon composes from the
321
+ // same `slots` the page uses. The strongest form, and the one with no second copy anywhere.
322
+ if (/^\s*root\s*:/m.test(text)) return true;
323
+ if (/\blayout\s*:/.test(text)) return true;
324
+ const frame = frameModuleFor(id);
325
+ // Declared but unresolvable: assume it HAS an arrangement, so the render check runs and fails loudly
326
+ // if it does not. The opposite default is how this went quiet in the first place.
327
+ if (frame.declared && !frame.module) return true;
328
+ if (frame.module && /\blayout\s*:/.test(readFileSync(frame.module, 'utf8'))) return true;
329
+ return false;
330
+ }