@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,626 @@
1
+ // Shared helpers: project layout + island name derivation. The layout (WHERE each piece lands) is
2
+ // declared in motu.config.json and resolved by loadMotuConfig(); the CLI holds no hardcoded app
3
+ // paths. WHAT lives inside each root stays motu's convention (islands/<kebab>/…, ui/<kebab>/…).
4
+ import { existsSync, readFileSync, mkdirSync, symlinkSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { dirname, resolve, relative } from 'node:path';
7
+ import { loadMotuConfig } from './config.mjs';
8
+ import { INSTALLED, frameworkFile } from './framework.mjs';
9
+
10
+ const here = dirname(fileURLToPath(import.meta.url));
11
+ const cfg = loadMotuConfig();
12
+
13
+ /** The motu project root (the directory that owns motu.config.json). */
14
+ export const REPO_ROOT = cfg.root;
15
+ /** Where the framework checkout lives — derived from the running CLI, or `$MOTU_ROOT`. */
16
+ export const MOTU_CHECKOUT = cfg.motuRoot;
17
+ /** The HOST application's root — where its instruction files and tsconfig live. */
18
+ export const HOST_ROOT = cfg.hostRoot;
19
+ /** The frontend app root (root/<app>) — everything below is motu-conventional. */
20
+ export const APP_ROOT = cfg.appRoot;
21
+ /** The npm package name whose barrel exports ELEMENT_REGISTRY (for the runtime harness). */
22
+ export const APP_PACKAGE = cfg.appPackage;
23
+ /** The stack the islands embed into ('angularjs' | 'next' | 'none') — selects host-specific gates. */
24
+ export const HOST = cfg.host;
25
+ /** Whether the `legacy` fit gate applies (only hosts that actually have a legacy skin). */
26
+ export const LEGACY_FIT = cfg.legacyFit;
27
+ /** Prefix of every custom element tag (`x-` by default) — `names(k).tag` is this plus the kebab. */
28
+ export const TAG_PREFIX = cfg.tagPrefix;
29
+
30
+ const ISLANDS_DIR = cfg.islandsDir;
31
+ const ARCHIPELAGOS_DIR = cfg.archipelagosDir;
32
+ // UI components live OUTSIDE islands so mount points can't import each other — islands are pure mount
33
+ // points (element.ts + fixtures + index); the reusable component(s) live in ui/<kebab>/.
34
+ const UI_DIR = cfg.uiDir;
35
+
36
+ export const paths = {
37
+ islandsDir: ISLANDS_DIR,
38
+ /**
39
+ * Shadow or light, from motu.config.json.
40
+ *
41
+ * `loadMotuConfig()` has always resolved it and this object never carried it, so every command that
42
+ * reads config through `paths` saw `undefined` — which is how the generated registry took a silent
43
+ * `'shadow'` default on a project that declares `light`. Same shape as the `hostSources` whitelist
44
+ * bug: the value existed, one layer below where it was asked for.
45
+ */
46
+ isolation: cfg.isolation,
47
+ /**
48
+ * Coverage, from motu.config.json. On `paths` for the same reason `isolation` is: the registry
49
+ * generator needs it, and a value that is whitelisted in the config loader but never surfaced here
50
+ * is whitelisted into a dead end — which is precisely how `hostSources` shipped doing nothing.
51
+ */
52
+ coverage: cfg.coverage,
53
+ /**
54
+ * How hard `root` is pushed: 'encouraged' (default) or 'required'. Surfaced HERE and not only in
55
+ * the config loader for the reason the two comments above already record — a key whitelisted one
56
+ * layer down and never read up here is whitelisted into a dead end.
57
+ */
58
+ regionRoot: cfg.regionRoot,
59
+ // Resolved here, because a value whitelisted in the config loader but never surfaced through
60
+ // `paths` is whitelisted into a dead end — see the four keys above that shipped as no-ops.
61
+ operations: cfg.operations ? resolve(cfg.root, cfg.operations) : undefined,
62
+ /** Who this project is on a lagoon host, when the git repository is the wrong answer. */
63
+ publishAs: cfg.publishAs,
64
+ /**
65
+ * The npm package name whose barrel exports ELEMENT_REGISTRY.
66
+ *
67
+ * The fourth value to be resolved by `loadMotuConfig()` and never surfaced here, after `isolation`,
68
+ * `coverage` and `regionRoot` — and it failed the same silent way they did. `archipelago init` writes a
69
+ * composition root that imports the project's islands, read it as `paths.appPackage ?? 'motu-islands'`,
70
+ * got `undefined`, and generated an import of a package that does not exist for every project whose
71
+ * appPackage is not literally `motu-islands`. The fallback is what hid it.
72
+ */
73
+ appPackage: cfg.appPackage,
74
+ /** The ui/ root itself (uiDir(kebab) is one island's folder inside it). */
75
+ uiRoot: UI_DIR,
76
+ islandDir: (kebab) => resolve(ISLANDS_DIR, kebab),
77
+ islandsRegistry: resolve(ISLANDS_DIR, 'registry.ts'),
78
+ archipelagosDir: ARCHIPELAGOS_DIR,
79
+ archipelagosRegistry: resolve(ARCHIPELAGOS_DIR, 'registry.ts'),
80
+ archipelagoFile: (id) => resolve(ARCHIPELAGOS_DIR, id, `${id}.archipelago.ts`),
81
+ /** A region's declared flows — beside its config, never inside it (evidence stays out of the bundle). */
82
+ archipelagoEvidence: (id) => resolve(ARCHIPELAGOS_DIR, id, `${id}.evidence.ts`),
83
+ barrel: cfg.barrel,
84
+ contract: resolve(cfg.contractSrcDir, 'index.ts'),
85
+ contractSrcDir: cfg.contractSrcDir,
86
+ // Framework-internal path, never the app's. Checkout-only: @motujs/codegen is not published, so
87
+ // installed this names a file that does not exist and `motu codegen` says so (commands/codegen.mjs).
88
+ codegenCli: frameworkFile('packages/codegen/src/cli.mjs'),
89
+ defaultManifest: cfg.manifest,
90
+ lagoonDir: cfg.lagoonDir,
91
+ bridgeDir: cfg.bridgeDir,
92
+ // The component lives in ui/<kebab>/ (a mount point owns no component of its own).
93
+ uiDir: (kebab) => resolve(UI_DIR, kebab),
94
+ componentFile: (kebab, pascal) => resolve(UI_DIR, kebab, `${pascal}.tsx`),
95
+ /**
96
+ * The file declaring an island's ElementSpec, in either supported layout — flat
97
+ * `<kebab>.island.ts(x)` (preferred) or the original `<kebab>/element.ts` (the reference ocean).
98
+ * Falls back to the folder form so a not-yet-created island still reports a sensible path.
99
+ */
100
+ elementFile: (kebab) => {
101
+ for (const p of [
102
+ resolve(ISLANDS_DIR, `${kebab}.island.ts`),
103
+ resolve(ISLANDS_DIR, `${kebab}.island.tsx`),
104
+ resolve(ISLANDS_DIR, kebab, 'element.ts'),
105
+ ]) {
106
+ if (existsSync(p)) return p;
107
+ }
108
+ return resolve(ISLANDS_DIR, kebab, 'element.ts');
109
+ },
110
+ islandIndexFile: (kebab) => resolve(ISLANDS_DIR, kebab, 'index.ts'),
111
+ /** An island's lagoon evidence: `<kebab>.evidence.ts` (flat) or `<kebab>/fixtures.mock.ts` (folder). */
112
+ fixturesFile: (kebab) => {
113
+ const flat = resolve(ISLANDS_DIR, `${kebab}.evidence.ts`);
114
+ if (existsSync(flat)) return flat;
115
+ return resolve(ISLANDS_DIR, kebab, 'fixtures.mock.ts');
116
+ },
117
+ /** The one shared island stylesheet (linted at region scope until islands own their own CSS). */
118
+ sharedStyles: resolve(cfg.sharedDir, 'styles.css'),
119
+ /** Project-relative display path for messages — derived from config, never hardcoded. */
120
+ rel: (abs) => relative(cfg.root, abs) || '.',
121
+ };
122
+
123
+ /**
124
+ * Strip comments and trailing commas from JSONC (tsconfig/jsconfig), string-aware.
125
+ *
126
+ * Regex stripping is wrong here in a way that looks fine until it silently destroys the file: a
127
+ * tsconfig's `paths` is full of `"@/*": ["./*"]`, and the `/*` inside those string literals opens a
128
+ * block comment that the regex then closes at the next comment terminator far below, deleting
129
+ * everything between. So scan characters, and only treat comment openers outside strings as comments.
130
+ */
131
+ function stripJsonc(text) {
132
+ let out = '';
133
+ let inString = false;
134
+ for (let i = 0; i < text.length; i++) {
135
+ const c = text[i];
136
+ if (inString) {
137
+ out += c;
138
+ if (c === '\\') {
139
+ out += text[++i] ?? '';
140
+ } else if (c === '"') {
141
+ inString = false;
142
+ }
143
+ continue;
144
+ }
145
+ if (c === '"') {
146
+ inString = true;
147
+ out += c;
148
+ } else if (c === '/' && text[i + 1] === '/') {
149
+ while (i < text.length && text[i] !== '\n') i++;
150
+ out += '\n';
151
+ } else if (c === '/' && text[i + 1] === '*') {
152
+ i += 2;
153
+ while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++;
154
+ i++;
155
+ } else {
156
+ out += c;
157
+ }
158
+ }
159
+ // Trailing commas are legal in JSONC, not in JSON.
160
+ return out.replace(/,(\s*[}\]])/g, '$1');
161
+ }
162
+
163
+ /**
164
+ * Path aliases declared by the HOST application's tsconfig (e.g. Next's `@/*` -> `./*`).
165
+ *
166
+ * An island that wraps a component the app already owns imports it the way the app does. Resolving
167
+ * that import means speaking the host's alias language, so read it from the host's own tsconfig
168
+ * rather than assuming a convention.
169
+ */
170
+ function hostTsconfigAliases() {
171
+ const out = [];
172
+ const read = (file) => JSON.parse(stripJsonc(readFileSync(file, 'utf8')));
173
+ const collect = (file, json) => {
174
+ const paths = json?.compilerOptions?.paths ?? {};
175
+ // `paths` without `baseUrl` is relative to the config file that declares it — which is the
176
+ // host root for tsconfig.json, and also for a referenced tsconfig.app.json beside it.
177
+ const base = resolve(dirname(file), json?.compilerOptions?.baseUrl ?? '.');
178
+ for (const [pattern, targets] of Object.entries(paths)) {
179
+ if (!Array.isArray(targets) || !targets.length) continue;
180
+ out.push({ prefix: pattern.replace(/\*$/, ''), target: resolve(base, targets[0].replace(/\*$/, '')) });
181
+ }
182
+ };
183
+ for (const name of ['tsconfig.json', 'jsconfig.json']) {
184
+ const file = resolve(cfg.hostRoot, name);
185
+ if (!existsSync(file)) continue;
186
+ try {
187
+ const json = read(file);
188
+ collect(file, json);
189
+ // A SOLUTION-STYLE ROOT declares no paths of its own. `npm create vite` writes exactly that —
190
+ // `tsconfig.json` is `{ files: [], references: [app, node] }` and the app's `@/*` lives in
191
+ // `tsconfig.app.json` — so a fresh Vite app's `--from @/components/x` was rejected as "does not
192
+ // resolve", with the alias sitting one file over. Only when the root has none, so a project
193
+ // whose root does declare paths keeps exactly the answer it had.
194
+ if (!out.length) {
195
+ for (const ref of json?.references ?? []) {
196
+ let target = resolve(dirname(file), ref?.path ?? '');
197
+ if (existsSync(target) && !target.endsWith('.json')) target = resolve(target, 'tsconfig.json');
198
+ if (!existsSync(target)) continue;
199
+ try {
200
+ collect(target, read(target));
201
+ } catch {
202
+ /* an unreadable reference contributes nothing; the others still count */
203
+ }
204
+ }
205
+ }
206
+ } catch {
207
+ /* unreadable host tsconfig — aliases just stay unresolved */
208
+ }
209
+ break;
210
+ }
211
+ return out;
212
+ }
213
+
214
+ const HOST_ALIASES = hostTsconfigAliases();
215
+
216
+ /**
217
+ * The host tsconfig's path aliases, as Vite `{ find, replacement }` entries.
218
+ *
219
+ * motu validates `--from @/components/x` against the tsconfig's `paths`, and the LAGOON resolves it
220
+ * with Vite's aliases — two different resolvers, so a specifier can pass `island create` and then fail
221
+ * the build. Measured twice: a bare `mastodon/…` import that `tsc` and the app's dev server both
222
+ * resolved while the lagoon 500'd, and an `@/…` island whose creation succeeded and whose bundle could
223
+ * not find it. Feeding the tsconfig's own mappings to Vite makes one answer serve both.
224
+ */
225
+ export function hostAliasEntries() {
226
+ return (
227
+ HOST_ALIASES
228
+ // PREFIX MAPPINGS ONLY (`@/*`), never exact module names.
229
+ //
230
+ // tsconfig `paths` is TYPE resolution; a Vite alias is RUNTIME resolution, and they are not the
231
+ // same mechanism. A Next project mapping `"react": ["./node_modules/@types/react/index.d.ts"]`
232
+ // — a common and correct thing to do — had that handed to Vite by this function, so `react`
233
+ // resolved to a declaration file and `react/jsx-runtime` to `…/index.d.ts/jsx-runtime`. It also
234
+ // aliased every `@motujs/*` onto a single source file. The dev server crashed on a real project
235
+ // with an error naming esbuild and nothing else.
236
+ //
237
+ // Only the wildcard forms say WHERE SOURCE LIVES, which is what the lagoon needed. An exact name
238
+ // says what a package IS, which this must not touch. The trailing `/` is the discriminator:
239
+ // `hostTsconfigAliases` strips the `*`, so `@/*` becomes `@/` while a bare `react` stays `react`.
240
+ .filter((a) => a.prefix?.endsWith('/') && a.target)
241
+ // And never a declaration file, whatever its shape — nothing runtime resolves to one.
242
+ .filter((a) => !/\.d\.[cm]?ts$/.test(a.target))
243
+ .map((a) => ({ find: a.prefix, replacement: a.target + '/' }))
244
+ );
245
+ }
246
+
247
+ /**
248
+ * A file, written as the specifier the HOST would use for it — `@/pages/x` — or null.
249
+ *
250
+ * The inverse of the resolver below, and it has to read the alias's real TARGET rather than assume
251
+ * `@/*` means "from the host root". Next's own template maps `"@/*": ["./*"]`, so assuming that
252
+ * worked everywhere until it met a Vite app mapping `"@/*": ["./src/*"]`: `archipelago init` then
253
+ * generated `@/src/pages/api-keys-region`, a doubled `src/` that resolves to nothing, and the adopter
254
+ * hand-fixed the import motu had just written for them.
255
+ *
256
+ * Longest prefix wins, so a project with both `@/*` and `@/components/*` gets the specific one.
257
+ */
258
+ export function hostAliasSpecifier(targetFile) {
259
+ const stripped = targetFile.replace(/\.(tsx?|jsx?)$/, '');
260
+ const candidates = HOST_ALIASES.filter((a) => a.prefix && !relative(a.target, stripped).startsWith('..')).sort(
261
+ (a, b) => b.target.length - a.target.length,
262
+ );
263
+ const alias = candidates[0];
264
+ if (!alias) return null;
265
+ const rest = relative(alias.target, stripped).split('\\').join('/');
266
+ return rest ? `${alias.prefix}${rest}` : alias.prefix.replace(/\/$/, '');
267
+ }
268
+
269
+ const TS_EXTENSIONS = ['', '.tsx', '.ts', '/index.tsx', '/index.ts'];
270
+
271
+ /** Resolve a module specifier found in an island's element.ts to a file on disk (null if it can't). */
272
+ export function resolveModuleSpecifier(spec, fromDir) {
273
+ let base = null;
274
+ if (spec.startsWith('.')) base = resolve(fromDir, spec);
275
+ else {
276
+ const alias = HOST_ALIASES.find((a) => a.prefix && spec.startsWith(a.prefix));
277
+ if (alias) base = resolve(alias.target, spec.slice(alias.prefix.length));
278
+ }
279
+ if (!base) return null;
280
+ // TS source is imported with a '.js' specifier under NodeNext; try the source extensions too.
281
+ const candidates = base.endsWith('.js') ? [base.slice(0, -3), base] : [base];
282
+ for (const c of candidates) {
283
+ for (const ext of TS_EXTENSIONS) {
284
+ const p = c + ext;
285
+ if (existsSync(p) && !p.endsWith('/')) return p;
286
+ }
287
+ }
288
+ return null;
289
+ }
290
+
291
+ /**
292
+ * Where an island's component actually lives.
293
+ *
294
+ * The reference layout puts it at ui/<kebab>/<Pascal>.tsx, because extracting from an AngularJS ocean
295
+ * means WRITING a React component that did not exist. A React host is the other case entirely: the
296
+ * component already exists and the island is only a mount point over it, so copying it into ui/ would
297
+ * fork the app's own component and let the two drift. So: follow element.ts's import of the component
298
+ * it mounts, and fall back to the ui/ convention when there is nothing to follow.
299
+ */
300
+ /** The identifier an island file mounts, in either declaration form. */
301
+ export function islandComponentIdentifier(text) {
302
+ // Code only: comments and string CONTENTS are blanked first (quotes kept, so the short form's tag
303
+ // still parses). Matching the raw text read a comment saying "the app's OWN component: the admin
304
+ // dialog…" as `component: the`, and every check that follows the identifier to its import then
305
+ // reported the wrapped component missing.
306
+ const code = blankComments(text).replace(/(['"`])(?:\\.|(?!\1)[^\\\n])*\1/g, (m, q) => q + ' '.repeat(m.length - 2) + q);
307
+ return (
308
+ code.match(/\bcomponent\s*:\s*([A-Za-z_$][\w$]*)/)?.[1] ??
309
+ code.match(/\bisland\(\s*(['"`])[^'"`]*\1\s*,\s*([A-Za-z_$][\w$]*)/)?.[2] ??
310
+ null
311
+ );
312
+ }
313
+
314
+ export function islandComponentPath(kebab, pascal) {
315
+ // Whichever layout the island uses. A relative import in the declaration resolves against the
316
+ // FILE's own directory — the islands dir for a flat `<kebab>.island.ts`, the island's folder for
317
+ // the original `<kebab>/element.ts` — so the base has to come from the resolved path, not a guess.
318
+ const elementPath = paths.elementFile(kebab);
319
+ if (existsSync(elementPath)) {
320
+ const text = readFileSync(elementPath, 'utf8');
321
+ // Either declaration form: the explicit `component:` property, or `island('x-tag', Component)`
322
+ // — the short form, where everything derivable comes from the generated contracts file.
323
+ const componentName = islandComponentIdentifier(text);
324
+ if (componentName) {
325
+ for (const m of text.matchAll(/import\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g)) {
326
+ const named = m[1].split(',').map((n) => n.trim().split(/\s+as\s+/).pop().trim());
327
+ if (!named.includes(componentName)) continue;
328
+ const resolved = resolveModuleSpecifier(m[2], dirname(elementPath));
329
+ if (resolved) return resolved;
330
+ }
331
+ }
332
+ }
333
+ return resolve(UI_DIR, kebab, `${pascal}.tsx`);
334
+ }
335
+
336
+ /**
337
+ * The viewports a project checks its UI at, from `lagoon.config.json`.
338
+ *
339
+ * Motu had no notion of one: `fit` is footprint and skin, never width, so "does this work on a phone"
340
+ * was left to whoever remembered to drag a browser window. Declared once, they drive the lagoon's own
341
+ * width switcher and the responsive check — the same list, so what you look at is what CI measures.
342
+ */
343
+ export function lagoonViewports() {
344
+ const file = resolve(cfg.lagoonDir, 'lagoon.config.json');
345
+ const declared = existsSync(file) ? (JSON.parse(readFileSync(file, 'utf8')).viewports ?? null) : null;
346
+ const map = declared ?? { mobile: 390, desktop: 1280 };
347
+ return Object.entries(map).map(([name, width]) => ({ name, width: Number(width) }));
348
+ }
349
+
350
+ /**
351
+ * How hard the accessibility check bites, from `lagoon.config.json`.
352
+ *
353
+ * Warnings by default, deliberately. A check that turns an existing codebase red on the day it ships
354
+ * gets switched off, not acted on — and the first run here found violations owned by a UI library, not
355
+ * by the app. Same adoption shape as key ownership: report, let a project get clean, then promote.
356
+ *
357
+ * "a11y": { "fail": "critical" | "serious" | "never", "ignore": ["aria-allowed-attr"] }
358
+ */
359
+ export function lagoonA11y() {
360
+ const file = resolve(cfg.lagoonDir, 'lagoon.config.json');
361
+ const declared = existsSync(file) ? (JSON.parse(readFileSync(file, 'utf8')).a11y ?? {}) : {};
362
+ return { fail: declared.fail ?? 'never', ignore: new Set(declared.ignore ?? []) };
363
+ }
364
+
365
+ /**
366
+ * The modules this project stands down in the lagoon — the ONE definition of a MODULE effect.
367
+ *
368
+ * There were two, and they agreed only by luck. The generator derived the list from hardcoded path
369
+ * patterns (`@/lib/services/…`, `@/hooks/…`); the `effects` check derives it from these alias keys.
370
+ * Both answered the same while every matching module happened to be aliased. Move a project's mock
371
+ * boundary — stub the Supabase client at the wire instead of each service module — and they split:
372
+ * the generator keeps emitting a service that no longer needs standing down, and the check calls that
373
+ * emission stale. Neither is broken on its own terms, and `motu sync` cannot reconcile them.
374
+ *
375
+ * The alias map wins because it is what a module effect MEANS: not "a module matching a path convention" but
376
+ * "a capability the host must provide, which the lagoon has to stand in for". Returns null when there
377
+ * is no lagoon config, so a project without one keeps the pattern-only behaviour.
378
+ */
379
+ export function lagoonAliases() {
380
+ const file = resolve(cfg.lagoonDir, 'lagoon.config.json');
381
+ if (!existsSync(file)) return null;
382
+ try {
383
+ return Object.keys(JSON.parse(readFileSync(file, 'utf8')).alias ?? {});
384
+ } catch {
385
+ return null;
386
+ }
387
+ }
388
+
389
+ /**
390
+ * The name of the component an island mounts, as EXPORTED by the component's own file.
391
+ *
392
+ * Not the name derived from the island's own kebab: on a modern host an island wraps a component the
393
+ * app already ships, whose name is the app's business (`WeekActionsView`, `NetworkStatsBanner`). Every
394
+ * check that reads the component — default props, the `${Name}Props` interface — has to look for what
395
+ * is actually there, or it reports "couldn't find" for a component sitting right in front of it.
396
+ */
397
+ export function islandComponentExport(kebab, fallback) {
398
+ const elementPath = paths.elementFile(kebab);
399
+ if (!existsSync(elementPath)) return fallback;
400
+ const text = readFileSync(elementPath, 'utf8');
401
+ const local = islandComponentIdentifier(text);
402
+ if (!local) return fallback;
403
+ for (const m of text.matchAll(/import\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"][^'"]+['"]/g)) {
404
+ for (const entry of m[1].split(',')) {
405
+ const [exported, alias] = entry.split(/\s+as\s+/).map((n) => n.trim());
406
+ if ((alias ?? exported) === local) return exported;
407
+ }
408
+ }
409
+ return local;
410
+ }
411
+
412
+ /**
413
+ * Locate the vite binary that serves/builds the lagoon.
414
+ *
415
+ * A single `REPO_ROOT/node_modules/vite` join only works when the project root is also the install
416
+ * root. It is not, in the case motu has to support: a motu project living in a SUBFOLDER of an
417
+ * existing app, where node_modules sits at the app root above it (or is hoisted further up still).
418
+ * Walk up from the lagoon itself — the app that actually needs vite — then fall back to the motu
419
+ * checkout's own install, so a project with no vite of its own can still close the loop.
420
+ */
421
+ function findVite() {
422
+ const candidates = [];
423
+ for (let dir = cfg.lagoonDir; ; dir = dirname(dir)) {
424
+ candidates.push(resolve(dir, 'node_modules/vite/bin/vite.js'));
425
+ if (dirname(dir) === dir) break;
426
+ }
427
+ candidates.push(resolve(here, '../../node_modules/vite/bin/vite.js'));
428
+ return candidates.find((p) => existsSync(p)) ?? candidates[0];
429
+ }
430
+
431
+ /** The vite binary the lagoon is served/built with (see findVite). */
432
+ export const VITE_BIN = findVite();
433
+
434
+ /** Accepts kebab-case, PascalCase or camelCase and returns the canonical names used throughout. */
435
+ export function names(input) {
436
+ const words = String(input)
437
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
438
+ .replace(/[_-]+/g, ' ')
439
+ .trim()
440
+ .split(/\s+/)
441
+ .filter(Boolean)
442
+ .map((w) => w.toLowerCase());
443
+ if (words.length === 0) throw new Error('island name is empty');
444
+ const pascal = words.map((w) => w[0].toUpperCase() + w.slice(1)).join('');
445
+ const camel = pascal[0].toLowerCase() + pascal.slice(1);
446
+ const kebab = words.join('-');
447
+ return { pascal, camel, kebab, tag: `${cfg.tagPrefix}${kebab}` };
448
+ }
449
+
450
+ export const color = {
451
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
452
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
453
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
454
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
455
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
456
+ };
457
+
458
+ /** ts-morph formatText settings matching the repo's 2-space style, applied after AST inserts. */
459
+ export const FMT = {
460
+ indentSize: 2,
461
+ tabSize: 2,
462
+ convertTabsToSpaces: true,
463
+ indentStyle: 2, // Smart
464
+ ensureNewLineAtEndOfFile: true,
465
+ };
466
+
467
+ /**
468
+ * An import specifier as an absolute path, so two spellings of one module compare equal.
469
+ *
470
+ * `@/app/x/y` and `./y` from inside `app/x` are the same file, and a check that compares the STRINGS
471
+ * reports a page as not importing what it plainly imports. Returns null for a package specifier.
472
+ */
473
+ export function resolveAppImport(fromFile, spec) {
474
+ const strip = (p) => p.replace(/\.(js|ts|tsx)$/, '');
475
+ if (spec.startsWith('@/')) return strip(resolve(HOST_ROOT, spec.slice(2)));
476
+ if (spec.startsWith('.')) return strip(resolve(dirname(fromFile), spec));
477
+ return null;
478
+ }
479
+
480
+ /**
481
+ * Comments blanked, WITHOUT mistaking a string for one.
482
+ *
483
+ * The regex version (`/\/\*[\s\S]*?\*\//`) cannot tell code from text, and a path glob is text
484
+ * containing `/*`: the moment an island declared `owns: 'src/modules/.../fields/**'`, everything from
485
+ * that quote to the next `*\/` — two more islands and a JSDoc away — was blanked, and the archipelago
486
+ * parsed as having ONE island instead of three. Silently, because a parser that finds less than is
487
+ * there has nothing to complain about.
488
+ *
489
+ * So it scans. Strings and templates are skipped whole; only real comments become spaces, and newlines
490
+ * survive so line numbers still mean something.
491
+ */
492
+ export function blankComments(text) {
493
+ let out = '';
494
+ let i = 0;
495
+ while (i < text.length) {
496
+ const ch = text[i];
497
+ // A string or template: copy it verbatim, including whatever slashes and stars it contains.
498
+ if (ch === "'" || ch === '"' || ch === '`') {
499
+ const quote = ch;
500
+ let j = i + 1;
501
+ while (j < text.length) {
502
+ if (text[j] === '\\') j += 2;
503
+ else if (text[j] === quote) break;
504
+ else j++;
505
+ }
506
+ out += text.slice(i, Math.min(j + 1, text.length));
507
+ i = j + 1;
508
+ continue;
509
+ }
510
+ if (ch === '/' && text[i + 1] === '*') {
511
+ const end = text.indexOf('*/', i + 2);
512
+ const stop = end === -1 ? text.length : end + 2;
513
+ out += text.slice(i, stop).replace(/[^\n]/g, ' ');
514
+ i = stop;
515
+ continue;
516
+ }
517
+ if (ch === '/' && text[i + 1] === '/') {
518
+ let j = i;
519
+ while (j < text.length && text[j] !== '\n') j++;
520
+ out += ' '.repeat(j - i);
521
+ i = j;
522
+ continue;
523
+ }
524
+ out += ch;
525
+ i++;
526
+ }
527
+ return out;
528
+ }
529
+
530
+ /**
531
+ * Make `@motujs/*` resolvable BY NODE inside the project, without an install.
532
+ *
533
+ * The lagoon resolves the framework with Vite aliases and tsconfig maps it with `paths` — that is the
534
+ * no-install posture and it works. What it does not cover is plain node: the runtime harness and the
535
+ * evidence loaders `import()` the project's own files, which import `@motujs/react`, and node knows
536
+ * nothing about either alias. In a workspace this is invisible because the workspace already linked
537
+ * them; in a standalone project `--fast` simply cannot load an island.
538
+ *
539
+ * So the links are created on demand, in the project's own gitignored `node_modules`, pointing at the
540
+ * checkout `motuRoot` names. An agent building an island in a fresh project hand-built exactly these
541
+ * by hand and inherited a duplicate-React problem for its trouble.
542
+ */
543
+ export function ensureNoInstallLinks(root, motuRoot) {
544
+ // CHECKOUT MODE ONLY. Installed from npm, `@motujs/*` are real packages in the app's node_modules,
545
+ // recorded in its package.json by `motu init` — and a symlink made here would REPLACE nothing (the
546
+ // `existsSync` below skips them) but would point into a `packages/` directory that does not exist
547
+ // the moment one is missing. There is nothing to restore that the package manager does not own.
548
+ if (INSTALLED) return 0;
549
+ const dir = resolve(root, 'node_modules', '@motujs');
550
+ // `chrome` belongs here even though a host never imports it by name: `@motujs/core`'s toolbar does
551
+ // (`import { MOTU_CHROME } from '@motujs/chrome'`). Leaving it out worked only as long as every host
552
+ // resolved motu's files by their real path and so fell through to motu's own workspace copy. A host
553
+ // that aliases `@motujs/*` to source instead — the arrangement Next and Angular both want — resolves
554
+ // from its own tree and gets an unresolved import from inside a package it never named.
555
+ const wanted = ['core', 'react', 'runtime', 'types', 'debug-overlay', 'chrome', 'coverage'];
556
+ // The HOST ADAPTERS live one directory down (`packages/adapters/next` -> `@motujs/adapter-next`) and
557
+ // were left out of this list, which made them the one part of the framework the no-install posture
558
+ // did not cover. Measured on a cold adoption of a real Next monorepo: `@motujs/adapter-next` was
559
+ // neither linked here nor written into the app's package.json (see below), so `nextHostBridge` —
560
+ // the single Next-specific export a Next host is documented to need — resolved nowhere, and the
561
+ // agent worked around it by abandoning `useHost` entirely. Nothing reported a problem.
562
+ const wantedAdapters = ['angularjs', 'next'];
563
+ let made = 0;
564
+ for (const name of [...wanted, ...wantedAdapters.map((a) => `adapters/${a}`)]) {
565
+ const target = resolve(motuRoot, 'packages', name);
566
+ if (!existsSync(target)) continue;
567
+ // `adapters/next` is published as `@motujs/adapter-next`: link it under the name it is IMPORTED by,
568
+ // never its directory name, or the specifier resolves to nothing and the link looks fine on disk.
569
+ const linkName = name.startsWith('adapters/') ? `adapter-${name.slice('adapters/'.length)}` : name;
570
+ const link = resolve(dir, linkName);
571
+ try {
572
+ if (existsSync(link)) continue;
573
+ mkdirSync(dir, { recursive: true });
574
+ symlinkSync(target, link, 'dir');
575
+ made++;
576
+ } catch {
577
+ // A read-only checkout or a real install already there: nothing to do, and nothing to say.
578
+ }
579
+ }
580
+ return made;
581
+ }
582
+
583
+ /**
584
+ * Does the host's TypeScript config make an out-of-range index visible?
585
+ *
586
+ * `default-props` already requires an island to render from defaults alone — which means handling the
587
+ * ABSENT case and the EMPTY one. motu checks the first mechanically and, until now, only hoped for the
588
+ * second: `missions[0].weekNumber` on a list that can be empty compiles clean under `strict`, throws at
589
+ * runtime, and is exactly the shape of a real bug this project has already fixed once.
590
+ *
591
+ * `noUncheckedIndexedAccess` types `missions[0]` as `MissionItem | undefined`, so forgetting is a
592
+ * compile error and `missions[0]?.weekNumber ?? 0` is not. It is the mechanical half of a rule motu
593
+ * already states, and it belongs in the island's own code rather than in a check that renders the
594
+ * state and watches for a throw — the compiler gets there first, for free, at authoring time.
595
+ *
596
+ * AUDITED, NOT ENFORCED. This is the host's build, the same boundary as a foreign store: motu can say
597
+ * the guarantee is missing and cannot install it.
598
+ *
599
+ * Follows `extends` (one chain, bounded) because a Next app keeps its strictness in a base config.
600
+ */
601
+ export function hostStrictBoundaries(root = HOST_ROOT) {
602
+ const seen = new Set();
603
+ let file = resolve(root, 'tsconfig.json');
604
+ // No tsconfig is not "the flag is off" — it is a project this check cannot speak about. Saying OFF
605
+ // would put a finding on every non-TypeScript host.
606
+ if (!existsSync(file)) return { known: false, enabled: false, file };
607
+ for (let hop = 0; hop < 8 && existsSync(file) && !seen.has(file); hop++) {
608
+ seen.add(file);
609
+ // MATCHED, NOT PARSED. A tsconfig is JSONC, and stripping its comments with a regex ate the `/*`
610
+ // inside a `"@/*"` path mapping — every project with a path alias came back "unknown". Two
611
+ // targeted patterns need no parser and cannot be confused by the rest of the file. Whole-line
612
+ // comments are dropped first, so a commented-out setting does not read as a setting.
613
+ const raw = readFileSync(file, 'utf8').replace(/^\s*\/\/.*$/gm, '');
614
+ const flag = raw.match(/"noUncheckedIndexedAccess"\s*:\s*(true|false)/);
615
+ if (flag) return { known: true, enabled: flag[1] === 'true', file };
616
+ const extends_ = raw.match(/"extends"\s*:\s*"([^"]+)"/);
617
+ if (!extends_) break;
618
+ const ext = extends_[1];
619
+ // A package specifier ('next/tsconfig.json') is not resolved here — its value is not knowable
620
+ // without walking node_modules, and reporting "off" for a base that enables it would be a lie.
621
+ if (!ext.startsWith('.')) return { known: false, enabled: false, file };
622
+ file = resolve(dirname(file), ext.endsWith('.json') ? ext : `${ext}.json`);
623
+ }
624
+ // Absent means OFF: `strict` does not include it, so silence in the config is a real answer.
625
+ return { known: true, enabled: false, file };
626
+ }