@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,586 @@
|
|
|
1
|
+
// An island's static checks: its component's source, its registry entry, its stylesheet.
|
|
2
|
+
// Split out of commands/verify.mjs; verify.mjs re-exports the public surface.
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { SyntaxKind } from 'ts-morph';
|
|
6
|
+
import { islandContract } from '../../lib/contracts.mjs';
|
|
7
|
+
import { isKinded, readEffectEntries } from '../../lib/effects.mjs';
|
|
8
|
+
import { listIslands } from '../../lib/islands.mjs';
|
|
9
|
+
import { sourceFileAt } from '../../lib/ts-project.mjs';
|
|
10
|
+
import { HOST, LEGACY_FIT, islandComponentExport, islandComponentIdentifier, islandComponentPath, names, paths } from '../../lib/util.mjs';
|
|
11
|
+
|
|
12
|
+
export const DOC_QUERIES = new Set(['querySelector', 'querySelectorAll', 'getElementById', 'getElementsByClassName']);
|
|
13
|
+
|
|
14
|
+
/** Static analysis of the component source. */
|
|
15
|
+
export function staticChecks(report, componentPath, pascal) {
|
|
16
|
+
const sf = sourceFileAt(componentPath, { allowJs: true, jsx: 4 /* react-jsx */ });
|
|
17
|
+
const line = (n) => n.getStartLineNumber();
|
|
18
|
+
|
|
19
|
+
let sawFetch = false,
|
|
20
|
+
sawHistory = false,
|
|
21
|
+
sawDoc = false;
|
|
22
|
+
|
|
23
|
+
for (const id of sf.getDescendantsOfKind(SyntaxKind.Identifier)) {
|
|
24
|
+
if (id.getText() === 'fetch') {
|
|
25
|
+
const call = id.getParentIfKind(SyntaxKind.CallExpression);
|
|
26
|
+
if (call && call.getExpression() === id) {
|
|
27
|
+
report.error('no-bare-fetch', 'bare fetch() in the island — I/O goes through the contract, or through a host module the island declares in `contract.effects`', line(id));
|
|
28
|
+
sawFetch = true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (id.getText() === 'XMLHttpRequest') {
|
|
32
|
+
report.error('no-bare-fetch', 'XMLHttpRequest in the island — I/O goes through the contract, or through a host module the island declares in `contract.effects`', line(id));
|
|
33
|
+
sawFetch = true;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const pa of sf.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
|
|
38
|
+
const name = pa.getName();
|
|
39
|
+
const obj = pa.getExpression().getText();
|
|
40
|
+
if (name === 'pushState' || name === 'replaceState') {
|
|
41
|
+
report.error('no-history', `history.${name}() — the ocean owns the URL; emit a navigate intent`, line(pa));
|
|
42
|
+
sawHistory = true;
|
|
43
|
+
}
|
|
44
|
+
if (/^(window\.)?history$/.test(obj)) {
|
|
45
|
+
report.error('no-history', 'history access — navigation is a host intent, not the island', line(pa));
|
|
46
|
+
sawHistory = true;
|
|
47
|
+
}
|
|
48
|
+
if (/^(window\.|document\.)?location$/.test(obj) && name !== 'origin') {
|
|
49
|
+
report.error('no-history', `location.${name} — the ocean owns the URL`, line(pa));
|
|
50
|
+
sawHistory = true;
|
|
51
|
+
}
|
|
52
|
+
if (DOC_QUERIES.has(name) && /^(window\.)?document$/.test(obj)) {
|
|
53
|
+
report.error('no-doc-reachout', `document.${name}() — islands never reach into the host DOM`, line(pa));
|
|
54
|
+
sawDoc = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!sawFetch) report.ok('no-bare-fetch', 'no bare fetch / XMLHttpRequest');
|
|
58
|
+
if (!sawHistory) report.ok('no-history', 'no history / location access');
|
|
59
|
+
if (!sawDoc) report.ok('no-doc-reachout', 'no document reach-out');
|
|
60
|
+
|
|
61
|
+
// Import discipline: no raw transport, no third-party HTTP client.
|
|
62
|
+
let badImport = false;
|
|
63
|
+
const httpLibs = new Set(['axios', 'ky', 'superagent', 'node-fetch', 'got']);
|
|
64
|
+
const rawTransport = new Set(['configure', 'HttpTransport', 'MockTransport']);
|
|
65
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
66
|
+
const spec = imp.getModuleSpecifierValue();
|
|
67
|
+
if (httpLibs.has(spec)) {
|
|
68
|
+
report.error('contract-only-io', `imports HTTP client '${spec}' — use @motujs/contract`, line(imp));
|
|
69
|
+
badImport = true;
|
|
70
|
+
}
|
|
71
|
+
if (spec === '@motujs/runtime' || spec === '@motujs/runtime/mock') {
|
|
72
|
+
for (const n of imp.getNamedImports()) {
|
|
73
|
+
if (rawTransport.has(n.getName())) {
|
|
74
|
+
report.error('contract-only-io', `imports raw transport '${n.getName()}' — use @motujs/contract`, line(imp));
|
|
75
|
+
badImport = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!badImport) report.ok('contract-only-io', 'server I/O only via @motujs/contract');
|
|
81
|
+
|
|
82
|
+
// Layer discipline: a ui/ component is the reusable "mainland" — it must not reach back into the
|
|
83
|
+
// mount points (islands/) or compositions (archipelagos/), so it stays liftable and islands can't
|
|
84
|
+
// couple through it. ui -> ui composition (`../<other>/…`) and shared/ are fine.
|
|
85
|
+
let sawLayerViolation = false;
|
|
86
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
87
|
+
const spec = imp.getModuleSpecifierValue();
|
|
88
|
+
const m = spec.match(/(?:^|\/)(islands|archipelagos)\//);
|
|
89
|
+
if (m) {
|
|
90
|
+
report.error('ui-layering', `imports ${m[1]}/ — a ui component must not depend on mount points or compositions (keep it liftable; coordinate at runtime via the store)`, line(imp));
|
|
91
|
+
sawLayerViolation = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!sawLayerViolation) report.ok('ui-layering', 'ui component does not reach into islands/ or archipelagos/');
|
|
95
|
+
|
|
96
|
+
// Contract calls: every @motujs/contract service.method the component invokes must exist in the
|
|
97
|
+
// generated contract — catches invented endpoints / method names stale after a backend change.
|
|
98
|
+
const contractServices = new Set();
|
|
99
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
100
|
+
if (imp.getModuleSpecifierValue() === '@motujs/contract' && !imp.isTypeOnly()) {
|
|
101
|
+
for (const n of imp.getNamedImports()) contractServices.add(n.getName());
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (contractServices.size) {
|
|
105
|
+
const pairs = contractPairs();
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
let badCall = false;
|
|
108
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
109
|
+
const expr = call.getExpression();
|
|
110
|
+
if (expr.getKind() !== SyntaxKind.PropertyAccessExpression) continue;
|
|
111
|
+
const obj = expr.getExpression().getText();
|
|
112
|
+
if (!contractServices.has(obj)) continue;
|
|
113
|
+
const key = `${obj}.${expr.getName()}`;
|
|
114
|
+
if (seen.has(key)) continue;
|
|
115
|
+
seen.add(key);
|
|
116
|
+
if (pairs.size && !pairs.has(key)) {
|
|
117
|
+
report.error('contract-calls', `calls ${key}() which isn't in @motujs/contract`, line(call));
|
|
118
|
+
badCall = true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!badCall) report.ok('contract-calls', 'contract calls exist in @motujs/contract');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Renders from default props alone. The lagoon mount below is the authoritative check (it mounts
|
|
125
|
+
// with zero props); statically we only flag the clearly-bad case: a required, non-destructured props
|
|
126
|
+
// object with no default. Destructured params (with per-field defaults / optional callbacks) are the
|
|
127
|
+
// idiom and are confirmed at runtime.
|
|
128
|
+
const fn =
|
|
129
|
+
sf.getFunction(pascal) ??
|
|
130
|
+
sf.getVariableDeclaration(pascal)?.getInitializerIfKind(SyntaxKind.ArrowFunction);
|
|
131
|
+
if (!fn) {
|
|
132
|
+
report.warn('default-props', `couldn't find component function ${pascal} to check default props`);
|
|
133
|
+
} else {
|
|
134
|
+
const p = fn.getParameters()[0];
|
|
135
|
+
const isDestructured = p?.getNameNode()?.getKind() === SyntaxKind.ObjectBindingPattern;
|
|
136
|
+
if (!p || p.hasInitializer() || p.isOptional() || isDestructured) {
|
|
137
|
+
report.ok('default-props', 'renders from default props alone');
|
|
138
|
+
} else {
|
|
139
|
+
report.error(
|
|
140
|
+
'default-props',
|
|
141
|
+
'component must render from default props — destructure with defaults or give the props param a default (e.g. `= {}`)',
|
|
142
|
+
line(fn),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The component's declared prop names, for the registry reconciliation in configChecks.
|
|
148
|
+
//
|
|
149
|
+
// Read from the props PARAMETER'S TYPE, not from a `${Pascal}Props` interface by name. The name
|
|
150
|
+
// lookup only saw components written for this check: an app's own components type their props
|
|
151
|
+
// inline, so the reconciliation silently skipped exactly the components a modern host wraps — and
|
|
152
|
+
// made the framework ask for an interface it did not actually need. The parameter's type covers
|
|
153
|
+
// every shape (inline literal, named interface, intersection); the interface lookup stays as the
|
|
154
|
+
// fallback for when types cannot be resolved from this file alone (an imported props type).
|
|
155
|
+
const propNames = paramPropNames(fn) ?? sf.getInterface(`${pascal}Props`)?.getProperties().map((m) => m.getName()) ?? null;
|
|
156
|
+
return { propNames };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Property names of a component's props parameter, or null when its type resolves to nothing. */
|
|
160
|
+
export function paramPropNames(fn) {
|
|
161
|
+
const param = fn?.getParameters?.()[0];
|
|
162
|
+
if (!param) return null;
|
|
163
|
+
let props;
|
|
164
|
+
try {
|
|
165
|
+
props = param.getType().getProperties();
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
// `getProperties()` on an unresolved type is empty, not an error — treat that as "unknown", so the
|
|
170
|
+
// caller falls back rather than reporting a component with props as having none.
|
|
171
|
+
return props.length ? props.map((p) => p.getName()) : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Registry + archipelago membership checks. Returns the tag if registered. */
|
|
175
|
+
export function configChecks(report, kebab, pascal, expectedTag, standalone, componentProps, componentPath) {
|
|
176
|
+
const elementPath = paths.elementFile(kebab);
|
|
177
|
+
if (!existsSync(elementPath)) {
|
|
178
|
+
report.error('registered', `no element.ts at ${paths.rel(elementPath)}`);
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
const sf = sourceFileAt(elementPath, { allowJs: true });
|
|
182
|
+
|
|
183
|
+
// Mount-point discipline: an island's element.ts must never import ANOTHER island — mount points
|
|
184
|
+
// coordinate only at runtime through the store. Its own component lives in ui/ (`../../ui/…`, which
|
|
185
|
+
// is two levels up), so a single `../<other>/…` is a sibling-island reference.
|
|
186
|
+
let sawSiblingIsland = false;
|
|
187
|
+
// Resolve against the actual island list rather than a path SHAPE. The folder layout made
|
|
188
|
+
// "sibling island" and "../<something>/" the same thing; the flat layout does not — there,
|
|
189
|
+
// `../ui/...` is the ui directory one level up, which is exactly what a mount point is supposed to
|
|
190
|
+
// import. Matching on shape flagged it as a sibling island.
|
|
191
|
+
const otherIslands = new Set(listIslands(paths.islandsDir).map((i) => i.kebab).filter((k) => k !== kebab));
|
|
192
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
193
|
+
const spec = imp.getModuleSpecifierValue();
|
|
194
|
+
const sibling =
|
|
195
|
+
spec.match(/^\.\.\/([^./][^/]*)\//)?.[1] ?? // folder layout: ../<kebab>/element.js
|
|
196
|
+
spec.match(/^\.\/([^./]+)\.island(?:\.js)?$/)?.[1]; // flat layout: ./<kebab>.island.js
|
|
197
|
+
if (sibling && otherIslands.has(sibling)) {
|
|
198
|
+
report.error('no-island-import', `imports sibling island '${sibling}' — mount points never import each other`, imp.getStartLineNumber());
|
|
199
|
+
sawSiblingIsland = true;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (!sawSiblingIsland) report.ok('no-island-import', 'mount point imports no sibling island');
|
|
203
|
+
|
|
204
|
+
// What the island file declares, in EITHER form: the long `islandElement({ tag, component, options })`
|
|
205
|
+
// literal, or the short `island('x-tag', Component)` call whose derivable half lives in the generated
|
|
206
|
+
// contracts file. The short form is the point of that generation — a mount point that says only what
|
|
207
|
+
// someone decided — so the checks below read both rather than insisting on the shape.
|
|
208
|
+
const elementText = readFileSync(elementPath, 'utf8');
|
|
209
|
+
let row;
|
|
210
|
+
for (const obj of sf.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)) {
|
|
211
|
+
if (obj.getProperty('tag') && obj.getProperty('component')) {
|
|
212
|
+
row = obj;
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const shortForm = !row && /\bisland\(\s*['"`]/.test(elementText);
|
|
217
|
+
if (!row && !shortForm) {
|
|
218
|
+
report.error('registered', `${pascal} has no island declaration in ${paths.rel(elementPath)}`);
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
// The identifier must resolve to something imported here; its NAME is the app's business (an island
|
|
222
|
+
// wraps the app's own component), so a difference from the island's kebab is not a finding.
|
|
223
|
+
const comp = row
|
|
224
|
+
? row.getProperty('component')?.getText().replace(/^component:\s*/, '').trim()
|
|
225
|
+
: islandComponentIdentifier(elementText);
|
|
226
|
+
if (comp && !sf.getImportDeclarations().some((i) => i.getNamedImports().some((n) => (n.getAliasNode() ?? n.getNameNode()).getText() === comp))) {
|
|
227
|
+
report.warn('registered', `element.ts mounts '${comp}', which is not imported in this file`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Confirm it's wired into the assembled ELEMENT_REGISTRY.
|
|
231
|
+
const registryText = existsSync(paths.islandsRegistry) ? readFileSync(paths.islandsRegistry, 'utf8') : '';
|
|
232
|
+
// Either layout's specifier, with or without the '.js'. motu now writes them extensionless (the
|
|
233
|
+
// host's bundler has to resolve them and not every one can map '.js' -> '.ts'), but a project
|
|
234
|
+
// generated before that still has the old form and is not broken.
|
|
235
|
+
const registered = [`./${kebab}.island`, `./${kebab}/element`].some(
|
|
236
|
+
(s) => registryText.includes(`${s}'`) || registryText.includes(`${s}.js'`) ||
|
|
237
|
+
registryText.includes(`${s}"`) || registryText.includes(`${s}.js"`),
|
|
238
|
+
);
|
|
239
|
+
if (registered) {
|
|
240
|
+
report.ok('registered', 'registered in ELEMENT_REGISTRY');
|
|
241
|
+
} else {
|
|
242
|
+
report.error('registered', `not wired into ${paths.rel(paths.islandsRegistry)}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const tag = row
|
|
246
|
+
? row
|
|
247
|
+
.getProperty('tag')
|
|
248
|
+
?.getText()
|
|
249
|
+
.replace(/^tag:\s*/, '')
|
|
250
|
+
.trim()
|
|
251
|
+
.replace(/['"]/g, '')
|
|
252
|
+
: elementText.match(/\bisland\(\s*'([^']+)'/)?.[1];
|
|
253
|
+
const options = row?.getProperty('options')?.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression);
|
|
254
|
+
// In the short form the boundary is generated, so it is read from the generated file rather than
|
|
255
|
+
// from this one — the same data the checks below expect, just not hand-written.
|
|
256
|
+
const generated = shortForm
|
|
257
|
+
? islandContract({ kebab, element: elementPath }, { islandComponentPath, islandComponentExport, names })
|
|
258
|
+
: null;
|
|
259
|
+
// Fitting an island to a legacy skin is only meaningful when the host HAS one. On a React host
|
|
260
|
+
// (next/none) the island mounts directly, so requiring the strategy would be dead ceremony.
|
|
261
|
+
// POSTURE FIRST. Testing for the field first (as this did) makes the skip branch unreachable for any
|
|
262
|
+
// project that declares it — which is every project, because the type used to require it. acme's eight
|
|
263
|
+
// islands each reported "declares a required legacy-fit strategy" under `host: next`, asserting a
|
|
264
|
+
// requirement that does not exist there.
|
|
265
|
+
if (!LEGACY_FIT) {
|
|
266
|
+
if (options?.getProperty('legacy')) {
|
|
267
|
+
report.warn(
|
|
268
|
+
'legacy-strategy',
|
|
269
|
+
`declares \`legacy\` but host is '${HOST}', which has no legacy skin to fit — remove it (nothing reads it)`,
|
|
270
|
+
);
|
|
271
|
+
} else {
|
|
272
|
+
report.skip('legacy-strategy', `no legacy fit on host '${HOST}' — nothing to fit to`);
|
|
273
|
+
}
|
|
274
|
+
} else if (options?.getProperty('legacy')) {
|
|
275
|
+
report.ok('legacy-strategy', 'declares a required legacy-fit strategy');
|
|
276
|
+
} else {
|
|
277
|
+
report.error('legacy-strategy', 'element row is missing the required `legacy` strategy');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// props/events reconciliation: the registry (element.ts) and the component are the two sources of
|
|
281
|
+
// truth for an island's props. Cross-check them so they can't drift — a registered prop the component
|
|
282
|
+
// doesn't accept is dead config; a component prop that's neither registered nor an event can never be
|
|
283
|
+
// set. (Only for destructured React components; AngularJS/opaque components are skipped.) Reads
|
|
284
|
+
// either the grouped `contract: { input, output }` or the flat `props`/`events` form.
|
|
285
|
+
if (componentProps?.propNames) {
|
|
286
|
+
const compNames = componentProps.propNames;
|
|
287
|
+
const registeredProps = generated ? generated.input : objectArrayStrings(optionProp(options, 'props', 'input'));
|
|
288
|
+
const eventKeys = generated ? Object.keys(generated.output) : objectKeys(optionProp(options, 'events', 'output'));
|
|
289
|
+
const before = report.findings.length;
|
|
290
|
+
|
|
291
|
+
for (const rp of registeredProps) {
|
|
292
|
+
if (!compNames.includes(rp)) {
|
|
293
|
+
report.error('props-match', `registered prop '${rp}' is not a prop of ${pascal} (dead registry entry)`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
for (const cp of compNames) {
|
|
297
|
+
if (cp === 'fit') continue; // framework-injected footprint prop
|
|
298
|
+
// `/^on[A-Z]/`, the same rule the contract reader uses. `startsWith('on')` called
|
|
299
|
+
// `onboardingState` a callback and asked for an event mapping for a string prop.
|
|
300
|
+
if (/^on[A-Z]/.test(cp)) {
|
|
301
|
+
if (!eventKeys.includes(cp)) {
|
|
302
|
+
report.warn('props-match', `callback '${cp}' isn't mapped to an event in element.ts (its output is dropped)`);
|
|
303
|
+
}
|
|
304
|
+
} else if (!registeredProps.includes(cp)) {
|
|
305
|
+
report.warn('props-match', `prop '${cp}' isn't registered in element.ts props — it can never be set`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (report.findings.length === before) report.ok('props-match', 'registry props/events match the component');
|
|
309
|
+
} else {
|
|
310
|
+
// Drift detection reads the `${Pascal}Props` interface. A component that types its props inline
|
|
311
|
+
// (common in an app's own components, which never expected to be reconciled against a registry)
|
|
312
|
+
// gives nothing to compare — say so, rather than silently dropping the check and looking green.
|
|
313
|
+
report.warn(
|
|
314
|
+
'props-match',
|
|
315
|
+
`no \`${pascal}Props\` interface on the component — registry/component drift can't be checked (declare one to enable it)`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// --- effects: the third leg of the boundary ---------------------------------------------------
|
|
320
|
+
//
|
|
321
|
+
// Input and output are declared. EFFECTS — everything a component reaches that it was not handed:
|
|
322
|
+
// a React context, a session hook, a feature gate, a service module it imports directly — was not,
|
|
323
|
+
// and it is the reach most likely to make an island unmountable somewhere else. It hid in the
|
|
324
|
+
// lagoon's `alias` table, where standing a module down looked like build configuration rather than
|
|
325
|
+
// a declared dependency.
|
|
326
|
+
//
|
|
327
|
+
// ONLY THE MODULE ENTRIES ARE ANSWERABLE HERE. `effects` is one list of everything an island
|
|
328
|
+
// reaches, tagged by kind, and the kinds are checked where each can be known: a bare specifier is a
|
|
329
|
+
// module and is compared against the component's own imports, below; `scope:` is the AngularJS
|
|
330
|
+
// adapter's to check; `table:`/`rpc:`/`fn:`/`route:` are runtime facts and belong to `data-reach`.
|
|
331
|
+
// Comparing a prefixed entry against an import list would report every one of them as undeclared.
|
|
332
|
+
//
|
|
333
|
+
// It is DERIVED, not asked for twice: the lagoon's alias keys are exactly the modules this project
|
|
334
|
+
// has had to stand down, so an island importing one of them requires it. Declaring it in `effects` makes
|
|
335
|
+
// that visible in the island, in the seam lens and in the contract snapshot.
|
|
336
|
+
{
|
|
337
|
+
const lagoonCfg = resolve(paths.lagoonDir, 'lagoon.config.json');
|
|
338
|
+
let aliases = [];
|
|
339
|
+
try {
|
|
340
|
+
aliases = Object.keys(JSON.parse(readFileSync(lagoonCfg, 'utf8')).alias ?? {});
|
|
341
|
+
} catch {
|
|
342
|
+
/* no lagoon config, or none declared */
|
|
343
|
+
}
|
|
344
|
+
if (aliases.length && existsSync(componentPath)) {
|
|
345
|
+
// Over RUNTIME imports only. A `import type { Mission } from '@/lib/services/missions'` erases —
|
|
346
|
+
// the island never calls that module, and asking it to declare the shape it borrows as a host
|
|
347
|
+
// capability is how a true list turns into a list nobody trusts.
|
|
348
|
+
const src = readFileSync(componentPath, 'utf8')
|
|
349
|
+
.split('\n')
|
|
350
|
+
.filter((l) => !/^\s*import\s+type\b/.test(l))
|
|
351
|
+
.join('\n');
|
|
352
|
+
const used = aliases.filter((a) => new RegExp(`from\\s*['"]${a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]`).test(src)).sort();
|
|
353
|
+
// Either place the boundary can live: written in the island file, or generated from the
|
|
354
|
+
// component into the contracts file. Reading only the island file reported every short-form
|
|
355
|
+
// island as declaring nothing.
|
|
356
|
+
// `[\s\S]*?` up to the closing bracket, because an entry may now be an OBJECT and the old
|
|
357
|
+
// `[^\]]*` stopped at the first one containing a bracket. `readEffectEntries` then normalises
|
|
358
|
+
// both forms to the canonical strings the runtime records.
|
|
359
|
+
const declaredText = readFileSync(elementPath, 'utf8').match(/effects:\s*\[([\s\S]*?)\]/)?.[1];
|
|
360
|
+
const all =
|
|
361
|
+
declaredText !== undefined
|
|
362
|
+
? readEffectEntries(declaredText)
|
|
363
|
+
: islandContract({ kebab, element: elementPath }, { islandComponentPath, islandComponentExport, names }).effects.slice();
|
|
364
|
+
// The MODULE entries only — see the note above about the other kinds.
|
|
365
|
+
const declared = all.filter((e) => !isKinded(e)).sort();
|
|
366
|
+
const missing = used.filter((u) => !declared.includes(u));
|
|
367
|
+
const stale = declared.filter((d) => !used.includes(d));
|
|
368
|
+
if (missing.length) {
|
|
369
|
+
report.warn('effects', `reaches a host module it does not declare: ${missing.join(', ')} — add it to \`contract.effects\``);
|
|
370
|
+
}
|
|
371
|
+
if (stale.length) {
|
|
372
|
+
report.warn('effects', `declares a host module it does not import: ${stale.join(', ')}`);
|
|
373
|
+
}
|
|
374
|
+
if (!missing.length && !stale.length) {
|
|
375
|
+
report.ok('effects', used.length ? `declares the host modules it reaches: ${used.join(', ')}` : 'reaches no host module');
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Membership: scan every archipelago file for this tag.
|
|
381
|
+
//
|
|
382
|
+
// An archipelago is shared state, so most islands are NOT in one — an island that couples with
|
|
383
|
+
// nothing is standalone, and that is a normal, permanent state rather than unfinished work. It is
|
|
384
|
+
// declared on the island (`standalone: true`) rather than passed to verify, because it is a property
|
|
385
|
+
// of the island, not of how you happened to invoke the check.
|
|
386
|
+
const declaredStandalone = /\bstandalone:\s*true\b/.test(readFileSync(elementPath, 'utf8'));
|
|
387
|
+
if (tag && archipelagoFilesInclude(`element: '${tag}'`)) {
|
|
388
|
+
report.ok('archipelago', 'member of an archipelago');
|
|
389
|
+
} else if (standalone || declaredStandalone) {
|
|
390
|
+
report.ok('archipelago', 'standalone island — couples with nothing');
|
|
391
|
+
} else {
|
|
392
|
+
report.warn('archipelago', `not in an archipelago — declare \`standalone: true\` if it shares state with no other island`);
|
|
393
|
+
}
|
|
394
|
+
return tag ?? expectedTag;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** The service.method pairs the generated contract actually exposes (each method body is a
|
|
398
|
+
* `call<…>('Service', 'method', [args])`). Used to reject invented endpoints. */
|
|
399
|
+
export function contractPairs() {
|
|
400
|
+
const set = new Set();
|
|
401
|
+
if (!existsSync(paths.contract)) return set;
|
|
402
|
+
const text = readFileSync(paths.contract, 'utf8');
|
|
403
|
+
for (const m of text.matchAll(/'(\w+)',\s*'(\w+)',\s*\[/g)) set.add(`${m[1]}.${m[2]}`);
|
|
404
|
+
return set;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Unwrap a `[...] as T[]` cast to the underlying node (props arrays are written with an `as` cast). */
|
|
408
|
+
export function unwrapAs(node) {
|
|
409
|
+
return node && node.getKind() === SyntaxKind.AsExpression ? node.getExpression() : node;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** The `contract: { ... }` object literal on an options node, if the island uses the grouped form. */
|
|
413
|
+
export function contractObj(options) {
|
|
414
|
+
return options?.getProperty('contract')?.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** An options property, preferring the grouped `contract.<contractName>` over the flat `<flatName>`. */
|
|
418
|
+
export function optionProp(options, flatName, contractName) {
|
|
419
|
+
return options?.getProperty(flatName) ?? contractObj(options)?.getProperty(contractName);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The prop names of a `props: [...]` (possibly `as`-cast) assignment — bare strings or {name} specs. */
|
|
423
|
+
export function objectArrayStrings(prop) {
|
|
424
|
+
const init = unwrapAs(prop?.getInitializer());
|
|
425
|
+
if (!init || init.getKind() !== SyntaxKind.ArrayLiteralExpression) return [];
|
|
426
|
+
const names = [];
|
|
427
|
+
for (const e of init.getElements()) {
|
|
428
|
+
if (e.getKind() === SyntaxKind.StringLiteral) {
|
|
429
|
+
names.push(e.getText().replace(/['"]/g, ''));
|
|
430
|
+
} else if (e.getKind() === SyntaxKind.ObjectLiteralExpression) {
|
|
431
|
+
const nameProp = e.getProperty('name')?.getInitializer();
|
|
432
|
+
if (nameProp?.getKind() === SyntaxKind.StringLiteral) names.push(nameProp.getText().replace(/['"]/g, ''));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return names;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** The property names of an `events: { ... }` (possibly `as`-cast) property assignment. */
|
|
439
|
+
export function objectKeys(prop) {
|
|
440
|
+
const init = unwrapAs(prop?.getInitializer());
|
|
441
|
+
if (!init || init.getKind() !== SyntaxKind.ObjectLiteralExpression) return [];
|
|
442
|
+
return init.getProperties().map((p) => p.getName?.()).filter(Boolean);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Lint a stylesheet for the two motu CSS rules: dual-mode host selectors, and colours from tokens.
|
|
446
|
+
* - css-host-form (error): every `:host` must be functional (`:host(...)`) or wrapped in
|
|
447
|
+
* `:where(:host, .motu-root)`, so the rule also applies in light isolation (a bare `:host` is
|
|
448
|
+
* inert in light DOM). The attribute-variant form `:host([attr]), .motu-root[attr]` is fine.
|
|
449
|
+
* - css-tokens (warn): brand-ish hex literals belong in a `--x-*`/`--_*` token definition or a
|
|
450
|
+
* `var(...)`, not raw in a normal declaration (white/black are allowed).
|
|
451
|
+
*/
|
|
452
|
+
export function cssChecks(report, cssText, label) {
|
|
453
|
+
// Blank out comments but keep newlines so line numbers stay accurate.
|
|
454
|
+
const src = cssText.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
|
|
455
|
+
const lineAt = (idx) => src.slice(0, Math.max(0, idx)).split('\n').length;
|
|
456
|
+
|
|
457
|
+
let bareHost = 0;
|
|
458
|
+
let literals = 0;
|
|
459
|
+
const ruleRe = /([^{}]*)\{([^{}]*)\}/g;
|
|
460
|
+
let m;
|
|
461
|
+
while ((m = ruleRe.exec(src))) {
|
|
462
|
+
const selector = m[1];
|
|
463
|
+
const body = m[2];
|
|
464
|
+
const bodyStart = m.index + m[1].length + 1;
|
|
465
|
+
|
|
466
|
+
if (selector.includes(':host')) {
|
|
467
|
+
const stripped = selector.replace(/:where\([^)]*\)/g, '');
|
|
468
|
+
if (/:host(?!\()/.test(stripped)) {
|
|
469
|
+
report.error(
|
|
470
|
+
'css-host-form',
|
|
471
|
+
`${label}: bare \`:host\` — use \`:where(:host, .motu-root)\` (base) or \`:host([attr]), .motu-root[attr]\` (variant) so light mode works`,
|
|
472
|
+
lineAt(m.index + selector.indexOf(':host')),
|
|
473
|
+
);
|
|
474
|
+
bareHost++;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
let d;
|
|
479
|
+
const declRe = /([-\w]+)\s*:\s*([^;]+)/g;
|
|
480
|
+
while ((d = declRe.exec(body))) {
|
|
481
|
+
const prop = d[1];
|
|
482
|
+
if (prop.startsWith('--')) continue; // custom-property definition — the token's literal source
|
|
483
|
+
const brand = (d[2].match(/#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/g) || []).filter(
|
|
484
|
+
(h) => !/^#(fff|ffffff|000|000000)$/i.test(h),
|
|
485
|
+
);
|
|
486
|
+
if (brand.length) {
|
|
487
|
+
report.warn('css-tokens', `${label}: literal colour ${brand[0]} in \`${prop}\` — prefer a --x-*/--_* token`, lineAt(bodyStart + d.index));
|
|
488
|
+
literals++;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (!bareHost) report.ok('css-host-form', `${label}: host rules work in both isolation modes`);
|
|
493
|
+
if (!literals) report.ok('css-tokens', `${label}: colours come from tokens`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** The island's own stylesheet if it owns one (any .css in its folder), else null (opt-in ownership). */
|
|
497
|
+
export function islandOwnCss(kebab) {
|
|
498
|
+
const dir = paths.islandDir(kebab);
|
|
499
|
+
if (!existsSync(dir)) return null;
|
|
500
|
+
const css = readdirSync(dir).find((f) => f.endsWith('.css'));
|
|
501
|
+
return css ? resolve(dir, css) : null;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* The balanced block that follows `label` — `{...}` or `[...]`, nesting included.
|
|
506
|
+
*
|
|
507
|
+
* The naive `label:\s*\{([^}]*)\}` this replaces stops at the first inner `}`, which silently
|
|
508
|
+
* truncated any nested declaration (`writes: { 'e': { field: 'key' } }` lost everything after it).
|
|
509
|
+
*/
|
|
510
|
+
export function blockAfter(code, label, open, from = 0) {
|
|
511
|
+
const at = code.indexOf(label, from);
|
|
512
|
+
if (at === -1) return null;
|
|
513
|
+
const start = code.indexOf(open, at);
|
|
514
|
+
if (start === -1) return null;
|
|
515
|
+
const close = open === '{' ? '}' : ']';
|
|
516
|
+
let depth = 0;
|
|
517
|
+
for (let i = start; i < code.length; i++) {
|
|
518
|
+
if (code[i] === open) depth++;
|
|
519
|
+
else if (code[i] === close && --depth === 0) return { body: code.slice(start + 1, i), end: i };
|
|
520
|
+
}
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Every balanced block introduced by `label` in the file. */
|
|
525
|
+
export function blocksAfter(code, label, open) {
|
|
526
|
+
const out = [];
|
|
527
|
+
let from = 0;
|
|
528
|
+
for (;;) {
|
|
529
|
+
const at = code.indexOf(label, from);
|
|
530
|
+
if (at === -1) return out;
|
|
531
|
+
const block = blockAfter(code, label, open, at);
|
|
532
|
+
if (!block) return out;
|
|
533
|
+
out.push(block.body);
|
|
534
|
+
from = block.end;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Store keys read by the islands in an archipelago file, in either `bind` form.
|
|
540
|
+
*
|
|
541
|
+
* The opener has to be the character that IMMEDIATELY follows `bind:` — searching for the next `[`
|
|
542
|
+
* from there would happily jump over a `{...}` bind and land in some unrelated array later in the
|
|
543
|
+
* file, reporting keys nobody binds.
|
|
544
|
+
*/
|
|
545
|
+
export function boundKeysIn(code) {
|
|
546
|
+
return boundKeyGroups(code).flat();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** The same keys, GROUPED — one array per `bind:` block, which is one per island. */
|
|
550
|
+
export function boundKeyGroups(code) {
|
|
551
|
+
const out = [];
|
|
552
|
+
const re = /\bbind:\s*([[{])/g;
|
|
553
|
+
for (const m of code.matchAll(re)) {
|
|
554
|
+
const block = blockAfter(code, 'bind:', m[1], m.index);
|
|
555
|
+
if (!block) continue;
|
|
556
|
+
// In the array form a bare entry IS the key and a `{ prop: 'key' }` entry names one; taking every
|
|
557
|
+
// quoted string would count the prop name as a key, so the renames are read off the value side and
|
|
558
|
+
// the bare entries off what is left.
|
|
559
|
+
const keys =
|
|
560
|
+
m[1] === '['
|
|
561
|
+
? [
|
|
562
|
+
...[...block.body.matchAll(/(?<![\w'"])'([^']+)'(?!\s*:)/g)].map((x) => x[1]),
|
|
563
|
+
...[...block.body.matchAll(/:\s*'([^']+)'/g)].map((x) => x[1]),
|
|
564
|
+
]
|
|
565
|
+
: [...block.body.matchAll(/:\s*['"]([^'"]+)['"]/g)].map((x) => x[1]);
|
|
566
|
+
out.push(keys);
|
|
567
|
+
}
|
|
568
|
+
return out;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Store keys in a `bind` / `writes` block: every quoted string that sits on the VALUE side. */
|
|
572
|
+
export function keysIn(blocks) {
|
|
573
|
+
return blocks.flatMap((b) => [...b.matchAll(/:\s*['"]([^'"]+)['"]/g)].map((m) => m[1]));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** True if any archipelago file contains the given substring. */
|
|
577
|
+
export function archipelagoFilesInclude(needle) {
|
|
578
|
+
const dir = paths.archipelagosDir;
|
|
579
|
+
if (!existsSync(dir)) return false;
|
|
580
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
581
|
+
if (!entry.isDirectory()) continue;
|
|
582
|
+
const f = paths.archipelagoFile(entry.name);
|
|
583
|
+
if (existsSync(f) && readFileSync(f, 'utf8').includes(needle)) return true;
|
|
584
|
+
}
|
|
585
|
+
return false;
|
|
586
|
+
}
|