@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.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/bundled/github/agents/archipelago-build.agent.md +298 -0
  3. package/bundled/github/agents/island-create.agent.md +261 -0
  4. package/bundled/github/agents/island-extract.agent.md +360 -0
  5. package/bundled/github/agents/island-locate.agent.md +208 -0
  6. package/bundled/github/agents/motu-checks.agent.md +158 -0
  7. package/bundled/github/agents/motu-evidence.agent.md +267 -0
  8. package/bundled/github/agents/motu-regions.agent.md +304 -0
  9. package/bundled/github/host-rules.md +43 -0
  10. package/bundled/github/prompts/archipelago-build.prompt.md +19 -0
  11. package/bundled/github/prompts/island-create.prompt.md +37 -0
  12. package/bundled/github/prompts/island-extract.prompt.md +59 -0
  13. package/bundled/github/prompts/island-locate.prompt.md +43 -0
  14. package/bundled/packages/adapters/angularjs/vite.mjs +43 -0
  15. package/bundled/packages/adapters/next/lagoon/next-stubs.tsx +108 -0
  16. package/bundled/packages/adapters/next/vite.mjs +121 -0
  17. package/bundled/packages/adapters/vite/vite.mjs +179 -0
  18. package/package.json +64 -3
  19. package/src/alias-loader.mjs +63 -0
  20. package/src/alias-register.mjs +28 -0
  21. package/src/asset-stub.cjs +3 -0
  22. package/src/asset-stub.mjs +5 -0
  23. package/src/cli.mjs +78 -0
  24. package/src/commands/adopt-root.mjs +194 -0
  25. package/src/commands/archipelago-init.mjs +314 -0
  26. package/src/commands/archipelago.mjs +138 -0
  27. package/src/commands/assurance.mjs +40 -0
  28. package/src/commands/check.mjs +346 -0
  29. package/src/commands/codegen.mjs +29 -0
  30. package/src/commands/coverage.mjs +358 -0
  31. package/src/commands/create.mjs +277 -0
  32. package/src/commands/defaults.mjs +129 -0
  33. package/src/commands/diff.mjs +595 -0
  34. package/src/commands/fixtures.mjs +153 -0
  35. package/src/commands/init.mjs +473 -0
  36. package/src/commands/integrate.mjs +208 -0
  37. package/src/commands/integration.mjs +1349 -0
  38. package/src/commands/lagoon-try.mjs +272 -0
  39. package/src/commands/lagoon.mjs +1419 -0
  40. package/src/commands/link.mjs +110 -0
  41. package/src/commands/region-coverage.mjs +604 -0
  42. package/src/commands/removal-check.mjs +878 -0
  43. package/src/commands/skills.mjs +156 -0
  44. package/src/commands/snapshot.mjs +456 -0
  45. package/src/commands/sync.mjs +38 -0
  46. package/src/commands/verify/island-runtime.mjs +581 -0
  47. package/src/commands/verify/island-static.mjs +586 -0
  48. package/src/commands/verify/island.mjs +135 -0
  49. package/src/commands/verify/reach.mjs +426 -0
  50. package/src/commands/verify/region-composition.mjs +391 -0
  51. package/src/commands/verify/region-config.mjs +330 -0
  52. package/src/commands/verify/region-flows.mjs +760 -0
  53. package/src/commands/verify/region-sources.mjs +516 -0
  54. package/src/commands/verify/region.mjs +271 -0
  55. package/src/commands/verify/report.mjs +338 -0
  56. package/src/commands/verify/screens-runtime.mjs +123 -0
  57. package/src/commands/verify.mjs +18 -0
  58. package/src/lagoon-build.mjs +25 -0
  59. package/src/lagoon-dev.mjs +32 -0
  60. package/src/lib/alias-core.mjs +49 -0
  61. package/src/lib/archipelagos.mjs +111 -0
  62. package/src/lib/assurance.mjs +144 -0
  63. package/src/lib/baselines.mjs +107 -0
  64. package/src/lib/bundlability.mjs +123 -0
  65. package/src/lib/changed.mjs +157 -0
  66. package/src/lib/component-props.mjs +145 -0
  67. package/src/lib/config.mjs +451 -0
  68. package/src/lib/contracts.mjs +244 -0
  69. package/src/lib/data-flow.mjs +47 -0
  70. package/src/lib/declared-sources.mjs +101 -0
  71. package/src/lib/dock-inject.mjs +47 -0
  72. package/src/lib/effects.mjs +97 -0
  73. package/src/lib/eject.mjs +715 -0
  74. package/src/lib/evidence-json.mjs +74 -0
  75. package/src/lib/expect-calls.mjs +53 -0
  76. package/src/lib/finding-memory.mjs +0 -0
  77. package/src/lib/flow-calls.mjs +53 -0
  78. package/src/lib/framework.mjs +98 -0
  79. package/src/lib/host-rules.mjs +55 -0
  80. package/src/lib/host-sources.mjs +121 -0
  81. package/src/lib/island-placement.mjs +64 -0
  82. package/src/lib/island-render.mjs +105 -0
  83. package/src/lib/islands.mjs +112 -0
  84. package/src/lib/lagoon-bundle.mjs +138 -0
  85. package/src/lib/lagoon-declares.mjs +553 -0
  86. package/src/lib/lagoon-materialize.mjs +217 -0
  87. package/src/lib/lagoon-vite.mjs +788 -0
  88. package/src/lib/live-url.mjs +53 -0
  89. package/src/lib/node-aliases.mjs +73 -0
  90. package/src/lib/operations.mjs +145 -0
  91. package/src/lib/provenance-plugin.mjs +74 -0
  92. package/src/lib/region-refs.mjs +40 -0
  93. package/src/lib/remote.mjs +171 -0
  94. package/src/lib/scaffold.mjs +606 -0
  95. package/src/lib/screens.mjs +266 -0
  96. package/src/lib/snapshots.mjs +85 -0
  97. package/src/lib/stubs.mjs +114 -0
  98. package/src/lib/ts-project.mjs +42 -0
  99. package/src/lib/util.mjs +626 -0
  100. package/src/lib/worlds.mjs +236 -0
  101. package/src/lib/write-if-changed.mjs +35 -0
  102. package/src/lib/writes-coverage.mjs +32 -0
  103. package/src/playwright-lagoon.mjs +2305 -0
  104. package/src/run.mjs +385 -0
  105. package/src/runtime-harness.mjs +225 -0
  106. package/README.md +0 -3
@@ -0,0 +1,266 @@
1
+ // `motu check — screens` (EXPERIMENTAL): do the declared screens hold together, and does every
2
+ // navigation a region asks for go somewhere?
3
+ //
4
+ // A screen is arrangement — a route, the app's own layout, a region per slot (`@motujs/react`
5
+ // `screens.ts`). What can be checked without a browser:
6
+ //
7
+ // screens-declared the routes are unique and every slot names a region this project has
8
+ // screen-coverage a region on no screen is reachable only on its own (a warning: legitimate for a
9
+ // region the app embeds elsewhere, worth knowing about everywhere else)
10
+ // nav-targets every `host.navigate(...)` in an archipelago resolves to a declared screen — a
11
+ // button to an address nothing serves fails here, before anything runs.
12
+ // KNOW THE LIMIT: a target that MATCHES the wrong route passes. The demo's
13
+ // "+ New member" once navigated to `/member/edit`, which `/member/:memberId`
14
+ // serves — so the router opened a profile for a member called "edit", and this
15
+ // check would have accepted it too. Matching a route is not meaning the route.
16
+ //
17
+ // READ, NOT EVALUATED: the screens module imports React components and evidence, which plain node
18
+ // cannot load — the same reason the region checks parse evidence. So a target built in a way this
19
+ // does not follow is reported as UNREAD rather than passed: a check that looked at nothing has not
20
+ // passed.
21
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
22
+ import { dirname, resolve } from 'node:path';
23
+ import { SyntaxKind } from 'ts-morph';
24
+ import { paths } from './util.mjs';
25
+ import { sourceFileAt } from './ts-project.mjs';
26
+
27
+ /** Where a project declares its screens: `src/screens/screens.ts`, beside `src/archipelagos/`. */
28
+ export function screensFile() {
29
+ return resolve(dirname(paths.archipelagosDir), 'screens', 'screens.ts');
30
+ }
31
+
32
+ /** `/member/:id` -> a whole-path matcher, as `compileScreenPath` in @motujs/react builds it. */
33
+ export function compilePath(path) {
34
+ const source = path
35
+ .replace(/\/+$/, '')
36
+ .split('/')
37
+ .map((part) => (/^:[A-Za-z_]\w*$/.test(part) ? '[^/]+' : part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
38
+ .join('/');
39
+ return new RegExp(`^${source}/?$`);
40
+ }
41
+
42
+ /** Does any screen serve this target? `prefix` targets end where an id would be appended. */
43
+ export function served(screens, target) {
44
+ const probe = target.kind === 'prefix' ? `${target.value}x-param` : target.value;
45
+ return screens.some((s) => compilePath(s.path).test(probe));
46
+ }
47
+
48
+ const str = (node) =>
49
+ node && (node.getKind() === SyntaxKind.StringLiteral || node.getKind() === SyntaxKind.NoSubstitutionTemplateLiteral)
50
+ ? node.getLiteralText()
51
+ : null;
52
+
53
+ /**
54
+ * Every path a navigate argument can produce, or `{ unread }` when it is built some other way.
55
+ *
56
+ * Followed: a literal; `'/prefix/' + anything` (and a template starting with a literal); either side
57
+ * of `?:` and `??`; `encodeURIComponent`-style calls inside a prefix; and `MAP[key]` where MAP is an
58
+ * object literal const in the same file — every one of its values is a target.
59
+ */
60
+ export function targetsOf(expr, sf) {
61
+ if (!expr) return [{ unread: '(no argument)' }];
62
+ const k = expr.getKind();
63
+ const literal = str(expr);
64
+ if (literal !== null) return [{ kind: 'exact', value: literal }];
65
+ if (k === SyntaxKind.ParenthesizedExpression) return targetsOf(expr.getExpression(), sf);
66
+ if (k === SyntaxKind.ConditionalExpression) return [...targetsOf(expr.getWhenTrue(), sf), ...targetsOf(expr.getWhenFalse(), sf)];
67
+ if (k === SyntaxKind.BinaryExpression) {
68
+ const op = expr.getOperatorToken().getText();
69
+ if (op === '??' || op === '||') return [...targetsOf(expr.getLeft(), sf), ...targetsOf(expr.getRight(), sf)];
70
+ if (op === '+') {
71
+ // The leftmost operand of a `+` chain is the prefix.
72
+ let left = expr;
73
+ while (left.getKind() === SyntaxKind.BinaryExpression && left.getOperatorToken().getText() === '+') left = left.getLeft();
74
+ const prefix = str(left);
75
+ if (prefix !== null) return [{ kind: 'prefix', value: prefix }];
76
+ }
77
+ }
78
+ if (k === SyntaxKind.TemplateExpression) {
79
+ const head = expr.getHead().getLiteralText();
80
+ if (head) return [{ kind: 'prefix', value: head }];
81
+ }
82
+ if (k === SyntaxKind.ElementAccessExpression) {
83
+ const name = expr.getExpression().getText();
84
+ const decl = sf.getVariableDeclaration(name);
85
+ const init = decl?.getInitializer();
86
+ if (init?.getKind() === SyntaxKind.ObjectLiteralExpression) {
87
+ const out = [];
88
+ for (const prop of init.getProperties()) {
89
+ const v = prop.getInitializer?.();
90
+ out.push(...(str(v) !== null ? [{ kind: 'exact', value: str(v) }] : [{ unread: prop.getText() }]));
91
+ }
92
+ return out;
93
+ }
94
+ }
95
+ return [{ unread: expr.getText() }];
96
+ }
97
+
98
+ /** Each declared screen: its id, its path, and the identifiers its slots name. */
99
+ function readScreens(sf) {
100
+ const out = [];
101
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
102
+ if (call.getExpression().getText() !== 'screen') continue;
103
+ const spec = call.getArguments()[0];
104
+ if (spec?.getKind() !== SyntaxKind.ObjectLiteralExpression) continue;
105
+ const id = str(spec.getProperty('id')?.getInitializer?.());
106
+ const path = str(spec.getProperty('path')?.getInitializer?.());
107
+ const slots = spec.getProperty('slots')?.getInitializer?.();
108
+ const regions = [];
109
+ if (slots?.getKind() === SyntaxKind.ObjectLiteralExpression) {
110
+ for (const p of slots.getProperties()) {
111
+ const v = p.getInitializer?.();
112
+ if (!v) continue;
113
+ if (v.getKind() === SyntaxKind.CallExpression && v.getExpression().getText() === 'notARegion') continue;
114
+ regions.push(v.getText());
115
+ }
116
+ }
117
+ if (id && path) out.push({ id, path, regions, line: call.getStartLineNumber() });
118
+ else out.push({ unread: true, line: call.getStartLineNumber(), text: (spec.getProperty('id')?.getText?.() ?? spec.getText()).slice(0, 60) });
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /** Identifier (as a screens file names it) -> region id, from the archipelago files' own exports. */
124
+ function regionIdsByName(screensSf) {
125
+ const byName = new Map();
126
+ const dir = paths.archipelagosDir;
127
+ if (!existsSync(dir)) return byName;
128
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
129
+ if (!entry.isDirectory()) continue;
130
+ const file = resolve(dir, entry.name, `${entry.name}.archipelago.ts`);
131
+ if (!existsSync(file)) continue;
132
+ const text = readFileSync(file, 'utf8');
133
+ const id = /\bid:\s*['"]([^'"]+)['"]/.exec(text)?.[1] ?? entry.name;
134
+ for (const m of text.matchAll(/export\s+(?:const|function)\s+(\w+)/g)) byName.set(m[1], id);
135
+ }
136
+ // One level of local aliasing in the screens file: `const members = membersArchipelago();`.
137
+ for (const decl of screensSf.getVariableDeclarations()) {
138
+ const init = decl.getInitializer();
139
+ if (!init) continue;
140
+ const callee = init.getKind() === SyntaxKind.CallExpression ? init.getExpression().getText() : init.getText();
141
+ if (byName.has(callee)) byName.set(decl.getName(), byName.get(callee));
142
+ }
143
+ return byName;
144
+ }
145
+
146
+ /** Findings for the screens section, or null when the project declares no screens. */
147
+ /**
148
+ * The screens this project declares, as `motu lagoon states` lists them: `{ screens, unread }`, or
149
+ * null when there is no screens module. `unread` counts the `screen(...)` calls whose id or path is
150
+ * not a string literal — said by the caller, never dropped.
151
+ */
152
+ export function declaredScreens() {
153
+ const file = screensFile();
154
+ if (!existsSync(file)) return null;
155
+ const all = readScreens(sourceFileAt(file, { allowJs: true, jsx: 4 }));
156
+ return { screens: all.filter((s) => !s.unread), unread: all.filter((s) => s.unread).length };
157
+ }
158
+
159
+ export function screenFindings() {
160
+ const file = screensFile();
161
+ if (!existsSync(file)) return null;
162
+ const findings = [];
163
+ const add = (level, check, msg) => findings.push({ level, check, msg });
164
+ const sf = sourceFileAt(file, { allowJs: true, jsx: 4 });
165
+ const all = readScreens(sf);
166
+ // A screen() whose id or path is not a plain string literal — a template, a helper's parameter — is
167
+ // a screen this check did not see. Said, never dropped: it once reported "8 screen(s)" over 21, and
168
+ // then called a route served by one of the unseen thirteen unserved.
169
+ const unreadScreens = all.filter((s) => s.unread);
170
+ const screens = all.filter((s) => !s.unread);
171
+ if (unreadScreens.length)
172
+ add(
173
+ 'warn',
174
+ 'screens-declared',
175
+ `${unreadScreens.length} screen(...) call(s) NOT read — write \`id\` and \`path\` as string literals: ` +
176
+ unreadScreens.map((s) => `${paths.rel(file)}:${s.line}`).join(', '),
177
+ );
178
+ if (!screens.length) {
179
+ add('error', 'screens-declared', `${paths.rel(file)} declares no screen(...) — nothing was examined, so nothing is proved`);
180
+ return { findings, screens };
181
+ }
182
+
183
+ // Unique routes: two screens on one path means the second can never open.
184
+ const seen = new Map();
185
+ for (const s of screens) {
186
+ const prior = seen.get(s.path);
187
+ if (prior) add('error', 'screens-declared', `screens "${prior}" and "${s.id}" both declare ${s.path} — the second can never open`);
188
+ seen.set(s.path, s.id);
189
+ }
190
+
191
+ // Every slot names a region this project has.
192
+ const byName = regionIdsByName(sf);
193
+ const regionIds = new Set(
194
+ existsSync(paths.archipelagosDir)
195
+ ? readdirSync(paths.archipelagosDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name)
196
+ : [],
197
+ );
198
+ const onScreen = new Set();
199
+ for (const s of screens) {
200
+ for (const name of s.regions) {
201
+ const id = byName.get(name);
202
+ if (!id) add('error', 'screens-declared', `screen "${s.id}" fills a slot with \`${name}\`, which is no archipelago this project exports`);
203
+ else onScreen.add(id);
204
+ }
205
+ }
206
+ if (!findings.some((f) => f.level === 'error' && f.check === 'screens-declared'))
207
+ add('ok', 'screens-declared', `${screens.length} screen(s), unique routes, every slot a known region · ${onScreen.size} region(s) placed`);
208
+
209
+ // A region REFERENCED by a placed region's root (`slots: { x: { region: 'equipage' } }`) is on the
210
+ // screen too — inside its parent. Followed to a fixed point, so a reference of a reference counts.
211
+ for (let grew = true; grew; ) {
212
+ grew = false;
213
+ for (const id of [...onScreen]) {
214
+ const archFile = paths.archipelagoFile(id);
215
+ if (!existsSync(archFile)) continue;
216
+ for (const m of readFileSync(archFile, 'utf8').matchAll(/\bregion:\s*['"]([\w-]+)['"]/g)) {
217
+ if (regionIds.has(m[1]) && !onScreen.has(m[1])) {
218
+ onScreen.add(m[1]);
219
+ grew = true;
220
+ }
221
+ }
222
+ }
223
+ }
224
+ const loose = [...regionIds].filter((id) => !onScreen.has(id)).sort();
225
+ if (loose.length)
226
+ add('warn', 'screen-coverage', `region(s) on no screen: ${loose.join(', ')} — reachable in the lagoon only on their own`);
227
+
228
+ // Every navigation a region asks for resolves to a screen.
229
+ let examined = 0;
230
+ const unread = [];
231
+ for (const id of regionIds) {
232
+ const archFile = paths.archipelagoFile(id);
233
+ if (!existsSync(archFile)) continue;
234
+ const asf = sourceFileAt(archFile, { allowJs: true, jsx: 4 });
235
+ for (const call of asf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
236
+ if (!/\.navigate$/.test(call.getExpression().getText())) continue;
237
+ for (const t of targetsOf(call.getArguments()[0], asf)) {
238
+ examined++;
239
+ if (t.unread) {
240
+ unread.push(`${id}:${call.getStartLineNumber()} \`${t.unread}\``);
241
+ continue;
242
+ }
243
+ if (!served(screens, t)) {
244
+ add(
245
+ 'error',
246
+ 'nav-targets',
247
+ `${id} navigates to ${t.kind === 'prefix' ? `${t.value}…` : t.value} (${paths.rel(archFile)}:${call.getStartLineNumber()}) — no declared screen serves it, so this control leads nowhere`,
248
+ );
249
+ }
250
+ }
251
+ }
252
+ }
253
+ if (unread.length)
254
+ add('warn', 'nav-targets', `${unread.length} navigation target(s) built in a way this check does not follow, so NOT checked: ${unread.join(', ')}`);
255
+ if (!findings.some((f) => f.check === 'nav-targets' && f.level === 'error'))
256
+ add(
257
+ examined ? 'ok' : 'warn',
258
+ 'nav-targets',
259
+ !examined
260
+ ? 'no region navigates anywhere — nothing was examined'
261
+ : unread.length
262
+ ? `the ${examined - unread.length} target(s) it could read resolve to a screen · ${unread.length} not checked (above)`
263
+ : `every navigation target resolves to a screen · ${examined} target(s) checked`,
264
+ );
265
+ return { findings, screens };
266
+ }
@@ -0,0 +1,85 @@
1
+ // Visual baselines — the gate for the one thing nothing else in motu looks at: what the island LOOKS
2
+ // like.
3
+ //
4
+ // Everything else here proves structure: props match the component, keys have one owner, the region
5
+ // mounts, the wiring resolves. None of it notices that a card lost its padding, that a heading now
6
+ // wraps on a phone, or that editing the header moved the panel underneath it. An agent cannot see, and
7
+ // a human reviewing a diff of TSX cannot either — so the harness has to.
8
+ //
9
+ // Baselines live beside the island's evidence, one per scenario × viewport, and are committed. A change
10
+ // to them is a change to the UI, and it shows up in review as an image, which is exactly where a
11
+ // reviewer can judge it.
12
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
13
+ import { basename, resolve } from 'node:path';
14
+
15
+ /** Where an island's baselines live: `<islands>/<kebab>.snapshots/`. */
16
+ export function snapshotDir(islandsDir, kebab) {
17
+ return resolve(islandsDir, `${kebab}.snapshots`);
18
+ }
19
+
20
+ /** `<scenario>@<viewport>.png`, with anything filesystem-hostile flattened out of the scenario name. */
21
+ export function snapshotName(scenario, viewport) {
22
+ const slug = String(scenario)
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9]+/g, '-')
25
+ .replace(/^-|-$/g, '');
26
+ return `${slug || 'default'}@${viewport}.png`;
27
+ }
28
+
29
+ /**
30
+ * Compare a fresh capture against its baseline.
31
+ *
32
+ * Returns `{ status, diffPixels, ratio }` where status is 'new' (no baseline yet), 'match',
33
+ * 'changed', or 'resized'. A size change is reported separately because pixel comparison is
34
+ * meaningless across dimensions, and because it is nearly always a layout change worth looking at.
35
+ */
36
+ export async function compareSnapshot(baselinePath, actual) {
37
+ if (!existsSync(baselinePath)) return { status: 'new', diffPixels: 0, ratio: 0 };
38
+ return compareBuffers(readFileSync(baselinePath), actual);
39
+ }
40
+
41
+ /**
42
+ * The same comparison against BYTES rather than a path — what `--remote` needs, where the accepted
43
+ * baseline arrives over http and never touches the repository.
44
+ */
45
+ export async function compareBuffers(baselineBytes, actual) {
46
+ const { PNG } = await import('pngjs');
47
+ const { default: pixelmatch } = await import('pixelmatch');
48
+ const before = PNG.sync.read(baselineBytes);
49
+ const after = PNG.sync.read(actual);
50
+ if (before.width !== after.width || before.height !== after.height) {
51
+ return { status: 'resized', diffPixels: 0, ratio: 1, from: [before.width, before.height], to: [after.width, after.height] };
52
+ }
53
+ const diff = new PNG({ width: before.width, height: before.height });
54
+ // A small threshold absorbs antialiasing differences without hiding a real change: anything a human
55
+ // would notice moves far more than a handful of pixels.
56
+ const diffPixels = pixelmatch(before.data, after.data, diff.data, before.width, before.height, { threshold: 0.12 });
57
+ const ratio = diffPixels / (before.width * before.height);
58
+ return { status: ratio > 0.001 ? 'changed' : 'match', diffPixels, ratio, diff: PNG.sync.write(diff) };
59
+ }
60
+
61
+ /** Write a baseline (and drop any stale `.actual`/`.diff` beside it). */
62
+ export function writeBaseline(dir, file, png) {
63
+ mkdirSync(dir, { recursive: true });
64
+ writeFileSync(resolve(dir, file), png);
65
+ for (const suffix of ['.actual.png', '.diff.png']) {
66
+ const stale = resolve(dir, file.replace(/\.png$/, suffix));
67
+ if (existsSync(stale)) rmSync(stale);
68
+ }
69
+ }
70
+
71
+ /** Write what was actually rendered + the diff, so a failure is inspectable rather than described. */
72
+ export function writeFailureArtifacts(dir, file, actual, diff) {
73
+ mkdirSync(dir, { recursive: true });
74
+ writeFileSync(resolve(dir, file.replace(/\.png$/, '.actual.png')), actual);
75
+ if (diff) writeFileSync(resolve(dir, file.replace(/\.png$/, '.diff.png')), diff);
76
+ }
77
+
78
+ /** Baselines with no scenario behind them any more — evidence was renamed or deleted. */
79
+ export function orphanBaselines(dir, expected) {
80
+ if (!existsSync(dir)) return [];
81
+ const keep = new Set(expected);
82
+ return readdirSync(dir)
83
+ .filter((f) => f.endsWith('.png') && !f.endsWith('.actual.png') && !f.endsWith('.diff.png'))
84
+ .filter((f) => !keep.has(basename(f)));
85
+ }
@@ -0,0 +1,114 @@
1
+ // The seam where the lagoon's isolation can become a lie.
2
+ //
3
+ // A host stub stands in for a real module so an island can render with no backend. Nothing checked
4
+ // that it still stands in for all of it: in acme, two exports went missing from stubs and only
5
+ // surfaced because the lagoon BUILD happened to import them — a bundler error, by luck. An export
6
+ // nothing imports at build time would have gone unnoticed, and the lagoon would have been quietly
7
+ // green about a module it no longer mirrors.
8
+ //
9
+ // What is checked here is what the ISLANDS actually reach for: every named import of an aliased
10
+ // specifier, found by walking the import graph from each island's component. Demanding that a stub
11
+ // mirror a module's whole surface would be noise — the real missions service exports twenty things and
12
+ // the islands use four.
13
+ import { existsSync, readFileSync, statSync } from 'node:fs';
14
+ import { dirname, resolve } from 'node:path';
15
+ import { paths, resolveModuleSpecifier } from './util.mjs';
16
+ import { listIslands, elementExportName } from './islands.mjs';
17
+
18
+ /** The lagoon's alias table: app specifier -> stub file (absolute). */
19
+ export function lagoonAliases() {
20
+ const file = resolve(paths.lagoonDir, 'lagoon.config.json');
21
+ if (!existsSync(file)) return [];
22
+ const alias = JSON.parse(readFileSync(file, 'utf8')).alias ?? {};
23
+ return Object.entries(alias).map(([specifier, target]) => ({
24
+ specifier,
25
+ stub: resolve(paths.lagoonDir, target),
26
+ }));
27
+ }
28
+
29
+ /** Named exports a module declares, by source text — enough for a name-level comparison. */
30
+ function exportedNames(file) {
31
+ if (!existsSync(file)) return null;
32
+ const code = readFileSync(file, 'utf8')
33
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
34
+ .replace(/\/\/[^\n]*/g, ' ');
35
+ const names = new Set();
36
+ for (const m of code.matchAll(/export\s+(?:async\s+)?(?:function|const|let|var|class)\s+([A-Za-z_$][\w$]*)/g)) {
37
+ names.add(m[1]);
38
+ }
39
+ // `export { a, b as c }` — the exported NAME is what an importer writes, so take the alias when there
40
+ // is one.
41
+ for (const block of code.matchAll(/export\s*\{([^}]*)\}/g)) {
42
+ for (const entry of block[1].split(',')) {
43
+ const [, exported] = entry.trim().match(/(?:(\w+)\s+as\s+)?(\w+)/) ?? [];
44
+ if (exported) names.add(exported);
45
+ }
46
+ }
47
+ return names;
48
+ }
49
+
50
+ /** Type-only imports never reach the runtime, so a stub owes them nothing. */
51
+ const TYPE_IMPORT = /^\s*import\s+type\b/;
52
+
53
+ /**
54
+ * Every named import of `specifier` made by code an island can reach.
55
+ *
56
+ * Walks from each island's component through relative and aliased imports, staying inside the host's
57
+ * own sources. That set is what the lagoon bundles, which is the only set a stub has to satisfy.
58
+ */
59
+ export function islandReachableImports(specifiers) {
60
+ const wanted = new Set(specifiers);
61
+ const found = new Map(specifiers.map((s) => [s, new Set()]));
62
+ const seen = new Set();
63
+ const queue = [];
64
+
65
+ for (const island of listIslands(paths.islandsDir)) {
66
+ const elementFile = island.element;
67
+ if (existsSync(elementFile)) queue.push(elementFile);
68
+ void elementExportName;
69
+ }
70
+
71
+ while (queue.length) {
72
+ const file = queue.shift();
73
+ // A specifier can resolve to a directory (an index import); there is nothing to read there.
74
+ if (seen.has(file) || !existsSync(file) || statSync(file).isDirectory()) continue;
75
+ seen.add(file);
76
+ const text = readFileSync(file, 'utf8');
77
+ for (const m of text.matchAll(/import\s+([^'"]*?)from\s*['"]([^'"]+)['"]/g)) {
78
+ const clause = m[1];
79
+ const spec = m[2];
80
+ if (wanted.has(spec) && !TYPE_IMPORT.test(m[0])) {
81
+ for (const named of clause.matchAll(/\{([^}]*)\}/g)) {
82
+ for (const entry of named[1].split(',')) {
83
+ const name = entry.trim().split(/\s+as\s+/)[0].trim().replace(/^type\s+/, '');
84
+ if (name && !entry.trim().startsWith('type ')) found.get(spec).add(name);
85
+ }
86
+ }
87
+ }
88
+ // Keep walking the app's own code; node_modules and motu's packages are not the host's to stub.
89
+ const next = resolveModuleSpecifier(spec, dirname(file));
90
+ if (next && !next.includes('node_modules')) queue.push(next);
91
+ }
92
+ }
93
+ return found;
94
+ }
95
+
96
+ /**
97
+ * Compare each stub against the real module, over what the islands import.
98
+ *
99
+ * Returns [{ specifier, stub, missing, unresolved }] — `missing` is the finding that matters: an
100
+ * island reaches for it, the stub does not have it.
101
+ */
102
+ export function stubParity() {
103
+ const aliases = lagoonAliases();
104
+ if (!aliases.length) return [];
105
+ const used = islandReachableImports(aliases.map((a) => a.specifier));
106
+ return aliases.map(({ specifier, stub }) => {
107
+ const stubExports = exportedNames(stub);
108
+ const real = resolveModuleSpecifier(specifier, paths.lagoonDir);
109
+ const needed = [...(used.get(specifier) ?? [])];
110
+ if (!stubExports) return { specifier, stub, unresolved: 'stub file not found', missing: [], needed };
111
+ const missing = needed.filter((name) => !stubExports.has(name));
112
+ return { specifier, stub, real, missing, needed, unresolved: null };
113
+ });
114
+ }
@@ -0,0 +1,42 @@
1
+ // One ts-morph program per process, instead of one per question.
2
+ //
3
+ // Every static check used to build its own Project and re-parse the file it was about to read: the
4
+ // component for the props check, the same component for the contract, the island file for coupling,
5
+ // the island file again for the mount-point rules. Parsing is the expensive part of a static verify —
6
+ // a sweep of fifteen islands spent most of its wall clock re-reading files it had already read.
7
+ //
8
+ // The projects here are keyed by compiler options and cached for the life of the command, so a file is
9
+ // parsed once no matter how many checks ask about it. They are READ-ONLY: anything that rewrites
10
+ // source (create, eject, integrate) keeps its own Project, because a shared one would carry another
11
+ // command's edits.
12
+ import { Project } from 'ts-morph';
13
+
14
+ const projects = new Map();
15
+
16
+ /** A shared, read-only Project for these compiler options. */
17
+ export function sharedProject(compilerOptions = { allowJs: true }) {
18
+ const key = JSON.stringify(compilerOptions);
19
+ let project = projects.get(key);
20
+ if (!project) {
21
+ project = new Project({
22
+ skipAddingFilesFromTsConfig: true,
23
+ skipFileDependencyResolution: true,
24
+ compilerOptions,
25
+ });
26
+ projects.set(key, project);
27
+ }
28
+ return project;
29
+ }
30
+
31
+ /**
32
+ * The source file at `path`, parsed at most once.
33
+ *
34
+ * `refreshFromFileSystemSync` is deliberate rather than paranoid: a long-running command (the lagoon's
35
+ * watch mode) can ask about a file that changed since it was first read.
36
+ */
37
+ export function sourceFileAt(path, compilerOptions) {
38
+ const project = sharedProject(compilerOptions);
39
+ const existing = project.getSourceFile(path);
40
+ if (existing) return existing;
41
+ return project.addSourceFileAtPath(path);
42
+ }