@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.
- 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,1349 @@
|
|
|
1
|
+
// `motu integrate check` — the LAST MILE: does the HOST actually use what verifies green?
|
|
2
|
+
//
|
|
3
|
+
// Everything else proves one side of the boundary. `island verify` proves an island renders and
|
|
4
|
+
// declares honestly; `archipelago verify` proves the region's wires carry; `removal-check` proves you
|
|
5
|
+
// can delete the whole thing. Nothing proved the step in between — that the application composes the
|
|
6
|
+
// region, places its slots and reads its state — so a region could pass every check while no browser
|
|
7
|
+
// had ever rendered it. That is not hypothetical: `directory` verified clean for a day while the page
|
|
8
|
+
// still rendered the components directly and nothing called `createRegion` at all.
|
|
9
|
+
//
|
|
10
|
+
// STATIC, because the facts are in source: the composition root, the slot markers, and the page's own
|
|
11
|
+
// state. It answers "is this wired?", which is the question that blocks adoption; the runtime half
|
|
12
|
+
// ("does the wired page hold the same values it held before") needs the host's own dev server and is
|
|
13
|
+
// a separate lane.
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
16
|
+
import { sep } from 'node:path';
|
|
17
|
+
import { blankComments, color, paths, HOST_ROOT, APP_ROOT, resolveAppImport } from '../lib/util.mjs';
|
|
18
|
+
import { readRegions } from '../lib/eject.mjs';
|
|
19
|
+
import { SyntaxKind } from 'ts-morph';
|
|
20
|
+
import { declaredSourcesOf } from '../lib/declared-sources.mjs';
|
|
21
|
+
import { rootSlots, regionRefsDeclared } from '../lib/lagoon-declares.mjs';
|
|
22
|
+
import { slotNameOfElement } from '../lib/island-placement.mjs';
|
|
23
|
+
import { sourceFileAt } from '../lib/ts-project.mjs';
|
|
24
|
+
import { hostSourceFiles, describeSources } from '../lib/host-sources.mjs';
|
|
25
|
+
import { loadMotuConfig } from '../lib/config.mjs';
|
|
26
|
+
|
|
27
|
+
/** `client-portfolio` -> `clientPortfolioArchipelago`, the const an archipelago file exports. */
|
|
28
|
+
function archConst(id) {
|
|
29
|
+
const camel = id.split(/[-_]/).map((w, i) => (i === 0 ? w : w[0].toUpperCase() + w.slice(1))).join('');
|
|
30
|
+
return `${camel}Archipelago`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The source ids one host file installs with `channelFrom`, for a given region.
|
|
35
|
+
*
|
|
36
|
+
* PARSED, for the reason the rest of this file is: `args: [port, { now }]` ends in a brace, and a
|
|
37
|
+
* regex reaching for the call's closing `})` stops on the wrong one. Reading the call expression
|
|
38
|
+
* costs nothing here — the file is already in the project for the placement scan.
|
|
39
|
+
*
|
|
40
|
+
* FILTERED BY REGION, because a composition root may compose more than one: `to` names the
|
|
41
|
+
* archipelago const, so a channel for somebody else's region does not answer for this one. A `to`
|
|
42
|
+
* that is not a plain identifier (assembled, re-exported, aliased) is counted rather than dropped —
|
|
43
|
+
* this check exists to say the page installs SOMETHING for that id, and a false red here is what
|
|
44
|
+
* sent it looking for an import that does not exist.
|
|
45
|
+
*/
|
|
46
|
+
export function channelSourceIds(file, regionId) {
|
|
47
|
+
let sf;
|
|
48
|
+
try {
|
|
49
|
+
sf = sourceFileAt(file, { allowJs: true, jsx: 4 });
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
const wanted = archConst(regionId);
|
|
54
|
+
const ids = [];
|
|
55
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
56
|
+
if (call.getExpression().getText() !== 'channelFrom') continue;
|
|
57
|
+
const spec = call.getArguments()[0];
|
|
58
|
+
if (spec?.getKind() !== SyntaxKind.ObjectLiteralExpression) continue;
|
|
59
|
+
const to = spec.getProperty('to')?.getInitializer?.();
|
|
60
|
+
if (to && to.getKind() === SyntaxKind.Identifier && to.getText() !== wanted) continue;
|
|
61
|
+
const id = spec.getProperty('id')?.getInitializer?.();
|
|
62
|
+
if (!id) continue;
|
|
63
|
+
if (id.getKind() === SyntaxKind.StringLiteral || id.getKind() === SyntaxKind.NoSubstitutionTemplateLiteral)
|
|
64
|
+
ids.push(id.getText().replace(/^['"`]|['"`]$/g, ''));
|
|
65
|
+
}
|
|
66
|
+
return ids;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Every host source file, as text.
|
|
71
|
+
*
|
|
72
|
+
* The roots come from `hostSourceFiles` — the host's own tsconfig where it has one, so this and
|
|
73
|
+
* `removal-check` cannot disagree about what "the application" is. This check used to hardcode the
|
|
74
|
+
* five-directory guess and IGNORE `hostSources` while its own failure message told you to set it;
|
|
75
|
+
* both now go through the one resolver.
|
|
76
|
+
*
|
|
77
|
+
* The motu app root is excluded: a region composed inside motu's own folder is not the application
|
|
78
|
+
* using it, it is motu talking to itself.
|
|
79
|
+
*/
|
|
80
|
+
export function hostSources() {
|
|
81
|
+
const files = new Map();
|
|
82
|
+
const resolved = hostSourceFiles(HOST_ROOT, loadMotuConfig());
|
|
83
|
+
// The directories motu writes into. Everything else under the host root is the application's.
|
|
84
|
+
//
|
|
85
|
+
// EXCLUDE MOTU'S OWN FILES, not the whole app. `APP_ROOT` is the motu app root, and when motu owns
|
|
86
|
+
// the repository (`"app": "."`, the natural greenfield shape) it EQUALS the host root — so
|
|
87
|
+
// excluding that skipped every file, scanned nothing, and reported "no createRegion in the host"
|
|
88
|
+
// about a host it had never opened. Excluding the directories motu actually owns keeps the check
|
|
89
|
+
// meaningful in both layouts.
|
|
90
|
+
const motuOwned = [paths.islandsDir, paths.archipelagosDir, paths.uiRoot, paths.lagoonDir].filter(Boolean);
|
|
91
|
+
for (const full of resolved.files) {
|
|
92
|
+
if (motuOwned.some((d) => full === d || full.startsWith(d + sep))) continue;
|
|
93
|
+
try {
|
|
94
|
+
files.set(full, readFileSync(full, 'utf8'));
|
|
95
|
+
} catch {
|
|
96
|
+
/* unreadable is not a placement */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// The ORIGIN travels with the answer: a scan that found nothing has to be able to say what it
|
|
100
|
+
// trusted, or "0 files" is indistinguishable from "the app is empty".
|
|
101
|
+
files.origin = resolved.origin;
|
|
102
|
+
files.detail = resolved.detail;
|
|
103
|
+
return files;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Comments blanked, so a commented-out `<X.Island>` never counts as a placement. */
|
|
107
|
+
const code = blankComments;
|
|
108
|
+
|
|
109
|
+
/** Every store key an island produces (the `writes` values, both shapes). */
|
|
110
|
+
/** Where a JSX opening tag ends, from its `<` — so an attribute scan stops at the element it is on. */
|
|
111
|
+
function closingOf(text, from) {
|
|
112
|
+
let depth = 0;
|
|
113
|
+
for (let i = from; i < text.length; i++) {
|
|
114
|
+
const ch = text[i];
|
|
115
|
+
if (ch === '{') depth++;
|
|
116
|
+
else if (ch === '}') depth--;
|
|
117
|
+
else if (ch === '>' && depth === 0) return i - from + 1;
|
|
118
|
+
}
|
|
119
|
+
return text.length - from;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The archipelago's prop -> slot map, when it declares a `root`; null when it does not.
|
|
124
|
+
*
|
|
125
|
+
* Read from the file's text like everything else here: this module never imports an archipelago, and
|
|
126
|
+
* a `root` is a component reference, so importing one would drag the application's UI into the CLI.
|
|
127
|
+
*/
|
|
128
|
+
function rootSlotMap(id) {
|
|
129
|
+
// PARSED, NOT MATCHED. This was two alternating regexes, anchored at two spaces of indent, and the
|
|
130
|
+
// comment they carried is the whole argument: the multi-line one "ran on to the next line beginning
|
|
131
|
+
// with `},` — swallowing the island entries below and reading their `bind` pairs as slot mappings.
|
|
132
|
+
// The page's own props then matched those invented names, and five props of one card were reported
|
|
133
|
+
// as slots the archipelago had never declared." The one-line pattern was added to fix a different
|
|
134
|
+
// shape of the same failure. `rootSlots` is a property lookup and has neither.
|
|
135
|
+
return rootSlots(paths.archipelagoFile(id));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function producedKeys(region) {
|
|
139
|
+
const keys = new Set();
|
|
140
|
+
for (const island of region.islands) {
|
|
141
|
+
for (const target of Object.values(island.writes ?? {})) {
|
|
142
|
+
if (typeof target === 'string') keys.add(target);
|
|
143
|
+
else for (const key of Object.values(target)) keys.add(key);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return keys;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A root prop ANOTHER REGION fills (`slots: { prop: { region: 'x' } }`): does the page put that
|
|
151
|
+
* region's composition there?
|
|
152
|
+
*
|
|
153
|
+
* Not an island of this region, so `placed` has nothing to say about it — and saying nothing is how a
|
|
154
|
+
* slot ends up empty in the application while both regions verify green on their own. The child's
|
|
155
|
+
* own `composed`/`mounted` rows say it is composed SOMEWHERE; this says it is composed HERE.
|
|
156
|
+
*
|
|
157
|
+
* Accepted: the child's `<Y.Region>` or `<Y.Root>` written straight into the prop, or an application
|
|
158
|
+
* component in the prop whose own file renders `<Y.Region>` (the page keeping the child's wiring in a
|
|
159
|
+
* component of its own is ordinary and fine). A prop that is passed but holds neither is a WARNING —
|
|
160
|
+
* the composition may be further away than this text scan follows; a prop never passed is an ERROR.
|
|
161
|
+
*/
|
|
162
|
+
export function regionRefPlacement(region, rootNames, rootTags, sources, add) {
|
|
163
|
+
const refs = regionRefsDeclared(region.file);
|
|
164
|
+
if (refs === null) {
|
|
165
|
+
add('warn', 'region-ref', `${paths.rel(region.file)} could not be parsed, so its region references were not checked against the page`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
for (const [prop, childId] of Object.entries(refs)) {
|
|
169
|
+
const childConst = archConst(childId);
|
|
170
|
+
let childBinding = null;
|
|
171
|
+
let childFile = null;
|
|
172
|
+
for (const [file, text] of sources) {
|
|
173
|
+
const m = code(text).match(new RegExp(`const\\s+(\\w+)\\s*=\\s*createRegion\\(\\s*${childConst}\\b`));
|
|
174
|
+
if (m) {
|
|
175
|
+
childBinding = m[1];
|
|
176
|
+
childFile = file;
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (!childBinding) {
|
|
181
|
+
add(
|
|
182
|
+
'error',
|
|
183
|
+
'region-ref',
|
|
184
|
+
`${prop} is filled by region "${childId}", and the host never composes it — no \`createRegion(${childConst})\`, ` +
|
|
185
|
+
`so the slot cannot hold it`,
|
|
186
|
+
);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
// The child's composition, under every name it is re-exported as (an RSC page renders an alias).
|
|
190
|
+
const childNames = [`${childBinding}.Region`, `${childBinding}.Root`];
|
|
191
|
+
for (const [, alias] of code(readFileSync(childFile, 'utf8')).matchAll(
|
|
192
|
+
new RegExp(`const\\s+(\\w+)\\s*=\\s*${childBinding}\\.(?:Region|Root)\\b`, 'g'),
|
|
193
|
+
)) {
|
|
194
|
+
childNames.push(alias);
|
|
195
|
+
}
|
|
196
|
+
const holds = (text) => childNames.some((n) => new RegExp(`<${n.replace('.', '\\.')}\\b`).test(text));
|
|
197
|
+
|
|
198
|
+
const values = [];
|
|
199
|
+
for (const { file, tag, children } of rootTags) {
|
|
200
|
+
if (prop === 'children') {
|
|
201
|
+
if (children.trim()) values.push({ file, text: children });
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const at = tag.search(new RegExp(`(^|\\s)${prop}\\s*=\\s*\\{`));
|
|
205
|
+
if (at === -1) continue;
|
|
206
|
+
const open = tag.indexOf('{', at);
|
|
207
|
+
let depth = 0;
|
|
208
|
+
let end = open;
|
|
209
|
+
for (; end < tag.length; end++) {
|
|
210
|
+
if (tag[end] === '{') depth++;
|
|
211
|
+
else if (tag[end] === '}' && --depth === 0) break;
|
|
212
|
+
}
|
|
213
|
+
values.push({ file, text: tag.slice(open, end + 1) });
|
|
214
|
+
}
|
|
215
|
+
if (!values.length) {
|
|
216
|
+
add(
|
|
217
|
+
'error',
|
|
218
|
+
'region-ref',
|
|
219
|
+
`<${rootNames[0]}> never passes ${prop}, which region "${childId}" fills — the slot is empty in the ` +
|
|
220
|
+
`application. Pass ${prop}={<${childBinding}.Region><${childBinding}.Root /></${childBinding}.Region>}`,
|
|
221
|
+
);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const direct = values.find((v) => holds(v.text));
|
|
225
|
+
if (direct) {
|
|
226
|
+
add('ok', 'region-ref', `${prop} holds region "${childId}" (<${childNames.find((n) => holds(direct.text))}>) in ${paths.rel(direct.file)}`);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
// One component away: `${prop}={<EquipagePanel />}`, where EquipagePanel's own file renders it.
|
|
230
|
+
const components = [...new Set(values.flatMap((v) => [...v.text.matchAll(/<([A-Z][\w$]*)/g)].map((m) => m[1])))];
|
|
231
|
+
const via = [...sources].find(
|
|
232
|
+
([, text]) =>
|
|
233
|
+
holds(code(text)) &&
|
|
234
|
+
components.some((c) => new RegExp(`(?:function|const|let|class)\\s+${c}\\b`).test(code(text))),
|
|
235
|
+
);
|
|
236
|
+
if (via) {
|
|
237
|
+
add('ok', 'region-ref', `${prop} holds region "${childId}" through a component rendered from ${paths.rel(via[0])}`);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
add(
|
|
241
|
+
'warn',
|
|
242
|
+
'region-ref',
|
|
243
|
+
`${prop} is passed, but nothing in it renders <${childNames[0]}> — region "${childId}" is declared to fill ` +
|
|
244
|
+
`this slot, and the page may be putting something else there. Place <${childBinding}.Region> in it ` +
|
|
245
|
+
`(directly, or in a component whose own file renders it)`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** One region's integration, as data. */
|
|
251
|
+
function checkRegion(region, sources) {
|
|
252
|
+
const findings = [];
|
|
253
|
+
const add = (level, check, msg) => findings.push({ level, check, msg });
|
|
254
|
+
const constName = archConst(region.id);
|
|
255
|
+
const rel = (f) => relative(HOST_ROOT, f);
|
|
256
|
+
|
|
257
|
+
// MEMBERSHIP FIRST when it is DATA. A catalogue region's members are decided at runtime, so nothing
|
|
258
|
+
// about them depends on the composition root existing — and the questions worth asking (does every
|
|
259
|
+
// declared type have a contract?) are most useful during adoption, before anything is wired.
|
|
260
|
+
// Planned islands are surveyed, not built: nothing places a slot whose island does not exist.
|
|
261
|
+
const liveIslands = region.islands.filter((i) => !i.planned);
|
|
262
|
+
const catalogueMembers = region.membership === 'catalogue' ? liveIslands.filter((i) => i.member) : [];
|
|
263
|
+
const placedIslands = region.membership === 'catalogue' ? liveIslands.filter((i) => !i.member) : liveIslands;
|
|
264
|
+
|
|
265
|
+
if (region.membership === 'catalogue') {
|
|
266
|
+
add(
|
|
267
|
+
'ok',
|
|
268
|
+
'catalogue',
|
|
269
|
+
`${catalogueMembers.length} member type(s) summoned by data (${catalogueMembers.map((i) => i.member).join(', ')}), ` +
|
|
270
|
+
`${placedIslands.length} island(s) still placed in source — run \`motu verify catalogue\` against a capture ` +
|
|
271
|
+
`to learn whether the declared types are the types the data produces`,
|
|
272
|
+
);
|
|
273
|
+
const withoutContract = catalogueMembers.filter((i) => !i.writes || !Object.keys(i.writes).length);
|
|
274
|
+
if (withoutContract.length) {
|
|
275
|
+
add(
|
|
276
|
+
'warn',
|
|
277
|
+
'contract',
|
|
278
|
+
`${withoutContract.map((i) => i.slot).join(', ')} declare no \`writes\` — a catalogue member that ` +
|
|
279
|
+
`owns nothing is fine, but check it is not simply undeclared`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 1 — COMPOSED? `createRegion(<id>Archipelago)` somewhere in the host.
|
|
285
|
+
let binding = null;
|
|
286
|
+
let bindingFile = null;
|
|
287
|
+
for (const [file, text] of sources) {
|
|
288
|
+
const m = code(text).match(new RegExp(`const\\s+(\\w+)\\s*=\\s*createRegion\\(\\s*${constName}\\b`));
|
|
289
|
+
if (m) {
|
|
290
|
+
binding = m[1];
|
|
291
|
+
bindingFile = file;
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (!binding) {
|
|
296
|
+
add(
|
|
297
|
+
'error',
|
|
298
|
+
'composed',
|
|
299
|
+
`no \`createRegion(${constName})\` in the host — the region is declared and verified, but the ` +
|
|
300
|
+
`application never composes it, so nothing it promises has ever run outside the lagoon`,
|
|
301
|
+
);
|
|
302
|
+
return { id: region.id, findings };
|
|
303
|
+
}
|
|
304
|
+
add('ok', 'composed', `${binding} = createRegion(${constName}) in ${rel(bindingFile)}`);
|
|
305
|
+
|
|
306
|
+
// 1a — WHERE THE ARCHIPELAGO CAME FROM, which decides whether this file can be evaluated at all.
|
|
307
|
+
//
|
|
308
|
+
// Taking the archipelago from the SAME module that supplies the island registry means taking it
|
|
309
|
+
// from the project barrel, and a barrel that exports the registry necessarily pulls in every
|
|
310
|
+
// island. Any island whose view reaches an application page, and that page composing a region,
|
|
311
|
+
// closes a cycle back to this file — and then `createRegion(<archipelago>)` runs while the
|
|
312
|
+
// archipelago is still in its temporal dead zone.
|
|
313
|
+
//
|
|
314
|
+
// That is not hypothetical. On acme it was eight hops:
|
|
315
|
+
// directory-fiche root -> barrel -> islands/registry -> week-actions.island -> WeekActionsView
|
|
316
|
+
// -> BoosterStrip -> mission-helpers -> directory-member-fiche -> directory-fiche root
|
|
317
|
+
// The whole lagoon is one module graph, so one module throwing took EVERY region blank, not just
|
|
318
|
+
// that one, and the page said nothing at all.
|
|
319
|
+
//
|
|
320
|
+
// The fix is one line: import the archipelago from its own module. The registry may still come
|
|
321
|
+
// from wherever it likes — it is the ARCHIPELAGO's path that has to be short.
|
|
322
|
+
//
|
|
323
|
+
// A WARNING, not an error: the rule is new, regions predate it, and a project whose islands never
|
|
324
|
+
// reach a page is genuinely fine.
|
|
325
|
+
const bindingSrc = code(readFileSync(bindingFile, 'utf8'));
|
|
326
|
+
const archImport = bindingSrc.match(new RegExp(`import\\s*\\{([^}]*\\b${constName}\\b[^}]*)\\}\\s*from\\s*['"]([^'"]+)['"]`));
|
|
327
|
+
if (archImport && /\bELEMENT_REGISTRY\b|\bREGISTRY\b/.test(archImport[1])) {
|
|
328
|
+
add(
|
|
329
|
+
'warn',
|
|
330
|
+
'root-imports',
|
|
331
|
+
`${rel(bindingFile)} takes ${constName} from '${archImport[2]}' — the same module it takes the ` +
|
|
332
|
+
`island registry from, which is the project barrel. That barrel pulls in every island, so an ` +
|
|
333
|
+
`island reaching an application page that composes a region closes a cycle back to this file ` +
|
|
334
|
+
`and ${constName} is read before it is initialised. Import it from its own module instead; ` +
|
|
335
|
+
`the registry can stay where it is.`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 1b — MOUNTED? a composition root nobody renders is a binding, not an integration. The wrapper is
|
|
340
|
+
// often re-exported under the page's own name (`export const MotuRegion = Actions.Region`), so
|
|
341
|
+
// take every name that resolves to it and look for any of them in the host's JSX.
|
|
342
|
+
const wrappers = [`${binding}.Region`];
|
|
343
|
+
for (const [, alias] of code(readFileSync(bindingFile, 'utf8')).matchAll(new RegExp(`const\\s+(\\w+)\\s*=\\s*${binding}\\.Region`, 'g'))) {
|
|
344
|
+
wrappers.push(alias);
|
|
345
|
+
}
|
|
346
|
+
// THE MEMBER FORM IS NOT THE ONLY ONE, and on a React Server Component host it is not even legal.
|
|
347
|
+
//
|
|
348
|
+
// A server component may RENDER a client component; it may not read a property off a client
|
|
349
|
+
// module's export. So `<Login.Island slot="…">` cannot appear in an RSC page at all — the
|
|
350
|
+
// composition root has to re-export it (`export const LoginIsland = Login.Island`) and the page
|
|
351
|
+
// renders that name. This scan knew only the member form, so acme's sign-in page — the project's
|
|
352
|
+
// first server-component region — reported its one island as never placed while it was placed,
|
|
353
|
+
// seeded and rendering. Take every name that resolves to the binding's Island, exactly as
|
|
354
|
+
// `wrappers` above already does for its Region.
|
|
355
|
+
const islandNames = [`${binding}.Island`];
|
|
356
|
+
for (const [, alias] of code(readFileSync(bindingFile, 'utf8')).matchAll(
|
|
357
|
+
new RegExp(`const\\s+(\\w+)\\s*=\\s*${binding}\\.Island\\b`, 'g'),
|
|
358
|
+
)) {
|
|
359
|
+
islandNames.push(alias);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// EVERY source, INCLUDING the one that composed it. This excluded `bindingFile`, on the assumption
|
|
363
|
+
// that a region is composed in one module and rendered from another — true of a Next app, and false
|
|
364
|
+
// of any application small enough to do both in its page. The review console does exactly that:
|
|
365
|
+
// `const Review = createRegion(...)` at module scope and `<Review.Region>` in the component below
|
|
366
|
+
// it, in one file, which is the shape motu's own scaffolding produces. It reported "nothing renders
|
|
367
|
+
// <Review.Region>" about a file containing `<Review.Region>`.
|
|
368
|
+
//
|
|
369
|
+
// Nothing is lost by including it: the claim is "someone renders this wrapper", and the alias
|
|
370
|
+
// declaration that prompted the exclusion (`const X = Review.Region`) has no `<` in front of it.
|
|
371
|
+
const mountedIn = [...sources].filter(([, t]) => wrappers.some((w) => code(t).includes(`<${w}`)));
|
|
372
|
+
if (mountedIn.length) add('ok', 'mounted', `<${wrappers[wrappers.length - 1]}> renders in ${rel(mountedIn[0][0])}`);
|
|
373
|
+
else
|
|
374
|
+
add(
|
|
375
|
+
'error',
|
|
376
|
+
'mounted',
|
|
377
|
+
`nothing renders <${wrappers.join('> or <')}> — the region is composed but no page wraps its content in it, ` +
|
|
378
|
+
`so the store exists and no island ever mounts`,
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
// 2 — PLACED? every declared slot marked in the host's own JSX. In a catalogue region this covers
|
|
382
|
+
// the chrome only: an island summoned by a data row has no `<X.Island slot>` to find, and
|
|
383
|
+
// demanding one would report the app's own design as an error.
|
|
384
|
+
{
|
|
385
|
+
// PARSED, NOT MATCHED. A regex over JSX cannot tell an element from a string that looks like one,
|
|
386
|
+
// cannot follow a tag across a line break, and reports the same answer for a placement inside a
|
|
387
|
+
// comment as for a real one. It also cannot see ORDER, which is the thing a page can silently
|
|
388
|
+
// change: swapping two valid slots type-checks (both names are declared), renders, and passes every
|
|
389
|
+
// motu check — measured, including `archipelago snapshot`, because the snapshot pictures the REGION
|
|
390
|
+
// through the lagoon's frame and never the page.
|
|
391
|
+
//
|
|
392
|
+
// ts-morph is how every HOST-SOURCE question here is answered. This was the last regex standing in
|
|
393
|
+
// the middle of THAT — a claim that got read as "the last regex", which it never was: the
|
|
394
|
+
// ARCHIPELAGO side was still matched, in this file and in `verify.mjs`, and the two readers had
|
|
395
|
+
// drifted. Both are parsed now and shared — `sources` in `lib/declared-sources.mjs`, `slots` in
|
|
396
|
+
// `lib/lagoon-declares.mjs` beside `nestedSlots`, which this repo already had and used elsewhere.
|
|
397
|
+
//
|
|
398
|
+
// The lesson is the claim, not the regexes: "the last one" was true of the question in front of the
|
|
399
|
+
// author and false of the file, and it is why nobody looked again for a year.
|
|
400
|
+
const placed = new Map();
|
|
401
|
+
const order = [];
|
|
402
|
+
for (const [file, text] of sources) {
|
|
403
|
+
let sf;
|
|
404
|
+
try {
|
|
405
|
+
sf = sourceFileAt(file);
|
|
406
|
+
} catch {
|
|
407
|
+
sf = null;
|
|
408
|
+
}
|
|
409
|
+
if (!sf) {
|
|
410
|
+
// Unparseable (a syntax the host's own build accepts and ts-morph does not): say so rather than
|
|
411
|
+
// silently reporting the file as placing nothing.
|
|
412
|
+
add('warn', 'placed', `${paths.rel(file)} could not be parsed, so its placements were not read`);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const elements = [
|
|
416
|
+
...sf.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
|
|
417
|
+
...sf.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
|
|
418
|
+
];
|
|
419
|
+
for (const el of elements) {
|
|
420
|
+
const tag = el.getTagNameNode().getText();
|
|
421
|
+
if (!islandNames.includes(tag)) continue;
|
|
422
|
+
const attr = el.getAttribute?.('slot');
|
|
423
|
+
const init = attr?.getInitializer?.();
|
|
424
|
+
// THE RULE LIVES IN `island-placement.mjs` now, because `verify`'s island-composition asks the
|
|
425
|
+
// same question and used to answer it with a regex. Applied here inline because this loop also
|
|
426
|
+
// needs the ELEMENT (for props and conditional placement); the shared module is what keeps the
|
|
427
|
+
// two from disagreeing about what counts as a named slot.
|
|
428
|
+
const slot = slotNameOfElement(init, attr);
|
|
429
|
+
if (slot === null) {
|
|
430
|
+
add('warn', 'placed', `${paths.rel(file)} places an island with a computed \`slot\`, which cannot be attributed`);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
if (slot === undefined) continue;
|
|
434
|
+
placed.set(slot, [...(placed.get(slot) ?? []), file]);
|
|
435
|
+
order.push({ slot, file, pos: el.getStart() });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// TWO PLACEMENTS OF ONE SLOT is a real defect and the regex could not report it: the page renders
|
|
440
|
+
// the island twice, both copies bind the same keys, and whichever writes last wins.
|
|
441
|
+
for (const [slot, files] of placed) {
|
|
442
|
+
if (files.length > 1) {
|
|
443
|
+
add('error', 'placed', `slot '${slot}' is placed ${files.length} times (${[...new Set(files.map((f) => paths.rel(f)))].join(', ')}) — two copies of one island share its declared keys, and the later write wins`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// THE ORDER THE PAGE PLACES THEM IN, remembered between runs. motu cannot know which order is
|
|
448
|
+
// RIGHT — in a thin-overlay region the arrangement is the page's own business — but it can say that
|
|
449
|
+
// it moved, which is the one thing nothing else here notices. (`root` removes this question
|
|
450
|
+
// entirely: the arrangement is then declared once and the page cannot reorder it.)
|
|
451
|
+
const sequence = order
|
|
452
|
+
.sort((a, b) => (a.file === b.file ? a.pos - b.pos : a.file.localeCompare(b.file)))
|
|
453
|
+
.map((o) => o.slot)
|
|
454
|
+
.join(' → ');
|
|
455
|
+
if (sequence) {
|
|
456
|
+
// DRIFT, NOT CORRECTNESS. motu cannot know which order is right — in a thin-overlay region the
|
|
457
|
+
// arrangement is the page's own business — but it can say that it CHANGED, and nothing else
|
|
458
|
+
// notices: swapping two declared slots type-checks (both names are valid), renders, passes every
|
|
459
|
+
// flow, and passes `archipelago snapshot`, because the snapshot pictures the region through the
|
|
460
|
+
// lagoon's frame and never the page. Measured on a real screen: the search box moved below the
|
|
461
|
+
// list and every motu check stayed green.
|
|
462
|
+
//
|
|
463
|
+
// Remembered per region, beside the other caches. A first run records and says nothing — there is
|
|
464
|
+
// no drift from an order nobody has seen before.
|
|
465
|
+
const file = resolve(paths.cacheDir ?? resolve(paths.root ?? '.', '.motu/cache'), 'placement-order.json');
|
|
466
|
+
let seen = {};
|
|
467
|
+
try {
|
|
468
|
+
seen = JSON.parse(readFileSync(file, 'utf8'));
|
|
469
|
+
} catch {}
|
|
470
|
+
const previous = seen[region.id];
|
|
471
|
+
if (previous && previous !== sequence) {
|
|
472
|
+
add(
|
|
473
|
+
'warn',
|
|
474
|
+
'placement-order',
|
|
475
|
+
`the page's slot order changed: was \`${previous}\`, now \`${sequence}\` — motu cannot say which is ` +
|
|
476
|
+
`right, only that the ARRANGEMENT moved and no other check sees it. If this was intended, this ` +
|
|
477
|
+
`warning is spent by the next run; if it was not, the page now renders the region in a ` +
|
|
478
|
+
`different shape than the one that was reviewed. Declaring \`root\` removes the question.`,
|
|
479
|
+
);
|
|
480
|
+
} else {
|
|
481
|
+
add('ok', 'placement-order', `the page places: ${sequence}`);
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
485
|
+
writeFileSync(file, JSON.stringify({ ...seen, [region.id]: sequence }));
|
|
486
|
+
} catch {}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// A ROOT REGION places its islands BY PROP NAME. `<X.Root results={…} />` is the page saying it
|
|
490
|
+
// wants the island the archipelago maps to `results`, and it never writes the slot — that is the
|
|
491
|
+
// point of `root`, and it is why the scan above finds nothing on such a page.
|
|
492
|
+
//
|
|
493
|
+
// Whether a prop is passed is the whole check here, because `Root` deliberately does NOT fill in a
|
|
494
|
+
// slot the page left out. Filling it in would turn a forgotten prop into a page that quietly
|
|
495
|
+
// renders something different from the region everyone previewed; leaving it out and saying so
|
|
496
|
+
// puts the decision in front of a human. (The lagoon mounts every declared slot regardless — there
|
|
497
|
+
// is no page there to have an opinion.)
|
|
498
|
+
const rootSlots = rootSlotMap(region.id);
|
|
499
|
+
if (rootSlots) {
|
|
500
|
+
const rootNames = [`${binding}.Root`];
|
|
501
|
+
for (const [, alias] of code(readFileSync(bindingFile, 'utf8')).matchAll(
|
|
502
|
+
new RegExp(`const\\s+(\\w+)\\s*=\\s*${binding}\\.Root\\b`, 'g'),
|
|
503
|
+
)) {
|
|
504
|
+
rootNames.push(alias);
|
|
505
|
+
}
|
|
506
|
+
let rootRendered = false;
|
|
507
|
+
/** Every `<X.Root …>` found, with its attribute text and — when paired — its children's text. */
|
|
508
|
+
const rootTags = [];
|
|
509
|
+
for (const [file, text] of sources) {
|
|
510
|
+
const t = code(text);
|
|
511
|
+
for (const name of rootNames) {
|
|
512
|
+
const at = t.indexOf(`<${name}`);
|
|
513
|
+
if (at === -1) continue;
|
|
514
|
+
rootRendered = true;
|
|
515
|
+
// The element's own attribute region: up to the matching `/>` or `>`, whichever ends it.
|
|
516
|
+
const tag = t.slice(at, at + Math.max(0, closingOf(t, at)));
|
|
517
|
+
const close = tag.trimEnd().endsWith('/>') ? -1 : t.indexOf(`</${name}>`, at + tag.length);
|
|
518
|
+
rootTags.push({ file, tag, children: close === -1 ? '' : t.slice(at + tag.length, close) });
|
|
519
|
+
// A SPREAD is still the page passing props — `{...(cond ? { hero } : { hero, frozenBanner })}`
|
|
520
|
+
// is how a page keeps a whole branch in one expression, and reading only `prop=` reported four
|
|
521
|
+
// of the ambassador region's slots as never placed on a page that places all four.
|
|
522
|
+
const spreads = [...tag.matchAll(/\{\.\.\.([\s\S]*?)\}\s*(?=\s[\w{]|\/?>)/g)].map((m) => m[1]).join(' ');
|
|
523
|
+
for (const [prop, slot] of Object.entries(rootSlots)) {
|
|
524
|
+
// `children` is not an attribute: `<X.Root><Form/></X.Root>` passes it by nesting, which is
|
|
525
|
+
// the natural shape for a region whose root takes one child.
|
|
526
|
+
const asChild = prop === 'children' && !tag.trimEnd().endsWith('/>');
|
|
527
|
+
if (asChild || new RegExp(`(^|\\s)${prop}\\s*=`).test(tag) || new RegExp(`(^|[\\s,{])${prop}\\s*[,:}]`).test(spreads)) {
|
|
528
|
+
placed.set(slot, [...(placed.get(slot) ?? []), file]);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (rootRendered) add('ok', 'root', `<${rootNames[0]}> composes the region from its declared slots`);
|
|
534
|
+
else
|
|
535
|
+
add(
|
|
536
|
+
'error',
|
|
537
|
+
'root',
|
|
538
|
+
`the archipelago declares a \`root\` and nothing renders <${rootNames.join('> or <')}> — ` +
|
|
539
|
+
`the region has one description and the page is not using it`,
|
|
540
|
+
);
|
|
541
|
+
if (rootRendered) regionRefPlacement(region, rootNames, rootTags, sources, add);
|
|
542
|
+
}
|
|
543
|
+
// PLACED IS NOT THE SAME AS RENDERED, and the regex above cannot tell them apart. A slot written
|
|
544
|
+
// inside `{isOpen && …}`, a ternary, or a `.map()` callback reads as placed and may never appear —
|
|
545
|
+
// this region once carried an island that was declared for months and rendered by nothing, while
|
|
546
|
+
// every check stayed green. The lagoon renders every declared slot unconditionally, so it cannot
|
|
547
|
+
// catch it either; only the host's own source says so.
|
|
548
|
+
//
|
|
549
|
+
// A WARNING, not an error: conditional placement is often correct (a drawer, a tab, a permission).
|
|
550
|
+
// What is not correct is not knowing.
|
|
551
|
+
const conditional = conditionallyPlaced(sources, islandNames, code);
|
|
552
|
+
for (const [slot, why] of conditional) {
|
|
553
|
+
if (!placedIslands.some((i) => i.slot === slot)) continue;
|
|
554
|
+
add(
|
|
555
|
+
'warn',
|
|
556
|
+
'placed',
|
|
557
|
+
`${slot} is placed inside ${why} — the lagoon renders it unconditionally, so nothing here proves ` +
|
|
558
|
+
`the application ever reaches it. Check the branch is one users actually take`,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// 2b — DOES THE PAGE FILL WHAT THE ISLAND BINDS?
|
|
563
|
+
//
|
|
564
|
+
// `<X.Island slot="s"><Card/></X.Island>` publishes the CHILD's props into the region under the
|
|
565
|
+
// island's `bind` keys. A prop the page does not pass is a key nothing feeds, and the island runs on
|
|
566
|
+
// its default — placed, composed, read, and quietly empty. The lagoon cannot see this: it seeds the
|
|
567
|
+
// key itself, which is the whole point of a preview and the reason it cannot answer this question.
|
|
568
|
+
const passed = passedProps(sources, islandNames, code, rootSlotMap(region.id));
|
|
569
|
+
// AN ISLAND-PRODUCED KEY IS NOT THE PAGE'S TO FEED, so its absence is correct rather than a finding.
|
|
570
|
+
//
|
|
571
|
+
// This check and the runtime used to contradict each other. `react-island.tsx` refuses to publish a
|
|
572
|
+
// prop whose key has a declared producer — "OWNED KEYS ARE NOT THE PAGE'S TO PUBLISH", D5 — and warns
|
|
573
|
+
// when a page passes one; this warned when a page did NOT. A region with an island-owned key could
|
|
574
|
+
// not satisfy both, and doing what this line asked was the laundering the ownership rules exist to
|
|
575
|
+
// stop. Only HOST-FED keys are the page's responsibility.
|
|
576
|
+
const producedHere = producedKeys(region);
|
|
577
|
+
for (const island of liveIslands) {
|
|
578
|
+
const bound = Object.entries(island.bind ?? {});
|
|
579
|
+
if (!bound.length) continue;
|
|
580
|
+
const props = passed.get(island.slot);
|
|
581
|
+
if (!props) continue; // not placed with children — the registry form takes its props from the store
|
|
582
|
+
const unfed = bound.filter(([prop, key]) => !props.has(prop) && !producedHere.has(key));
|
|
583
|
+
if (unfed.length) {
|
|
584
|
+
add(
|
|
585
|
+
'warn',
|
|
586
|
+
'placed',
|
|
587
|
+
`${island.slot} binds ${unfed.map(([p, k]) => `${p} → ${k}`).join(', ')}, and the page's own element ` +
|
|
588
|
+
`passes ${unfed.length > 1 ? 'none of those props' : 'no such prop'} — the island renders its default ` +
|
|
589
|
+
`and the region key stays empty`,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// WHAT A DECLARED SOURCE PRODUCES. Needed twice below: a key a source feeds is established (2c),
|
|
595
|
+
// and it is not the page's to feed by hand (2d).
|
|
596
|
+
const sourceKeys = new Map(); // key -> declared source id
|
|
597
|
+
const unreadable = [];
|
|
598
|
+
for (const [sourceId, declared] of Object.entries(region.sources ?? {})) {
|
|
599
|
+
// `produces: null` is "could not be read", which is NOT "produces nothing" — treating the two the
|
|
600
|
+
// same is how this check would pass by looking at nothing.
|
|
601
|
+
if (declared?.produces == null) {
|
|
602
|
+
unreadable.push(`${sourceId} (${declared?.unresolved ?? 'unresolved'})`);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
for (const key of declared.produces) sourceKeys.set(key, sourceId);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// 2c — IS EVERY HOST-FED KEY ACTUALLY ESTABLISHED?
|
|
609
|
+
//
|
|
610
|
+
// Host-fed is DERIVED, exactly as the rules say it is: every bound key that no island `writes`. The
|
|
611
|
+
// page owes each of them a value, by one of the two acts that exist — `seed(...)`, or passing the
|
|
612
|
+
// bound prop on a placed island's own element. A key that gets neither is read as `undefined` by
|
|
613
|
+
// whoever reads it, and no other check can see it: the archipelago declares the key, the island
|
|
614
|
+
// binds it, the lagoon SEEDS it (that is what a preview does), and the host quietly feeds nothing.
|
|
615
|
+
const produced = new Set(liveIslands.flatMap((i) => Object.values(i.writes ?? {}).map((t) => (typeof t === 'string' ? t : Object.values(t)).toString())).flat());
|
|
616
|
+
// SEEDING HAS TWO SHAPES, and this used to know only one. `seededKeys` reads the CALL forms
|
|
617
|
+
// (`X.seed(k, v)` / `X.seed({ k })`); a page can equally hand the seed to `createRegion` as an
|
|
618
|
+
// option, which is what the scaffolding writes and what the docs show. `flow-shape`, twenty lines
|
|
619
|
+
// below, already took the union for exactly that reason — and this check did not, so a page that
|
|
620
|
+
// seeded the option form was told its keys were "never established" while its neighbour counted
|
|
621
|
+
// them in the same run. Two readers of one fact, disagreeing out loud.
|
|
622
|
+
const seeded = new Set([...seededKeys(sources, binding, code), ...createRegionSeedKeys(bindingFile, constName)]);
|
|
623
|
+
// THREE acts establish a host-fed key, not two: seed, a passed prop, and `provide()`. The docstring
|
|
624
|
+
// above named two and the check believed it, so a key the page provides on mount was reported as
|
|
625
|
+
// never established — advice to go and do what the page already does.
|
|
626
|
+
const provided = providedKeys(sources, binding, code);
|
|
627
|
+
const fedBy = new Map(); // host-fed key -> the slots whose bind could feed it
|
|
628
|
+
for (const island of liveIslands) {
|
|
629
|
+
for (const [prop, key] of Object.entries(island.bind ?? {})) {
|
|
630
|
+
if (produced.has(key)) continue;
|
|
631
|
+
const entry = fedBy.get(key) ?? { slots: [], passed: false };
|
|
632
|
+
entry.slots.push(island.slot);
|
|
633
|
+
if (passed.get(island.slot)?.has(prop)) entry.passed = true;
|
|
634
|
+
fedBy.set(key, entry);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
// A key a DECLARED SOURCE produces is established by that source — it is neither seeded nor passed
|
|
638
|
+
// as a prop, and reporting it as starved sends someone to add the very hand-feeding that 2d forbids.
|
|
639
|
+
const starved = [...fedBy].filter(
|
|
640
|
+
([key, e]) => !e.passed && !seeded.has(key) && !provided.has(key) && !sourceKeys.has(key),
|
|
641
|
+
);
|
|
642
|
+
for (const [key, e] of starved) {
|
|
643
|
+
add(
|
|
644
|
+
'warn',
|
|
645
|
+
'fed',
|
|
646
|
+
`host-fed key ${key} is never established: the host neither seeds it nor passes it on ` +
|
|
647
|
+
`${e.slots.join(', ')} — every reader sees undefined, and the lagoon cannot tell you because it ` +
|
|
648
|
+
`seeds the key itself`,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
if (fedBy.size && !starved.length)
|
|
652
|
+
add('ok', 'fed', `all ${fedBy.size} host-fed key(s) established · ${sourceKeys.size} by a declared source`);
|
|
653
|
+
|
|
654
|
+
// DOES THE LAGOON PREVIEW THE REGION IN THE SHAPE THE PAGE CREATES?
|
|
655
|
+
//
|
|
656
|
+
// Every other check compares the region against its DECLARATION. This compares two things that are
|
|
657
|
+
// both real: the keys the page establishes on first paint, and the keys the flows ever establish.
|
|
658
|
+
// A key the page always seeds and no flow ever seeds is not a missing scenario — it is a missing
|
|
659
|
+
// COLUMN. Every flow runs against a region shaped differently from the one users get, and nothing
|
|
660
|
+
// else notices, because each side is internally consistent.
|
|
661
|
+
//
|
|
662
|
+
// Found by the coverage fold on motu's own review console — `App.tsx` seeds seven keys, its flows
|
|
663
|
+
// seed four, so `busy` and `error` were absent from every previewed state. Which is the point of
|
|
664
|
+
// putting it HERE: that finding cost a beacon, a corpus and a comparison to learn, and it is two
|
|
665
|
+
// literal object key-lists in source. No browser, no traffic, no production.
|
|
666
|
+
const flowSeeded = flowSeedKeys(region.id);
|
|
667
|
+
// `seeded` is already both shapes (see the note where it is built).
|
|
668
|
+
const pageSeeded = seeded;
|
|
669
|
+
if (flowSeeded === null) {
|
|
670
|
+
add('skip', 'flow-shape', 'no readable flows — nothing to compare the page\'s seed against');
|
|
671
|
+
} else if (!pageSeeded.size) {
|
|
672
|
+
add('skip', 'flow-shape', 'the page establishes no keys by `seed(...)` — nothing to compare');
|
|
673
|
+
} else {
|
|
674
|
+
// A KEY A DECLARED SOURCE PRODUCES IS NOT THE FLOW'S TO SEED. The source is installed in every flow
|
|
675
|
+
// and publishes it; a flow that seeded it too would overwrite the source's first answer with the
|
|
676
|
+
// page's empty placeholder — which is exactly what happened when this warning was obeyed: every
|
|
677
|
+
// step without a stimulus failed on an empty calendar. The page seeds it for first paint; the flow
|
|
678
|
+
// gets it from the source, which is the more faithful of the two.
|
|
679
|
+
const missing = [...pageSeeded].filter((k) => !flowSeeded.has(k) && !sourceKeys.has(k)).sort();
|
|
680
|
+
if (missing.length) {
|
|
681
|
+
add(
|
|
682
|
+
'warn',
|
|
683
|
+
'flow-shape',
|
|
684
|
+
`the page seeds ${pageSeeded.size} key(s) and the flows seed ${flowSeeded.size}: ${missing.join(', ')} ` +
|
|
685
|
+
`${missing.length === 1 ? 'is' : 'are'} established on every real page load and in no flow. ` +
|
|
686
|
+
`The lagoon previews a region shaped differently from the one users get — add ${missing.length === 1 ? 'it' : 'them'} ` +
|
|
687
|
+
`to the flow seeds in ${paths.rel(paths.archipelagoEvidence(region.id))}`,
|
|
688
|
+
);
|
|
689
|
+
} else {
|
|
690
|
+
add('ok', 'flow-shape', `the flows seed every key the page does · ${pageSeeded.size} key(s)`, pageSeeded.size);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// 2d — DOES THE HOST GO ROUND ITS OWN DECLARED SOURCES?
|
|
695
|
+
//
|
|
696
|
+
// A source declares which keys it produces, and the LAGOON has no way to go round it: `channelFrom`
|
|
697
|
+
// names the declared source and passes data, with no expression position left for hand-written
|
|
698
|
+
// orchestration. The page had one — fetch however you like, then `provide()` the result — so the
|
|
699
|
+
// two halves could implement the same coupling differently, or the page could implement one the
|
|
700
|
+
// region never carried at all. That is not a hypothetical: a console whose archipelago promised
|
|
701
|
+
// "picking a repo changes what the list shows" performed it in two page effects, so the published
|
|
702
|
+
// lagoon showed a project rail where clicking did nothing, and every check passed.
|
|
703
|
+
//
|
|
704
|
+
// Caught here as well as at runtime because `provide()` throwing is the moment someone runs the
|
|
705
|
+
// page, and this is the moment they run `motu check`.
|
|
706
|
+
if (unreadable.length) {
|
|
707
|
+
add(
|
|
708
|
+
'warn',
|
|
709
|
+
'source-owned',
|
|
710
|
+
`could not read what ${unreadable.join(', ')} produces, so nothing was checked against ` +
|
|
711
|
+
`${unreadable.length > 1 ? 'those sources' : 'that source'} — the host may be feeding their keys by hand`,
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
if (sourceKeys.size) {
|
|
715
|
+
const bypassed = [...sourceKeys].filter(([key]) => provided.has(key));
|
|
716
|
+
for (const [key, sourceId] of bypassed) {
|
|
717
|
+
add(
|
|
718
|
+
'error',
|
|
719
|
+
'source-owned',
|
|
720
|
+
`the host calls ${binding}.provide('${key}'), but ${key} is produced by the declared source ` +
|
|
721
|
+
`'${sourceId}' — install the source instead of feeding its key by hand ` +
|
|
722
|
+
`(\`channels: [channelFrom({ to: archipelago, id: '${sourceId}', args: [port] })]\`), or the page ` +
|
|
723
|
+
`and the lagoon are free to answer the same coupling differently`,
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
if (!bypassed.length)
|
|
727
|
+
add('ok', 'source-owned', `no host write goes round a declared source · ${sourceKeys.size} source-produced key(s)`, sourceKeys.size);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
// 2e — DOES THE LAGOON RENDER THESE ISLANDS WITH WHAT THE PAGE GIVES THEM?
|
|
732
|
+
//
|
|
733
|
+
// `<R.Island slot="x" props={{ shotUrl }} />` is the page's other expression position. Those props
|
|
734
|
+
// are NOT region state — where the host lives, a formatter, a URL builder — so no bind declares
|
|
735
|
+
// them and `fed` never looks. The lagoon had no counterpart at all until `LagoonOverrides.props`,
|
|
736
|
+
// which means the island a human approves can be missing the very thing that makes it useful: the
|
|
737
|
+
// review console's diff viewer rendered `<img src="">`, naturalWidth 0, with every check green and
|
|
738
|
+
// a flow asserting its heading text.
|
|
739
|
+
const pageEnvProps = elementProps(sources, binding);
|
|
740
|
+
const overridesFile = paths.lagoonDir ? resolve(paths.lagoonDir, 'src/lagoon.tsx') : null;
|
|
741
|
+
const fromLagoon = lagoonProps(overridesFile);
|
|
742
|
+
if (pageEnvProps.size && fromLagoon === null) {
|
|
743
|
+
add(
|
|
744
|
+
'warn',
|
|
745
|
+
'island-props',
|
|
746
|
+
`the page passes props on ${[...pageEnvProps.keys()].join(', ')}, and the lagoon overrides could ` +
|
|
747
|
+
`not be read — nothing was compared`,
|
|
748
|
+
);
|
|
749
|
+
} else if (pageEnvProps.size) {
|
|
750
|
+
const slotsHere = new Map(liveIslands.map((i) => [i.slot, i]));
|
|
751
|
+
const supplied = fromLagoon.get(region.id) ?? new Map();
|
|
752
|
+
let compared = 0;
|
|
753
|
+
for (const [slot, names] of pageEnvProps) {
|
|
754
|
+
const island = slotsHere.get(slot);
|
|
755
|
+
if (!island) continue;
|
|
756
|
+
const bound = new Set(Object.keys(island.bind ?? {}));
|
|
757
|
+
const env = [...names].filter((n) => !bound.has(n));
|
|
758
|
+
if (!env.length) continue;
|
|
759
|
+
compared += env.length;
|
|
760
|
+
const missing = env.filter((n) => !(supplied.get(slot) ?? new Set()).has(n));
|
|
761
|
+
if (missing.length) {
|
|
762
|
+
add(
|
|
763
|
+
'error',
|
|
764
|
+
'island-props',
|
|
765
|
+
`the page passes ${missing.join(', ')} to ${slot} and the lagoon supplies ${missing.length > 1 ? 'none of them' : 'nothing for it'} — ` +
|
|
766
|
+
`the island is previewed and approved without what the page gives it. Add it to the lagoon's ` +
|
|
767
|
+
`\`props\` override (a stand-in, not the real thing)`,
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
for (const [slot, names] of supplied) {
|
|
772
|
+
const extra = [...names].filter((n) => !(pageEnvProps.get(slot) ?? new Set()).has(n));
|
|
773
|
+
if (extra.length && slotsHere.has(slot)) {
|
|
774
|
+
add(
|
|
775
|
+
'warn',
|
|
776
|
+
'island-props',
|
|
777
|
+
`the lagoon gives ${slot} ${extra.join(', ')} and the page passes ${extra.length > 1 ? 'none of them' : 'no such prop'} — ` +
|
|
778
|
+
`the preview shows more than the application does`,
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if (compared) add('ok', 'island-props', `page-passed props are supplied by the lagoon too · ${compared} prop(s)`);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const declared = placedIslands.map((i) => i.slot);
|
|
786
|
+
const missing = declared.filter((s) => !placed.has(s));
|
|
787
|
+
// UNKNOWN means the archipelago declares no such slot — measured against EVERY live island, not
|
|
788
|
+
// just the source-placed ones. A catalogue member is summoned by data AND may still be wrapped in
|
|
789
|
+
// source (Twenty's dispatch wraps two of its widget cases), and comparing against the chrome-only
|
|
790
|
+
// list reported both of them as slots the region had never heard of.
|
|
791
|
+
const knownSlots = liveIslands.map((i) => i.slot);
|
|
792
|
+
const unknown = [...placed.keys()].filter((s) => !knownSlots.includes(s));
|
|
793
|
+
if (missing.length) {
|
|
794
|
+
add(
|
|
795
|
+
'error',
|
|
796
|
+
'placed',
|
|
797
|
+
`slot(s) declared but never placed in the host: ${missing.join(', ')} — the island cannot render ` +
|
|
798
|
+
`in the application, whatever the lagoon shows`,
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
for (const slot of unknown) {
|
|
802
|
+
add('error', 'placed', `<${islandNames[0]} slot="${slot}"> is placed but the archipelago declares no such slot`);
|
|
803
|
+
}
|
|
804
|
+
if (!missing.length && !unknown.length && declared.length)
|
|
805
|
+
add('ok', 'placed', `all ${declared.length} source-placed slot(s) placed in the host`);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// 3 — READ? a region nobody reads is a store the page writes into and ignores.
|
|
809
|
+
const readers = [...sources].filter(([, t]) => code(t).includes(`${binding}.useRegion(`));
|
|
810
|
+
const bound = region.islands.some((i) => i.bind ?? true);
|
|
811
|
+
if (readers.length) add('ok', 'read', `${binding}.useRegion() in ${readers.length} host file(s)`);
|
|
812
|
+
else if (bound) add('warn', 'read', `nothing calls ${binding}.useRegion() — the host feeds the region but never reads it back`);
|
|
813
|
+
|
|
814
|
+
// 3b — does the PAGE install the declared source? The region names one module per host-fed group
|
|
815
|
+
// precisely so both consumers use it; a page that fetches those keys its own way is the other
|
|
816
|
+
// half of the drift the lagoon side already refuses.
|
|
817
|
+
// PARSED, NOT MATCHED, and by the reader `verify` uses. The regex here matched only
|
|
818
|
+
// `{ module, produces }` — so a region declaring `teams: teamsSource`, the STRONGER form the type
|
|
819
|
+
// system checks, skipped this check entirely and nothing said so. Two readers of one declaration is
|
|
820
|
+
// how the better shape ends up with less checking than the weaker one.
|
|
821
|
+
//
|
|
822
|
+
// TWO DECLARATION FORMS, TWO WAYS TO INSTALL ONE, and reading both as "the page imports the module"
|
|
823
|
+
// is what turned this into a permanent red. A source declared BY REFERENCE (`shots: shotsSource`)
|
|
824
|
+
// is already imported — by the ARCHIPELAGO, which is the point of the stronger form — and the page
|
|
825
|
+
// installs it by naming it: `channelFrom({ to: reviewArchipelago, id: 'shots', args: [port] })`.
|
|
826
|
+
// There is no import for the page to make, and adding one would be a second reference to a module
|
|
827
|
+
// the region already owns. `channelFrom` says so itself: handed a source declared by module name
|
|
828
|
+
// only, it throws "there is nothing to install" — so the two forms do not share an answer.
|
|
829
|
+
//
|
|
830
|
+
// by reference -> a `channelFrom` naming this source id (or the module, still imported)
|
|
831
|
+
// { module, produces } -> the module, imported by a host file that uses the region
|
|
832
|
+
const declaredSrc = declaredSourcesOf(region.file);
|
|
833
|
+
{
|
|
834
|
+
const users = [...sources].filter(([, t]) => code(t).includes(`${binding}.`));
|
|
835
|
+
const channelled = new Set();
|
|
836
|
+
for (const [file] of users) for (const id of channelSourceIds(file, region.id)) channelled.add(id);
|
|
837
|
+
// AND THE FILE THAT COMPOSES THE BINDING. `createRegion(arch, { channels: [channelFrom(...)] })` is
|
|
838
|
+
// a supported install, and it lives where the binding is DEFINED — which never says `Events.`, only
|
|
839
|
+
// `Events = createRegion(...)`. Reading only the files that USE the binding called that install
|
|
840
|
+
// missing: an error on the one shape the createRegion option exists for.
|
|
841
|
+
for (const id of channelSourceIds(bindingFile, region.id)) channelled.add(id);
|
|
842
|
+
|
|
843
|
+
for (const [name, src] of Object.entries(declaredSrc)) {
|
|
844
|
+
const module = src.module;
|
|
845
|
+
// Resolved, not compared as text: the page says `./directory-source`, the region says
|
|
846
|
+
// `@/app/dashboard/directory/directory-source`, and they are the same file.
|
|
847
|
+
const target = resolveAppImport(region.file, module);
|
|
848
|
+
const imported = users.some(([f, t]) =>
|
|
849
|
+
[...code(t).matchAll(/from\s*['"]([^'"]+)['"]/g)].some((i) => resolveAppImport(f, i[1]) === target),
|
|
850
|
+
);
|
|
851
|
+
if (imported) add('ok', 'source', `source "${name}" installed from ${module}`);
|
|
852
|
+
else if (src.byReference && channelled.has(name))
|
|
853
|
+
add('ok', 'source', `source "${name}" installed by channelFrom({ id: '${name}' })`);
|
|
854
|
+
else if (src.byReference)
|
|
855
|
+
add(
|
|
856
|
+
'error',
|
|
857
|
+
'source',
|
|
858
|
+
`no host file that uses ${binding} installs source "${name}" — the region declares it produces ` +
|
|
859
|
+
`its keys, so the page must install it with channelFrom({ to: ${archConst(region.id)}, id: '${name}', args: [...] }) ` +
|
|
860
|
+
`rather than feed them another way`,
|
|
861
|
+
);
|
|
862
|
+
else
|
|
863
|
+
add(
|
|
864
|
+
'error',
|
|
865
|
+
'source',
|
|
866
|
+
`no host file that uses ${binding} imports ${module} — the region declares source "${name}" ` +
|
|
867
|
+
`produces its keys, so the page must install it rather than feed them another way`,
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// 4 — DUPLICATED? the page keeping its own copy of a key an island produces is integration
|
|
873
|
+
// half-done: both exist, they drift, and the one the user sees is whichever the page renders.
|
|
874
|
+
const produced = producedKeys(region);
|
|
875
|
+
const users = [...sources].filter(([, t]) => code(t).includes(`${binding}.`));
|
|
876
|
+
for (const [file, text] of users) {
|
|
877
|
+
for (const [, name] of code(text).matchAll(/const\s+\[(\w+)\s*,\s*set\w+\]\s*=\s*useState/g)) {
|
|
878
|
+
if (produced.has(name)) {
|
|
879
|
+
add(
|
|
880
|
+
'warn',
|
|
881
|
+
'duplicated',
|
|
882
|
+
`${rel(file)} keeps \`useState\` for "${name}", which an island produces — the region owns it ` +
|
|
883
|
+
`after integration, and two copies of one value drift`,
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
return { id: region.id, findings };
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Every region's integration, as data — shared with `motu check`, which runs this as its last section.
|
|
893
|
+
*
|
|
894
|
+
* Deliberately the same code path: an integration gate that only exists behind its own verb is a gate
|
|
895
|
+
* nobody runs, and this is the one question the rest of `check` cannot ask.
|
|
896
|
+
*/
|
|
897
|
+
/**
|
|
898
|
+
* slot -> the props the page passes to the island's child element.
|
|
899
|
+
*
|
|
900
|
+
* Only the WRAP form has an answer: `<X.Island slot="s"><Card a={1}/></X.Island>` publishes `a`. A
|
|
901
|
+
* self-closing `<X.Island slot="s"/>` renders from the registry and takes its props from the store, so
|
|
902
|
+
* there is nothing here to compare and the slot is absent from the map rather than empty in it —
|
|
903
|
+
* "passes nothing" and "there is nothing to pass" are different answers.
|
|
904
|
+
*/
|
|
905
|
+
/**
|
|
906
|
+
* slot -> the props the page passes on the ISLAND ELEMENT itself (`<R.Island slot="x" props={{ a }} />`).
|
|
907
|
+
*
|
|
908
|
+
* Distinct from `passedProps`, which reads the CHILDREN form — the page's own component inside the
|
|
909
|
+
* island. This is the other placement form, and its props are the ones with no lagoon counterpart:
|
|
910
|
+
* not region state, so no bind declares them, and until now nothing compared them with anything.
|
|
911
|
+
*/
|
|
912
|
+
function elementProps(sources, binding) {
|
|
913
|
+
const out = new Map();
|
|
914
|
+
for (const [file] of sources) {
|
|
915
|
+
let sf;
|
|
916
|
+
try {
|
|
917
|
+
sf = sourceFileAt(file, { allowJs: true, jsx: 4 });
|
|
918
|
+
} catch {
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
const opens = [
|
|
922
|
+
...sf.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
|
|
923
|
+
...sf.getDescendantsOfKind(SyntaxKind.JsxElement).map((e) => e.getOpeningElement?.()).filter(Boolean),
|
|
924
|
+
];
|
|
925
|
+
for (const open of opens) {
|
|
926
|
+
if (open.getTagNameNode?.().getText() !== `${binding}.Island`) continue;
|
|
927
|
+
const attrs = open.getAttributes?.() ?? [];
|
|
928
|
+
const attr = (n) => attrs.find((a) => (a.getNameNode?.().getText?.() ?? a.getName?.()) === n);
|
|
929
|
+
const slot = attr('slot')?.getInitializer?.()?.getText?.()?.replace(/['"`{}]/g, '');
|
|
930
|
+
const propsInit = attr('props')?.getInitializer?.();
|
|
931
|
+
if (!slot || !propsInit) continue;
|
|
932
|
+
const obj = propsInit.getExpression?.();
|
|
933
|
+
const names = new Set(out.get(slot) ?? []);
|
|
934
|
+
for (const prop of obj?.getProperties?.() ?? []) {
|
|
935
|
+
const name = prop.getNameNode?.()?.getText?.() ?? prop.getName?.();
|
|
936
|
+
if (name) names.add(String(name).replace(/['"`]/g, ''));
|
|
937
|
+
}
|
|
938
|
+
out.set(slot, names);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
return out;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* regionId -> slot -> prop names, from the lagoon's `props` override.
|
|
946
|
+
*
|
|
947
|
+
* Read with ts-morph rather than a regex: it is a nested object literal whose values are functions,
|
|
948
|
+
* and a brace-counting reader over that is the kind of thing that silently returns nothing.
|
|
949
|
+
*/
|
|
950
|
+
function lagoonProps(overridesFile) {
|
|
951
|
+
const out = new Map();
|
|
952
|
+
if (!overridesFile || !existsSync(overridesFile)) return null;
|
|
953
|
+
let sf;
|
|
954
|
+
try {
|
|
955
|
+
sf = sourceFileAt(overridesFile, { allowJs: true, jsx: 4 });
|
|
956
|
+
} catch {
|
|
957
|
+
return null;
|
|
958
|
+
}
|
|
959
|
+
const decl = sf.getVariableDeclaration?.('props');
|
|
960
|
+
const obj = decl?.getInitializer?.();
|
|
961
|
+
if (!obj?.getProperties) return out;
|
|
962
|
+
for (const regionProp of obj.getProperties()) {
|
|
963
|
+
const region = (regionProp.getNameNode?.()?.getText?.() ?? '').replace(/['"`]/g, '');
|
|
964
|
+
const slots = new Map();
|
|
965
|
+
for (const slotProp of regionProp.getInitializer?.()?.getProperties?.() ?? []) {
|
|
966
|
+
const slot = (slotProp.getNameNode?.()?.getText?.() ?? '').replace(/['"`]/g, '');
|
|
967
|
+
const names = new Set();
|
|
968
|
+
for (const p of slotProp.getInitializer?.()?.getProperties?.() ?? []) {
|
|
969
|
+
const n = p.getNameNode?.()?.getText?.() ?? p.getName?.();
|
|
970
|
+
if (n) names.add(String(n).replace(/['"`]/g, ''));
|
|
971
|
+
}
|
|
972
|
+
slots.set(slot, names);
|
|
973
|
+
}
|
|
974
|
+
out.set(region, slots);
|
|
975
|
+
}
|
|
976
|
+
return out;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function passedProps(sources, islandNames, code, rootSlots) {
|
|
980
|
+
const out = new Map();
|
|
981
|
+
for (const [file] of sources) {
|
|
982
|
+
let sf;
|
|
983
|
+
try {
|
|
984
|
+
sf = sourceFileAt(file, { allowJs: true, jsx: 4 });
|
|
985
|
+
} catch {
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
for (const el of sf.getDescendantsOfKind(SyntaxKind.JsxElement)) {
|
|
989
|
+
const open = el.getOpeningElement?.();
|
|
990
|
+
if (!islandNames.includes(open?.getTagNameNode?.().getText())) continue;
|
|
991
|
+
const slot = open
|
|
992
|
+
.getAttributes?.()
|
|
993
|
+
?.find((a) => (a.getNameNode?.().getText?.() ?? a.getName?.()) === 'slot')
|
|
994
|
+
?.getInitializer?.()
|
|
995
|
+
?.getText?.()
|
|
996
|
+
?.replace(/['"`{}]/g, '');
|
|
997
|
+
if (!slot) continue;
|
|
998
|
+
const props = new Set();
|
|
999
|
+
for (const child of el.getJsxChildren?.() ?? []) {
|
|
1000
|
+
const childOpen =
|
|
1001
|
+
child.getKind?.() === SyntaxKind.JsxElement
|
|
1002
|
+
? child.getOpeningElement?.()
|
|
1003
|
+
: child.getKind?.() === SyntaxKind.JsxSelfClosingElement
|
|
1004
|
+
? child
|
|
1005
|
+
: null;
|
|
1006
|
+
for (const a of childOpen?.getAttributes?.() ?? []) {
|
|
1007
|
+
const name = a.getNameNode?.().getText?.() ?? a.getName?.();
|
|
1008
|
+
if (name) props.add(name);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
out.set(slot, props);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// THE ROOT FORM. `<X.Root header={<C a={1}/>} />` is the page passing that island's props too —
|
|
1015
|
+
// it just names the PROP instead of the slot, which is the whole point of `root`. Reading only
|
|
1016
|
+
// the `<X.Island>` form reported two of the actions region's keys as never established the moment
|
|
1017
|
+
// that page stopped writing slots, when the page was passing them exactly as before.
|
|
1018
|
+
for (const el of [
|
|
1019
|
+
...sf.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
|
|
1020
|
+
...sf.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
|
|
1021
|
+
]) {
|
|
1022
|
+
if (el.getTagNameNode?.().getText().split('.').pop() !== 'Root') continue;
|
|
1023
|
+
for (const attr of el.getAttributes?.() ?? []) {
|
|
1024
|
+
const prop = attr.getNameNode?.().getText?.();
|
|
1025
|
+
const slot = prop ? rootSlots?.[prop] : null;
|
|
1026
|
+
if (!slot) continue;
|
|
1027
|
+
const props = new Set(out.get(slot) ?? []);
|
|
1028
|
+
for (const target of rootSlotElements(attr.getInitializer?.()?.getExpression?.())) {
|
|
1029
|
+
for (const a of target.getAttributes?.() ?? []) {
|
|
1030
|
+
const name = a.getNameNode?.().getText?.() ?? a.getName?.();
|
|
1031
|
+
if (name) props.add(name);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
out.set(slot, props);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
return out;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/** What stands in a root slot: the element, or each branch of a conditional / `&&`. Never a descendant. */
|
|
1042
|
+
function rootSlotElements(expr) {
|
|
1043
|
+
if (!expr) return [];
|
|
1044
|
+
switch (expr.getKind()) {
|
|
1045
|
+
case SyntaxKind.JsxSelfClosingElement:
|
|
1046
|
+
return [expr];
|
|
1047
|
+
case SyntaxKind.JsxElement:
|
|
1048
|
+
return [expr.getOpeningElement()];
|
|
1049
|
+
case SyntaxKind.ParenthesizedExpression:
|
|
1050
|
+
return rootSlotElements(expr.getExpression());
|
|
1051
|
+
case SyntaxKind.ConditionalExpression:
|
|
1052
|
+
return [...rootSlotElements(expr.getWhenTrue()), ...rootSlotElements(expr.getWhenFalse())];
|
|
1053
|
+
case SyntaxKind.BinaryExpression:
|
|
1054
|
+
return expr.getOperatorToken().getText() === '&&' ? rootSlotElements(expr.getRight()) : [];
|
|
1055
|
+
default:
|
|
1056
|
+
return [];
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Slots whose `<X.Island>` sits under something that may not run: a logical `&&`, a ternary, or a
|
|
1062
|
+
* callback (`.map`, `.filter`). Uses the AST rather than the text, because "is this line inside a
|
|
1063
|
+
* conditional" is exactly the question a regex cannot answer.
|
|
1064
|
+
*/
|
|
1065
|
+
/** A name that goes into a RegExp — `Login.Island` carries a dot that must not match anything. */
|
|
1066
|
+
function escapeRe(name) {
|
|
1067
|
+
return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
export function conditionallyPlaced(sources, islandNames, code) {
|
|
1071
|
+
const out = new Map();
|
|
1072
|
+
for (const [file] of sources) {
|
|
1073
|
+
let sf;
|
|
1074
|
+
try {
|
|
1075
|
+
sf = sourceFileAt(file, { allowJs: true, jsx: 4 });
|
|
1076
|
+
} catch {
|
|
1077
|
+
continue; // unparseable here is not a finding — `placed` already read it as text
|
|
1078
|
+
}
|
|
1079
|
+
for (const kind of [SyntaxKind.JsxOpeningElement, SyntaxKind.JsxSelfClosingElement]) {
|
|
1080
|
+
for (const el of sf.getDescendantsOfKind(kind)) {
|
|
1081
|
+
if (!islandNames.includes(el.getTagNameNode?.().getText())) continue;
|
|
1082
|
+
// `getName()` is not the accessor here — on a JsxAttribute the name is a node, and reading it
|
|
1083
|
+
// the wrong way returned undefined for EVERY element, so the whole check silently found
|
|
1084
|
+
// nothing while looking like it ran.
|
|
1085
|
+
const slotAttr = el
|
|
1086
|
+
.getAttributes?.()
|
|
1087
|
+
?.find((a) => (a.getNameNode?.().getText?.() ?? a.getName?.()) === 'slot');
|
|
1088
|
+
const slot = slotAttr?.getInitializer?.()?.getText?.()?.replace(/['"`{}]/g, '');
|
|
1089
|
+
if (!slot) continue;
|
|
1090
|
+
let why = null;
|
|
1091
|
+
for (let node = el.getParent(); node && !why; node = node.getParent()) {
|
|
1092
|
+
const k = node.getKind();
|
|
1093
|
+
if (k === SyntaxKind.ConditionalExpression) why = 'a ternary';
|
|
1094
|
+
else if (k === SyntaxKind.BinaryExpression && node.getOperatorToken?.().getText() === '&&') why = 'a `&&` guard';
|
|
1095
|
+
else if (k === SyntaxKind.ArrowFunction || k === SyntaxKind.FunctionExpression) {
|
|
1096
|
+
const call = node.getParent();
|
|
1097
|
+
const callee = call?.getExpression?.()?.getText?.() ?? '';
|
|
1098
|
+
if (/\.(map|filter|flatMap|forEach)$/.test(callee)) why = `a \`${callee.split('.').pop()}()\` callback`;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
if (why) out.set(slot, why);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
return out;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
export function integrationResults(only) {
|
|
1109
|
+
const regions = readRegions(paths.archipelagosDir).filter((r) => !only || r.id === only);
|
|
1110
|
+
if (!regions.length) return [];
|
|
1111
|
+
const sources = hostSources();
|
|
1112
|
+
// A CHECK THAT LOOKED AT NOTHING HAS NOT PASSED — and, worse here, has not FAILED either. With no
|
|
1113
|
+
// host files in view every region reports "the application never composes it", which reads as a
|
|
1114
|
+
// finding about the app rather than about the scan. Say which it is.
|
|
1115
|
+
if (sources.size === 0) {
|
|
1116
|
+
console.log(color.bold('\nmotu integrate check\n'));
|
|
1117
|
+
console.log(
|
|
1118
|
+
` ${color.yellow('!')} ${color.dim('host-sources'.padEnd(12))} scanned 0 files under ${paths.rel(HOST_ROOT)} ` +
|
|
1119
|
+
`(${describeSources(sources, paths.rel)}) — ` +
|
|
1120
|
+
`nothing was examined, so nothing is proved about the host. ` +
|
|
1121
|
+
`Set \`hostSources\` in motu.config.json if the application lives elsewhere.`,
|
|
1122
|
+
);
|
|
1123
|
+
process.exit(2);
|
|
1124
|
+
}
|
|
1125
|
+
return regions.map((r) => checkRegion(r, sources));
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
export async function integrateCheckCommand(argv) {
|
|
1129
|
+
const results = integrationResults(argv._[0]);
|
|
1130
|
+
if (!results.length) {
|
|
1131
|
+
console.error(color.red(`✗ no archipelago${argv._[0] ? ` "${argv._[0]}"` : 's'} to check`));
|
|
1132
|
+
process.exit(2);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
if (argv.json) {
|
|
1136
|
+
console.log(JSON.stringify({ host: HOST_ROOT, regions: results }, null, 2));
|
|
1137
|
+
return process.exit(results.some((r) => r.findings.some((f) => f.level === 'error')) ? 1 : 0);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
console.log(color.bold('\nmotu integrate check — is the host using what verifies green?\n'));
|
|
1141
|
+
let errors = 0;
|
|
1142
|
+
let warns = 0;
|
|
1143
|
+
for (const result of results) {
|
|
1144
|
+
const bad = result.findings.filter((f) => f.level === 'error').length;
|
|
1145
|
+
const warn = result.findings.filter((f) => f.level === 'warn').length;
|
|
1146
|
+
errors += bad;
|
|
1147
|
+
warns += warn;
|
|
1148
|
+
const mark = bad ? color.red('✗') : warn ? color.yellow('!') : color.green('✓');
|
|
1149
|
+
console.log(` ${mark} ${color.bold(result.id.padEnd(18))}${color.dim(bad ? `${bad} error(s)` : warn ? `${warn} warning(s)` : 'integrated')}`);
|
|
1150
|
+
for (const f of result.findings) {
|
|
1151
|
+
if (f.level === 'ok') continue;
|
|
1152
|
+
const m = f.level === 'error' ? color.red('✗') : color.yellow('!');
|
|
1153
|
+
console.log(` ${m} ${color.dim(f.check.padEnd(12))} ${f.msg}`);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
console.log('');
|
|
1157
|
+
if (errors) console.log(color.red(color.bold('FAIL')) + ` ${errors} error(s), ${warns} warning(s)`);
|
|
1158
|
+
else console.log(color.green(color.bold('PASS')) + color.dim(` ${results.length} region(s) integrated · ${warns} warning(s)`));
|
|
1159
|
+
process.exit(errors ? 1 : 0);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Keys the host establishes through `createRegion(config, { seed: { … } })`.
|
|
1164
|
+
*
|
|
1165
|
+
* `seededKeys` knows the two CALL forms — `X.seed(k, v)` and `X.seed({ k })` — and motu's own review
|
|
1166
|
+
* console uses neither: it hands the seed to `createRegion` as an option, at module scope, which is
|
|
1167
|
+
* what the scaffolding produces and what the docs show. So the one shape most likely to be in front
|
|
1168
|
+
* of a reader was the shape this could not see.
|
|
1169
|
+
*/
|
|
1170
|
+
function createRegionSeedKeys(bindingFile, constName) {
|
|
1171
|
+
if (!bindingFile) return new Set();
|
|
1172
|
+
let src;
|
|
1173
|
+
try {
|
|
1174
|
+
src = blankComments(readFileSync(bindingFile, 'utf8'));
|
|
1175
|
+
} catch {
|
|
1176
|
+
return new Set();
|
|
1177
|
+
}
|
|
1178
|
+
const call = src.match(new RegExp(`createRegion\\(\\s*${constName}\\b`));
|
|
1179
|
+
if (!call) return new Set();
|
|
1180
|
+
const seedAt = src.indexOf('seed:', call.index);
|
|
1181
|
+
if (seedAt < 0) return new Set();
|
|
1182
|
+
const brace = src.indexOf('{', seedAt);
|
|
1183
|
+
if (brace < 0) return new Set();
|
|
1184
|
+
let depth = 0;
|
|
1185
|
+
let body = '';
|
|
1186
|
+
for (let i = brace; i < src.length; i++) {
|
|
1187
|
+
if (src[i] === '{') depth++;
|
|
1188
|
+
else if (src[i] === '}' && --depth === 0) {
|
|
1189
|
+
body = src.slice(brace + 1, i);
|
|
1190
|
+
break;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
const keys = new Set();
|
|
1194
|
+
for (const entry of body.split(/,(?![^{[(]*[}\])])/)) {
|
|
1195
|
+
const name = entry.split(':')[0].trim().replace(/^\.\.\./, '');
|
|
1196
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name)) keys.add(name);
|
|
1197
|
+
}
|
|
1198
|
+
return keys;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* The union of keys every declared FLOW establishes in its seed.
|
|
1203
|
+
*
|
|
1204
|
+
* Read from source rather than by importing the module: this runs inside a static check that must not
|
|
1205
|
+
* boot a loader, and a flow's seed is a literal object at the top of the file. Returns null when there
|
|
1206
|
+
* is nothing readable — a check that cannot see its input reports a skip, never a pass.
|
|
1207
|
+
*
|
|
1208
|
+
* Deliberately UNION rather than per-flow: one flow establishing a key is enough for the region to
|
|
1209
|
+
* have been previewed with that column present. What this catches is a key NO flow ever mentions.
|
|
1210
|
+
*/
|
|
1211
|
+
function flowSeedKeys(id) {
|
|
1212
|
+
const file = paths.archipelagoEvidence(id);
|
|
1213
|
+
if (!existsSync(file)) return null;
|
|
1214
|
+
let src;
|
|
1215
|
+
try {
|
|
1216
|
+
src = blankComments(readFileSync(file, 'utf8'));
|
|
1217
|
+
} catch {
|
|
1218
|
+
return null;
|
|
1219
|
+
}
|
|
1220
|
+
const keys = new Set();
|
|
1221
|
+
// `seed: { a, b: 1, ...SEED }` and `seed: SEED` both appear in real evidence. The spread and the
|
|
1222
|
+
// bare identifier are followed to their `const NAME = { … }` in the same file, which is the form
|
|
1223
|
+
// every evidence module in this repository uses.
|
|
1224
|
+
const objectKeys = (body) => {
|
|
1225
|
+
for (const entry of body.split(/,(?![^{[(]*[}\])])/)) {
|
|
1226
|
+
const name = entry.split(':')[0].trim().replace(/^\.\.\./, '');
|
|
1227
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name)) keys.add(name);
|
|
1228
|
+
}
|
|
1229
|
+
};
|
|
1230
|
+
const balanced = (text, from) => {
|
|
1231
|
+
let depth = 0;
|
|
1232
|
+
for (let i = from; i < text.length; i++) {
|
|
1233
|
+
if (text[i] === '{') depth++;
|
|
1234
|
+
else if (text[i] === '}' && --depth === 0) return text.slice(from + 1, i);
|
|
1235
|
+
}
|
|
1236
|
+
return '';
|
|
1237
|
+
};
|
|
1238
|
+
const constObject = (name) => {
|
|
1239
|
+
const m = src.match(new RegExp(`const\\s+${name}\\s*(?::[^=]*)?=\\s*\\{`));
|
|
1240
|
+
return m ? balanced(src, m.index + m[0].length - 1) : null;
|
|
1241
|
+
};
|
|
1242
|
+
let found = false;
|
|
1243
|
+
for (const m of src.matchAll(/\bseed\s*:\s*/g)) {
|
|
1244
|
+
const rest = src.slice(m.index + m[0].length);
|
|
1245
|
+
found = true;
|
|
1246
|
+
if (rest.startsWith('{')) {
|
|
1247
|
+
const body = balanced(src, m.index + m[0].length);
|
|
1248
|
+
objectKeys(body);
|
|
1249
|
+
// `{ ...SEED, selectedShot }` — follow every spread to its declaration.
|
|
1250
|
+
for (const sp of body.matchAll(/\.\.\.([A-Za-z_$][\w$]*)/g)) {
|
|
1251
|
+
const inner = constObject(sp[1]);
|
|
1252
|
+
if (inner) objectKeys(inner);
|
|
1253
|
+
}
|
|
1254
|
+
} else {
|
|
1255
|
+
const ident = rest.match(/^([A-Za-z_$][\w$]*)/);
|
|
1256
|
+
const inner = ident && constObject(ident[1]);
|
|
1257
|
+
if (inner) objectKeys(inner);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
return found ? keys : null;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* The region keys the host establishes by hand, by either act: `X.seed(...)` / `seedArchipelago(...)`.
|
|
1265
|
+
*
|
|
1266
|
+
* Both the positional form (`seed('k', v)`) and the object form (`seed({ k: v })`), because both are
|
|
1267
|
+
* written in this codebase and a check that knew only one would report the other as missing.
|
|
1268
|
+
*/
|
|
1269
|
+
/**
|
|
1270
|
+
* Keys the host hands over with `provide(...)`. Same two argument forms as `seededKeys`, and split
|
|
1271
|
+
* from it because the two acts mean different things: a seed is first paint, a provide is the host
|
|
1272
|
+
* feeding a key it owns — and it must not own one a declared source produces.
|
|
1273
|
+
*/
|
|
1274
|
+
function providedKeys(sources, binding, code) {
|
|
1275
|
+
const keys = new Set();
|
|
1276
|
+
const idents = (text) =>
|
|
1277
|
+
splitTop(text)
|
|
1278
|
+
.map((entry) => entry.split(':')[0].trim())
|
|
1279
|
+
.filter((k) => /^[A-Za-z_$][\w$]*$/.test(k));
|
|
1280
|
+
for (const [, text] of sources) {
|
|
1281
|
+
const src = code(text);
|
|
1282
|
+
for (const m of src.matchAll(new RegExp(`(?:${binding}\\.provide|provideToArchipelago)\\s*\\(`, 'g'))) {
|
|
1283
|
+
let depth = 0;
|
|
1284
|
+
let i = m.index + m[0].length - 1;
|
|
1285
|
+
const start = i + 1;
|
|
1286
|
+
for (; i < src.length; i++) {
|
|
1287
|
+
if (src[i] === '(') depth++;
|
|
1288
|
+
else if (src[i] === ')' && --depth === 0) break;
|
|
1289
|
+
}
|
|
1290
|
+
for (const arg of splitTop(src.slice(start, i))) {
|
|
1291
|
+
const a = arg.trim();
|
|
1292
|
+
const quoted = a.match(/^['"`]([A-Za-z_$][\w$]*)['"`]$/);
|
|
1293
|
+
if (quoted) keys.add(quoted[1]);
|
|
1294
|
+
else if (a.startsWith('{')) for (const k of idents(a.slice(1, -1))) keys.add(k);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
return keys;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function seededKeys(sources, binding, code) {
|
|
1302
|
+
const keys = new Set();
|
|
1303
|
+
const idents = (text) =>
|
|
1304
|
+
splitTop(text)
|
|
1305
|
+
.map((entry) => entry.split(':')[0].trim())
|
|
1306
|
+
.filter((k) => /^[A-Za-z_$][\w$]*$/.test(k));
|
|
1307
|
+
for (const [, text] of sources) {
|
|
1308
|
+
const src = code(text);
|
|
1309
|
+
for (const m of src.matchAll(new RegExp(`(?:${binding}\\.seed|seedArchipelago)\\s*\\(`, 'g'))) {
|
|
1310
|
+
let depth = 0;
|
|
1311
|
+
let i = m.index + m[0].length - 1;
|
|
1312
|
+
const start = i + 1;
|
|
1313
|
+
for (; i < src.length; i++) {
|
|
1314
|
+
if (src[i] === '(') depth++;
|
|
1315
|
+
else if (src[i] === ')' && --depth === 0) break;
|
|
1316
|
+
}
|
|
1317
|
+
const args = splitTop(src.slice(start, i));
|
|
1318
|
+
for (const arg of args) {
|
|
1319
|
+
const a = arg.trim();
|
|
1320
|
+
// `seed('key', value)` / `seedArchipelago(id, 'key', value)` — a quoted argument is a key name.
|
|
1321
|
+
const quoted = a.match(/^['"`]([A-Za-z_$][\w$]*)['"`]$/);
|
|
1322
|
+
if (quoted) keys.add(quoted[1]);
|
|
1323
|
+
// `seed({ recentMembers })` — the SHORTHAND is the form this codebase actually writes, and a
|
|
1324
|
+
// `key:` regex reads right past it. That is how the object form went undetected while the
|
|
1325
|
+
// positional one passed: the check was half-blind and both halves looked identical from here.
|
|
1326
|
+
else if (a.startsWith('{')) for (const k of idents(a.slice(1, -1))) keys.add(k);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
return keys;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/** Split on commas at depth 0 — nested objects, arrays, calls and generics stay whole. */
|
|
1334
|
+
function splitTop(text) {
|
|
1335
|
+
const out = [];
|
|
1336
|
+
let depth = 0;
|
|
1337
|
+
let last = 0;
|
|
1338
|
+
for (let i = 0; i < text.length; i++) {
|
|
1339
|
+
const c = text[i];
|
|
1340
|
+
if ('([{'.includes(c)) depth++;
|
|
1341
|
+
else if (')]}'.includes(c)) depth--;
|
|
1342
|
+
else if (c === ',' && depth === 0) {
|
|
1343
|
+
out.push(text.slice(last, i));
|
|
1344
|
+
last = i + 1;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
out.push(text.slice(last));
|
|
1348
|
+
return out.filter((s) => s.trim());
|
|
1349
|
+
}
|