@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.
- package/LICENSE +21 -0
- package/bundled/github/agents/archipelago-build.agent.md +298 -0
- package/bundled/github/agents/island-create.agent.md +261 -0
- package/bundled/github/agents/island-extract.agent.md +360 -0
- package/bundled/github/agents/island-locate.agent.md +208 -0
- package/bundled/github/agents/motu-checks.agent.md +158 -0
- package/bundled/github/agents/motu-evidence.agent.md +267 -0
- package/bundled/github/agents/motu-regions.agent.md +304 -0
- package/bundled/github/host-rules.md +43 -0
- package/bundled/github/prompts/archipelago-build.prompt.md +19 -0
- package/bundled/github/prompts/island-create.prompt.md +37 -0
- package/bundled/github/prompts/island-extract.prompt.md +59 -0
- package/bundled/github/prompts/island-locate.prompt.md +43 -0
- package/bundled/packages/adapters/angularjs/vite.mjs +43 -0
- package/bundled/packages/adapters/next/lagoon/next-stubs.tsx +108 -0
- package/bundled/packages/adapters/next/vite.mjs +121 -0
- package/bundled/packages/adapters/vite/vite.mjs +179 -0
- package/package.json +64 -3
- package/src/alias-loader.mjs +63 -0
- package/src/alias-register.mjs +28 -0
- package/src/asset-stub.cjs +3 -0
- package/src/asset-stub.mjs +5 -0
- package/src/cli.mjs +78 -0
- package/src/commands/adopt-root.mjs +194 -0
- package/src/commands/archipelago-init.mjs +314 -0
- package/src/commands/archipelago.mjs +138 -0
- package/src/commands/assurance.mjs +40 -0
- package/src/commands/check.mjs +346 -0
- package/src/commands/codegen.mjs +29 -0
- package/src/commands/coverage.mjs +358 -0
- package/src/commands/create.mjs +277 -0
- package/src/commands/defaults.mjs +129 -0
- package/src/commands/diff.mjs +595 -0
- package/src/commands/fixtures.mjs +153 -0
- package/src/commands/init.mjs +473 -0
- package/src/commands/integrate.mjs +208 -0
- package/src/commands/integration.mjs +1349 -0
- package/src/commands/lagoon-try.mjs +272 -0
- package/src/commands/lagoon.mjs +1419 -0
- package/src/commands/link.mjs +110 -0
- package/src/commands/region-coverage.mjs +604 -0
- package/src/commands/removal-check.mjs +878 -0
- package/src/commands/skills.mjs +156 -0
- package/src/commands/snapshot.mjs +456 -0
- package/src/commands/sync.mjs +38 -0
- package/src/commands/verify/island-runtime.mjs +581 -0
- package/src/commands/verify/island-static.mjs +586 -0
- package/src/commands/verify/island.mjs +135 -0
- package/src/commands/verify/reach.mjs +426 -0
- package/src/commands/verify/region-composition.mjs +391 -0
- package/src/commands/verify/region-config.mjs +330 -0
- package/src/commands/verify/region-flows.mjs +760 -0
- package/src/commands/verify/region-sources.mjs +516 -0
- package/src/commands/verify/region.mjs +271 -0
- package/src/commands/verify/report.mjs +338 -0
- package/src/commands/verify/screens-runtime.mjs +123 -0
- package/src/commands/verify.mjs +18 -0
- package/src/lagoon-build.mjs +25 -0
- package/src/lagoon-dev.mjs +32 -0
- package/src/lib/alias-core.mjs +49 -0
- package/src/lib/archipelagos.mjs +111 -0
- package/src/lib/assurance.mjs +144 -0
- package/src/lib/baselines.mjs +107 -0
- package/src/lib/bundlability.mjs +123 -0
- package/src/lib/changed.mjs +157 -0
- package/src/lib/component-props.mjs +145 -0
- package/src/lib/config.mjs +451 -0
- package/src/lib/contracts.mjs +244 -0
- package/src/lib/data-flow.mjs +47 -0
- package/src/lib/declared-sources.mjs +101 -0
- package/src/lib/dock-inject.mjs +47 -0
- package/src/lib/effects.mjs +97 -0
- package/src/lib/eject.mjs +715 -0
- package/src/lib/evidence-json.mjs +74 -0
- package/src/lib/expect-calls.mjs +53 -0
- package/src/lib/finding-memory.mjs +0 -0
- package/src/lib/flow-calls.mjs +53 -0
- package/src/lib/framework.mjs +98 -0
- package/src/lib/host-rules.mjs +55 -0
- package/src/lib/host-sources.mjs +121 -0
- package/src/lib/island-placement.mjs +64 -0
- package/src/lib/island-render.mjs +105 -0
- package/src/lib/islands.mjs +112 -0
- package/src/lib/lagoon-bundle.mjs +138 -0
- package/src/lib/lagoon-declares.mjs +553 -0
- package/src/lib/lagoon-materialize.mjs +217 -0
- package/src/lib/lagoon-vite.mjs +788 -0
- package/src/lib/live-url.mjs +53 -0
- package/src/lib/node-aliases.mjs +73 -0
- package/src/lib/operations.mjs +145 -0
- package/src/lib/provenance-plugin.mjs +74 -0
- package/src/lib/region-refs.mjs +40 -0
- package/src/lib/remote.mjs +171 -0
- package/src/lib/scaffold.mjs +606 -0
- package/src/lib/screens.mjs +266 -0
- package/src/lib/snapshots.mjs +85 -0
- package/src/lib/stubs.mjs +114 -0
- package/src/lib/ts-project.mjs +42 -0
- package/src/lib/util.mjs +626 -0
- package/src/lib/worlds.mjs +236 -0
- package/src/lib/write-if-changed.mjs +35 -0
- package/src/lib/writes-coverage.mjs +32 -0
- package/src/playwright-lagoon.mjs +2305 -0
- package/src/run.mjs +385 -0
- package/src/runtime-harness.mjs +225 -0
- 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
|
+
}
|