@motujs/cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,123 @@
1
+ // CAN THIS ISLAND BE BUNDLED FOR A BROWSER, and if not, which module stopped it?
2
+ //
3
+ // Two callers, one answer. `motu island verify` asks it to REPORT (`rsc-boundary`), and the lagoon
4
+ // build asks it to DECIDE — because an island whose import graph cannot be bundled does not merely
5
+ // fail its own preview, it fails the whole build.
6
+ //
7
+ // WHY THE SECOND CALLER EXISTS. Measured on a cold adoption of Formbricks: a SAML button statically
8
+ // imports a Next server action, which reaches `node:async_hooks` five hops down. The lagoon builds
9
+ // every archipelago as ONE chunk, so that single island took the entire gallery with it — rollup died
10
+ // 11,000 modules deep, no island in the project was previewable, and the agent that hit it ended with
11
+ // a fully green `motu check --runtime` and not one openable URL. Green checks and nothing to look at
12
+ // is the worst possible state for a tool whose promise is "every declared state has an address".
13
+ //
14
+ // The island's own verdict does not change: it still fails `rsc-boundary`, and its own preview is
15
+ // still broken. What changes is the BLAST RADIUS.
16
+ import { existsSync, readFileSync, statSync } from 'node:fs';
17
+ import { blankComments, names, paths, resolveAppImport, HOST, islandComponentPath } from './util.mjs';
18
+ import { listIslands } from './islands.mjs';
19
+
20
+ /** Adapters that ship a verify contribution, by the specifier an island imports / by host. */
21
+ const ADAPTER_VERIFY = { '@motujs/adapter-next': '@motujs/adapter-next/verify' };
22
+ const HOST_ADAPTER_VERIFY = { next: '@motujs/adapter-next/verify' };
23
+
24
+ /**
25
+ * Every APPLICATION file an island's component can reach, breadth-first from the component.
26
+ *
27
+ * A host boundary is a property of the BUNDLE, not of one file. Judging it from the island's own
28
+ * source alone is how `rsc-boundary` printed `✓` over a component that could not be built.
29
+ *
30
+ * Bounded on purpose — package specifiers are not followed, and the file count is capped so a check
31
+ * that runs per island stays cheap. The cap being HIT is reported, because a check that quietly
32
+ * examined part of a graph is the bug this closes.
33
+ */
34
+ export function reachableAppSources(entry, { aliased = [], limit = 400 } = {}) {
35
+ const isAliased = (spec) => aliased.some((a) => spec === a || spec.startsWith(a + '/'));
36
+ const seen = new Set();
37
+ const out = [];
38
+ const queue = [entry];
39
+ let truncated = false;
40
+ while (queue.length) {
41
+ const file = queue.shift();
42
+ const found = ['', '.tsx', '.ts', '/index.tsx', '/index.ts']
43
+ .map((e) => file + e)
44
+ .find((c) => existsSync(c) && statSync(c).isFile());
45
+ if (!found || seen.has(found)) continue;
46
+ seen.add(found);
47
+ if (out.length >= limit) {
48
+ truncated = true;
49
+ break;
50
+ }
51
+ let src;
52
+ try {
53
+ src = readFileSync(found, 'utf8');
54
+ } catch {
55
+ continue;
56
+ }
57
+ if (found !== entry) out.push({ file: paths.rel(found), source: src });
58
+ for (const m of blankComments(src).matchAll(/(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g)) {
59
+ if (isAliased(m[1])) continue;
60
+ const next = resolveAppImport(found, m[1]);
61
+ if (next) queue.push(next);
62
+ }
63
+ }
64
+ if (truncated) out.push({ file: '(truncated)', source: '', truncated: true });
65
+ return out;
66
+ }
67
+
68
+ /** The adapter verify module for one island, or null when no adapter judges this host. */
69
+ function adapterFor(elementPath) {
70
+ if (!existsSync(elementPath)) return null;
71
+ const text = readFileSync(elementPath, 'utf8');
72
+ const pkgs = [...text.matchAll(/from\s+['"](@motujs\/adapter-[\w-]+)['"]/g)].map((m) => m[1]);
73
+ return pkgs.map((p) => ADAPTER_VERIFY[p]).find(Boolean) ?? HOST_ADAPTER_VERIFY[HOST] ?? null;
74
+ }
75
+
76
+ /**
77
+ * Islands that CANNOT be bundled, as `{ kebab, tag, element, componentPath, reason }`.
78
+ *
79
+ * Only an ERROR-level `rsc-boundary` finding counts. A warning is a note about how something renders;
80
+ * this list decides whether a module is put into the build at all, so it takes only the level that
81
+ * means "this cannot work", and errs toward including an island when it cannot tell.
82
+ */
83
+ export async function unbundlableIslands({ aliased = [] } = {}) {
84
+ const out = [];
85
+ for (const island of listIslands(paths.islandsDir)) {
86
+ const specifier = adapterFor(island.element);
87
+ if (!specifier) continue;
88
+ // `islandComponentPath(kebab, PASCAL)` — the pascal name, which is what every other caller passes.
89
+ // Handing it the EXPORTED name instead returned nothing, so the detector found no islands at all
90
+ // and excluded nothing, silently. Caught only because the build then behaved identically with the
91
+ // feature on and off.
92
+ const { pascal } = names(island.kebab);
93
+ const componentPath = islandComponentPath(island.kebab, pascal);
94
+ if (!componentPath || !existsSync(componentPath)) continue;
95
+ let mod;
96
+ try {
97
+ mod = await import(specifier);
98
+ } catch {
99
+ continue; // no adapter available: not our place to exclude anything
100
+ }
101
+ let findings = [];
102
+ try {
103
+ findings = mod.checkCoupling({
104
+ source: readFileSync(componentPath, 'utf8'),
105
+ elementSource: readFileSync(island.element, 'utf8'),
106
+ graph: reachableAppSources(componentPath, { aliased }),
107
+ });
108
+ } catch {
109
+ continue;
110
+ }
111
+ const fatal = findings.filter((f) => f.level === 'error' && f.check === 'rsc-boundary');
112
+ if (!fatal.length) continue;
113
+ const tag = readFileSync(island.element, 'utf8').match(/tag:\s*['"]([^'"]+)['"]/)?.[1];
114
+ out.push({
115
+ kebab: island.kebab,
116
+ tag: tag ?? null,
117
+ element: island.element,
118
+ componentPath,
119
+ reason: fatal[0].msg,
120
+ });
121
+ }
122
+ return out;
123
+ }
@@ -0,0 +1,157 @@
1
+ // Which islands and regions did this branch actually touch?
2
+ //
3
+ // The per-island cost of `--runtime` is roughly constant, so a project pays it once per island: at
4
+ // sixteen islands a full run is a hundred seconds, and an agent that changed one file pays for the
5
+ // other fifteen. That is the difference between a check an agent runs and one it learns to skip.
6
+ //
7
+ // THE RULE THIS FOLLOWS: narrowing must never be silent. A change this cannot attribute to an island
8
+ // or a region — the shared module, the lagoon frame, the config, the framework itself — widens the run
9
+ // back to everything and says why. A check that quietly examined less than you think is the failure
10
+ // this whole report exists to avoid, and "fast because it skipped your change" is the worst version.
11
+ import { execFileSync } from 'node:child_process';
12
+ import { relative, resolve, sep } from 'node:path';
13
+ import { islandComponentPath, names, paths, REPO_ROOT } from './util.mjs';
14
+ import { listIslands } from './islands.mjs';
15
+ import { readRegions } from './eject.mjs';
16
+
17
+ /** Files this working tree has touched, relative to `base` (default: everything not yet committed). */
18
+ function changedFiles(base) {
19
+ const git = (args) => execFileSync('git', args, { cwd: REPO_ROOT, encoding: 'utf8' }).trim();
20
+ const out = new Set();
21
+ try {
22
+ // Uncommitted work, staged and not, plus untracked files: what an agent has in front of it.
23
+ for (const f of git(['diff', '--name-only', 'HEAD']).split('\n')) if (f) out.add(f);
24
+ for (const f of git(['ls-files', '--others', '--exclude-standard']).split('\n')) if (f) out.add(f);
25
+ // And, when asked, everything since a branch point.
26
+ if (base) for (const f of git(['diff', '--name-only', `${base}...HEAD`]).split('\n')) if (f) out.add(f);
27
+ } catch {
28
+ return null; // not a git repo, or git unavailable — cannot scope, so do not pretend to
29
+ }
30
+ return [...out].map((f) => resolve(gitRoot(), f));
31
+ }
32
+
33
+ function gitRoot() {
34
+ try {
35
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: REPO_ROOT, encoding: 'utf8' }).trim();
36
+ } catch {
37
+ return REPO_ROOT;
38
+ }
39
+ }
40
+
41
+ const under = (dir, file) => {
42
+ const rel = relative(dir, file);
43
+ return rel && !rel.startsWith('..') && !rel.startsWith(sep) ? rel : null;
44
+ };
45
+
46
+ /**
47
+ * Split the touched files into the islands and regions they belong to.
48
+ *
49
+ * Returns `{ scoped: false, reason }` when something changed that cannot be attributed — the caller
50
+ * then runs everything, which is the safe direction to be wrong in.
51
+ */
52
+ /**
53
+ * Files that cannot change what an island renders, whatever else they change.
54
+ *
55
+ * NARROWING MUST NEVER BE SILENT — that rule is why anything unattributable widens the run, and it is
56
+ * right. But it made the most common edit in an agent's tree the most expensive: `.claude/
57
+ * settings.local.json` changes every time a permission is granted, belongs to no island, and forced a
58
+ * hundred-second sweep to prove something about a file the lagoon never loads. Editor and CI
59
+ * configuration is not a silent narrowing; it is a file that provably cannot reach a render.
60
+ *
61
+ * Deliberately short, and deliberately NOT including lockfiles: a dependency version absolutely can
62
+ * change what renders. If a pattern here is ever wrong, the run gets narrower than the truth, so the
63
+ * bar for adding one is "there is no path from this file to a rendered pixel".
64
+ */
65
+ const CANNOT_AFFECT_A_RENDER = [
66
+ /(^|\/)\.claude\//,
67
+ /(^|\/)\.vscode\//,
68
+ /(^|\/)\.idea\//,
69
+ /(^|\/)\.github\//,
70
+ /\.md$/,
71
+ ];
72
+
73
+ export function changedScope(base) {
74
+ const files = changedFiles(base);
75
+ if (!files) return { scoped: false, reason: 'not a git repository, so nothing can be attributed' };
76
+ if (!files.length) return { scoped: true, islands: [], regions: [], files: 0 };
77
+
78
+ const islands = new Set();
79
+ const regions = new Set();
80
+ const unattributed = [];
81
+
82
+ // AN ISLAND'S COMPONENT MAY NOT LIVE UNDER motu AT ALL. On a React host the island wraps a component
83
+ // the application already owns — acme's `src/ui` is empty and every component sits in the app — so
84
+ // mapping by directory alone would attribute the most common edit to nothing and widen every run
85
+ // back to the whole project. Follow each island to the file it actually mounts.
86
+ const byComponent = new Map();
87
+ for (const i of listIslands(paths.islandsDir)) {
88
+ try {
89
+ const componentPath = islandComponentPath(i.kebab, names(i.kebab).pascal);
90
+ if (componentPath) byComponent.set(componentPath, i.kebab);
91
+ } catch {
92
+ // An island whose component cannot be resolved is one this cannot scope; leave it to widen.
93
+ }
94
+ }
95
+
96
+ for (const file of files) {
97
+ if (CANNOT_AFFECT_A_RENDER.some((re) => re.test(file))) continue;
98
+ const inIslands = under(paths.islandsDir, file);
99
+ const inUi = under(paths.uiRoot, file);
100
+ const inRegions = under(paths.archipelagosDir, file);
101
+
102
+ if (inRegions) {
103
+ const id = inRegions.split(sep)[0];
104
+ if (id && !id.endsWith('.ts')) regions.add(id);
105
+ else unattributed.push(file); // the registry itself, or a loose file — affects every region
106
+ continue;
107
+ }
108
+ if (inIslands) {
109
+ // `<kebab>.island.ts`, `<kebab>.evidence.ts` or `<kebab>/…`; the generated registry is nobody's.
110
+ const head = inIslands.split(sep)[0];
111
+ const kebab = head.replace(/\.(island|evidence)\.tsx?$/, '').replace(/\.tsx?$/, '');
112
+ if (head.startsWith('registry.') || head.startsWith('contracts.')) continue; // generated
113
+ if (kebab) islands.add(kebab);
114
+ else unattributed.push(file);
115
+ continue;
116
+ }
117
+ if (inUi) {
118
+ const dir = inUi.split(sep)[0];
119
+ if (dir) islands.add(dir);
120
+ else unattributed.push(file);
121
+ continue;
122
+ }
123
+ const owner = byComponent.get(file);
124
+ if (owner) {
125
+ islands.add(owner);
126
+ continue;
127
+ }
128
+ // THE LAGOON'S PER-REGION MODULE belongs to that region and to no other: `regions/<id>.tsx` holds
129
+ // one region's seed and one region's arrangement. The map that wires them all together
130
+ // (`lagoon.tsx`) is a different file and still widens, as it should.
131
+ const inLagoonRegions = under(resolve(paths.lagoonDir, 'src/regions'), file);
132
+ if (inLagoonRegions && !inLagoonRegions.includes(sep)) {
133
+ regions.add(inLagoonRegions.replace(/\.tsx?$/, ''));
134
+ continue;
135
+ }
136
+ unattributed.push(file);
137
+ }
138
+
139
+ if (unattributed.length) {
140
+ return {
141
+ scoped: false,
142
+ reason:
143
+ `${unattributed.length} changed file(s) belong to no single island or region ` +
144
+ `(e.g. ${relative(gitRoot(), unattributed[0])})`,
145
+ };
146
+ }
147
+ // A CHANGED ISLAND BELONGS TO SPECIFIC REGIONS, not to all of them. Running every region because one
148
+ // island moved cost more than running every island: a region check is ~20s, an island ~3s.
149
+ for (const region of readRegions(paths.archipelagosDir)) {
150
+ const tags = new Set((region.islands ?? []).map((i) => i.element));
151
+ for (const kebab of islands) {
152
+ if (tags.has(names(kebab).tag)) regions.add(region.id);
153
+ }
154
+ }
155
+
156
+ return { scoped: true, islands: [...islands], regions: [...regions], files: files.length };
157
+ }
@@ -0,0 +1,145 @@
1
+ // What a component already tells us about its own boundary.
2
+ //
3
+ // Every island file starts as the same transcription job: list the props, map the `on*` ones to event
4
+ // names, note which host capabilities the component reaches for. All three are facts about the
5
+ // component, sitting in its source — so scaffolding should read them, not ask a human (or an agent) to
6
+ // copy them and get one wrong. What is left for a person to decide is the part that is actually a
7
+ // decision: which region keys those props bind to, and what the events are called.
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { SyntaxKind } from 'ts-morph';
10
+ import { sourceFileAt } from './ts-project.mjs';
11
+ import { lagoonAliases } from './util.mjs';
12
+
13
+ /**
14
+ * Everything an island reaches without being handed it — declared as `contract.effects`.
15
+ *
16
+ * CANDIDATES ONLY. These patterns are a heuristic for "looks like a host capability"; what makes one
17
+ * ACTUALLY an effect is that the lagoon has to stand it in (`lagoonAliases()`), and the derivation
18
+ * below intersects with that whenever a lagoon config exists. Keeping the patterns as the fallback
19
+ * means a project with no lagoon still gets a sensible contract.
20
+ */
21
+ const AMBIENT = [/^@\/lib\/contexts\//, /^@\/hooks\//, /^@\/lib\/services\//, /^@\/contexts\//, /^@\/services\//];
22
+
23
+ /** `onWeekProgress` -> `week-progress`. The author renames it if the region has a better word. */
24
+ /** React's callback convention, and the ONLY test either side of the contract may use. */
25
+ const CALLBACK = /^on[A-Z]/;
26
+
27
+ export function eventNameFor(prop) {
28
+ return prop
29
+ .replace(/^on/, '')
30
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
31
+ .replace(/^-/, '')
32
+ .toLowerCase();
33
+ }
34
+
35
+ /**
36
+ * Read a component's boundary: input props, callback props (with a proposed event name), and the host
37
+ * modules it imports.
38
+ *
39
+ * Returns null when the component cannot be found or its props cannot be resolved from the file alone
40
+ * — scaffolding then falls back to an empty contract rather than inventing one.
41
+ */
42
+ const contractCache = new Map();
43
+
44
+ /**
45
+ * Does the component invoke this callback from an EFFECT — i.e. from rendering itself, rather than
46
+ * from something a user does?
47
+ *
48
+ * The distinction decides what a runtime check may expect. An output fired inside `useEffect` MUST
49
+ * fire when the island simply renders, so a region that renders and never sees it has a component
50
+ * that stopped emitting. An output fired from a click handler need not fire at all during a
51
+ * render-only pass, and demanding it would flag every well-behaved island in the project.
52
+ *
53
+ * CONSERVATIVE BY DESIGN: it answers "provably effect-driven", and anything it cannot prove is
54
+ * reported as not effect-driven, so the runtime check stays silent rather than guessing. An indirect
55
+ * call — the callback handed to a helper that invokes it later — reads as handler-driven here, which
56
+ * costs a missed finding and never a false one.
57
+ */
58
+ function calledFromEffect(fn, prop) {
59
+ const EFFECT_HOOKS = new Set(['useEffect', 'useLayoutEffect', 'useInsertionEffect']);
60
+ let found = false;
61
+ fn.forEachDescendant((node) => {
62
+ if (found) return;
63
+ if (node.getKindName?.() !== 'CallExpression') return;
64
+ const expr = node.getExpression?.();
65
+ // `onProgress(...)` or `props.onProgress(...)` — the two spellings a destructured or bagged prop takes.
66
+ const callee = expr?.getText?.() ?? '';
67
+ if (callee !== prop && !callee.endsWith(`.${prop}`)) return;
68
+ for (let a = node.getParent?.(); a; a = a.getParent?.()) {
69
+ if (a.getKindName?.() !== 'CallExpression') continue;
70
+ const hook = a.getExpression?.()?.getText?.() ?? '';
71
+ if (EFFECT_HOOKS.has(hook.split('.').pop() ?? '')) {
72
+ found = true;
73
+ return;
74
+ }
75
+ }
76
+ });
77
+ return found;
78
+ }
79
+
80
+ export function readComponentContract(file, exportName) {
81
+ if (!existsSync(file)) return null;
82
+ // Reading a component means parsing it and resolving its props type, which is the expensive part of
83
+ // a static verify. A sweep asks for the same file several times (props-match, effects, the generated
84
+ // contract), so the answer is remembered for the life of the process.
85
+ const cacheKey = `${file}::${exportName ?? ''}`;
86
+ if (contractCache.has(cacheKey)) return contractCache.get(cacheKey);
87
+ const result = readComponentContractUncached(file, exportName);
88
+ contractCache.set(cacheKey, result);
89
+ return result;
90
+ }
91
+
92
+ function readComponentContractUncached(file, exportName) {
93
+ const sf = sourceFileAt(file, { allowJs: true, jsx: 4 });
94
+
95
+ const fn =
96
+ (exportName && (sf.getFunction(exportName) ?? sf.getVariableDeclaration(exportName)?.getInitializerIfKind(SyntaxKind.ArrowFunction))) ??
97
+ sf.getFunctions().find((f) => f.isExported() && /^[A-Z]/.test(f.getName() ?? '')) ??
98
+ null;
99
+ if (!fn) return null;
100
+
101
+ let names = [];
102
+ const param = fn.getParameters()[0];
103
+ if (param) {
104
+ try {
105
+ names = param
106
+ .getType()
107
+ .getProperties()
108
+ .map((p) => p.getName());
109
+ } catch {
110
+ names = [];
111
+ }
112
+ }
113
+
114
+ // A TYPE import is not a capability: it erases, the island never calls it, and declaring it as
115
+ // declaring it would ask the lagoon to stand in for a module nothing reaches at runtime.
116
+ //
117
+ // Intersected with what the lagoon actually stands down, so this agrees with the `effects` check by
118
+ // construction rather than by coincidence — see `lagoonAliases()` for the split this closed.
119
+ const stoodDown = lagoonAliases();
120
+ const effects = [...new Set(
121
+ readFileSync(file, 'utf8')
122
+ .split('\n')
123
+ .filter((l) => !/^\s*import\s+type\b/.test(l))
124
+ .map((l) => l.match(/from\s*['"]([^'"]+)['"]/)?.[1])
125
+ .filter((spec) => spec && AMBIENT.some((re) => re.test(spec)))
126
+ .filter((spec) => stoodDown === null || stoodDown.includes(spec)),
127
+ )].sort();
128
+
129
+ return {
130
+ component: fn.getName?.() ?? exportName ?? null,
131
+ // `fit` is motu's own injected prop, never part of an island's declared boundary.
132
+ // ONE RULE FOR BOTH SIDES, and it is React's own: a callback is `on` FOLLOWED BY A CAPITAL.
133
+ //
134
+ // These two filters used to disagree — inputs excluded anything starting with `on`, outputs
135
+ // required `/^on[A-Z]/` — so a prop whose name merely begins with those two letters
136
+ // (`onboardingState`) was neither. It vanished from the contract silently: not an input, not an
137
+ // event, and `props-match` then reported it as a callback nobody had mapped. A prop that decides
138
+ // which of four cards a page renders, dropped because of two letters.
139
+ input: names.filter((n) => !CALLBACK.test(n) && n !== 'fit'),
140
+ output: names
141
+ .filter((n) => CALLBACK.test(n))
142
+ .map((n) => ({ prop: n, event: eventNameFor(n), effectDriven: calledFromEffect(fn, n) })),
143
+ effects,
144
+ };
145
+ }