@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,878 @@
1
+ // `motu removal-check` — prove that removing motu from the host application is a NO-OP.
2
+ //
3
+ // C2: motu may appear in app code, but it must never be load-bearing. Delete the framework and the app
4
+ // must still compile and render. Two allowed shapes:
5
+ //
6
+ // 1. a file that is 100% motu (a composition root, a dev page) — deletable whole;
7
+ // 2. a wrapper whose deletion leaves valid JSX — strip the import, unwrap the element, and the page
8
+ // still renders its own components with the props it already computes.
9
+ //
10
+ // This performs exactly that surgery and runs the HOST's own typecheck on the result. Everything is
11
+ // backed up first and restored in a finally block: the check must never be able to leave a repo
12
+ // half-stripped, including when the typecheck itself throws.
13
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync, cpSync } from 'node:fs';
14
+ import { spawnSync } from 'node:child_process';
15
+ import { createHash } from 'node:crypto';
16
+ import { relative, resolve, dirname } from 'node:path';
17
+ import { Project, SyntaxKind } from 'ts-morph';
18
+ import { paths, color, blankComments } from '../lib/util.mjs';
19
+ import { loadMotuConfig } from '../lib/config.mjs';
20
+ import { hostSourceFiles, describeSources } from '../lib/host-sources.mjs';
21
+ import { listIslands } from '../lib/islands.mjs';
22
+ import { ejectFile, readOutputs, readRegions, regionOfRoot } from '../lib/eject.mjs';
23
+
24
+ /** The host's own typecheck, run the way the host would run it. */
25
+ function hostTypecheck(hostRoot) {
26
+ const tsconfig = existsSync(resolve(hostRoot, 'tsconfig.json')) ? 'tsconfig.json' : null;
27
+ return spawnSync('npx', ['tsc', '--noEmit', ...(tsconfig ? ['-p', tsconfig] : [])], {
28
+ cwd: hostRoot,
29
+ encoding: 'utf8',
30
+ stdio: ['ignore', 'pipe', 'pipe'],
31
+ maxBuffer: 64 * 1024 * 1024,
32
+ });
33
+ }
34
+
35
+
36
+ /**
37
+ * Every app source and what it imports, read as TEXT.
38
+ *
39
+ * The check used to hand the whole application to ts-morph — a thousand files parsed and type-loaded
40
+ * so it could find the dozen that mention motu. What it actually needs first is an import graph, and
41
+ * an import graph is a regex over source text. The files that matter are then parsed properly; the
42
+ * rest are never opened again.
43
+ */
44
+ function importGraph(hostRoot, motuRoot, cfg) {
45
+ const graph = new Map();
46
+ const resolved = hostSourceFiles(hostRoot, cfg);
47
+ // The ORIGIN travels with the graph: a scan that found nothing has to be able to say what it
48
+ // trusted, or "0 files" is indistinguishable from "the host has no source".
49
+ graph.origin = resolved.origin;
50
+ graph.detail = resolved.detail;
51
+ for (const full of resolved.files) {
52
+ // MOTU'S OWN SOURCE IS NOT THE HOST'S — unless the host lives inside the checkout.
53
+ //
54
+ // This skipped everything under `motuRoot`, which is right for an application that sits beside
55
+ // motu and wrong for one that sits inside it. The review console does: it was subtree'd into
56
+ // this repository, so every one of its files matched and the walk returned nothing, and the
57
+ // check reported "examined no files" over a fully integrated app — the same empty-search
58
+ // failure `hostSourceFiles` was written to stop, arriving by a different door.
59
+ //
60
+ // Guarded on the two being different so the repo-root project, whose hostRoot IS the checkout,
61
+ // does not start treating motu's own packages as the host.
62
+ //
63
+ // AND AN EXPLICIT ANSWER IS NOT A GUESS. When `hostSources` named the directories, this skip was
64
+ // still discarding them: for a host that lives INSIDE the checkout `hostRoot === motuRoot`, so
65
+ // every file matched and the walk returned nothing — over a project whose config said, in as many
66
+ // words, where its application is. That is `hostSources` shipping as a no-op for the second time,
67
+ // and the failure it produces is the worst one here: "examined no files" is reported as a FAILING
68
+ // load-bearing check, so the fix looks like a bug in the host.
69
+ if (resolved.origin !== 'config' && full.startsWith(motuRoot) && (hostRoot === motuRoot || !full.startsWith(hostRoot))) continue;
70
+ let text;
71
+ try {
72
+ text = readFileSync(full, 'utf8');
73
+ } catch {
74
+ continue;
75
+ }
76
+ // TYPE-ONLY IMPORTS ARE NOT IMPORTS, for the one question this graph answers.
77
+ //
78
+ // The rule downstream is "a file is deleted whole only if EVERY import it makes is motu's". A
79
+ // `import type { X } from '@/app/…'` erases at compile time: it cannot make the file need the
80
+ // application at runtime, and counting it disqualifies a file that is still 100% motu once the
81
+ // types are gone. That matters the moment a motu declaration wants to be TYPED against the host —
82
+ // an archipelago naming the props of the page it belongs to, say — which is otherwise impossible
83
+ // without making the archipelago undeletable.
84
+ //
85
+ // Both spellings: the whole clause (`import type { X } from`) and nothing else, since a mixed
86
+ // `import { type X, y }` still imports `y` for real.
87
+ graph.set(
88
+ full,
89
+ [...text.matchAll(/(?:^|\n)\s*(?:import|export)(?!\s+type\s)[^'"\n]*?from\s*['"]([^'"]+)['"]/g)].map(
90
+ (m) => m[1],
91
+ ),
92
+ );
93
+ }
94
+ return graph;
95
+ }
96
+
97
+ /**
98
+ * What the answer depends on: the files the surgery touches, and the declarations it generates from.
99
+ *
100
+ * Nothing else can change the verdict. The host's own type errors are the host's own build to catch —
101
+ * this check asks one question ("is motu load-bearing?"), and that question's inputs are small enough
102
+ * to hash. Which means the expensive part (a full `tsc` of the application) can be skipped when they
103
+ * are all identical to the last time it passed.
104
+ */
105
+ function proofFingerprint(candidates, extra) {
106
+ const h = createHash('sha1');
107
+ for (const file of [...candidates, ...extra].sort()) {
108
+ h.update(file);
109
+ try {
110
+ h.update(readFileSync(file));
111
+ } catch {
112
+ h.update('<missing>');
113
+ }
114
+ }
115
+ return h.digest('hex');
116
+ }
117
+
118
+ /** Specifiers that only exist because motu does. */
119
+ const MOTU_SPECIFIER = /^@motujs\/|^motu-islands(\/|$)/;
120
+
121
+ /**
122
+ * The one motu module that STAYS, and the sentence it makes true.
123
+ *
124
+ * Deleting motu leaves no runtime trace — the application ships byte-identical output. One
125
+ * TYPE-ONLY package remains, which erases at compile time.
126
+ *
127
+ * `@motujs/types` has no value exports, so an app file importing it emits nothing: the guarantee this
128
+ * check exists to prove is about the RUNTIME, and it is unchanged. Stripping the import would only
129
+ * make the app fail to typecheck against types that were never going to be there — proving something
130
+ * weaker while looking stricter. Anything else under `@motujs/` is still removed.
131
+ */
132
+ const MOTU_TYPES_ONLY = /^@motujs\/types(\/|$)/;
133
+
134
+ /**
135
+ * Which JSX tags in THIS file disappear with motu.
136
+ *
137
+ * Not a fixed list. A project legitimately has its own thin composition root — a client component that
138
+ * configures the transport, builds the host bridge and renders `<Archipelago>` — and that file is 100%
139
+ * motu, so it gets deleted. Any tag imported FROM a deleted file must therefore be unwrapped too, or
140
+ * removing motu leaves a dangling element referring to a file that is gone.
141
+ *
142
+ * So: a tag is unwrappable when it was imported from a motu specifier, or from a file that is itself
143
+ * motu-only. Unwrapping keeps the children — that is the whole C2 property.
144
+ */
145
+ /**
146
+ * `<X.Root a={<A/>} header={{ member }} />` -> `<RootComponent a={<A/>} header={<Header member={member} />} />`.
147
+ *
148
+ * The region's whole composition is declared, so this is a rename plus two import lines rather than a
149
+ * reconstruction: every prop the page passes is already the page's own JSX, and the archipelago says
150
+ * which of them an island was wrapping (nothing to unwrap — the island wrapper is motu's, the child is
151
+ * the app's) and which is a host component given as data.
152
+ */
153
+ /**
154
+ * Which region a `<X.Root>` belongs to, from the BINDING it hangs off — `X = createRegion(<arch>)`,
155
+ * declared in this file or in the module `X` is imported from — or null when that cannot be read.
156
+ *
157
+ * Preferred over the prop-scoring guess (`regionOfRoot`) because it is not a guess, and because a
158
+ * region nested in another one's slot is exactly where the guess fails: `<Equipage.Root />` passes no
159
+ * prop at all, scores zero against every region, and was replaced with `null` — the proof still
160
+ * compiled, but it proved a page with the nested region deleted rather than ejected.
161
+ */
162
+ function regionOfBinding(sf, binding, regions, resolveSpec) {
163
+ const archOf = (text, name) =>
164
+ blankComments(text).match(new RegExp(`(?:const|let|var)\\s+${name}\\s*=\\s*createRegion\\s*\\(\\s*([A-Za-z_$][\\w$]*)`))?.[1] ?? null;
165
+ let arch = archOf(sf.getFullText(), binding);
166
+ if (!arch) {
167
+ for (const imp of sf.getImportDeclarations()) {
168
+ const named = imp.getNamedImports().find((n) => (n.getAliasNode() ?? n.getNameNode()).getText() === binding);
169
+ if (!named) continue;
170
+ const target = resolveSpec?.(sf, imp.getModuleSpecifierValue());
171
+ if (target && existsSync(target)) arch = archOf(readFileSync(target, 'utf8'), named.getName());
172
+ break;
173
+ }
174
+ }
175
+ if (!arch) return null;
176
+ return regions.find((r) => r.file && existsSync(r.file) && new RegExp(`export\\s+const\\s+${arch}\\b`).test(readFileSync(r.file, 'utf8'))) ?? null;
177
+ }
178
+
179
+ function rewriteRegionRoots(sf, regions, tags, filePath, resolveSpec) {
180
+ const rooted = regions.filter((r) => r.root);
181
+ if (!rooted.length) return;
182
+ const needed = new Map();
183
+ for (const el of [
184
+ ...sf.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
185
+ ...sf.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
186
+ ].reverse()) {
187
+ const tag = el.getTagNameNode().getText();
188
+ if (tag.split('.').pop() !== 'Root') continue;
189
+ if (!tags.has(rootTagName(tag))) continue;
190
+ const byBinding = regionOfBinding(sf, rootTagName(tag), rooted, resolveSpec);
191
+ const region = byBinding ?? regionOfRoot(el, rooted) ?? (rooted.length === 1 ? rooted[0] : null);
192
+ if (!region) continue;
193
+
194
+ // A host-slot prop carries the component's PROPS; it has to become the element again.
195
+ for (const [prop, spec] of Object.entries(region.rootHostSlots ?? {})) {
196
+ const attr = el.getAttribute?.(prop);
197
+ const expr = attr?.getInitializer?.()?.getExpression?.();
198
+ if (!expr || !spec.component) continue;
199
+ const text = expr.getText().trim();
200
+ const inner = text.startsWith('{') && text.endsWith('}') ? text.slice(1, -1).trim() : null;
201
+ // `{{ member }}` is an object literal of props; spreading it keeps whatever shape it had,
202
+ // including a shorthand, without this having to parse the properties.
203
+ attr.replaceWithText(`${prop}={<${spec.component} {...${inner === null ? text : `{${inner}}`}} />}`);
204
+ needed.set(spec.component, spec.from);
205
+ }
206
+
207
+ el.getTagNameNode().replaceWithText(region.root);
208
+ // A paired `<X.Root>…</X.Root>` closes with the same name.
209
+ const closing = el.getParent?.()?.getClosingElement?.();
210
+ if (closing) closing.getTagNameNode().replaceWithText(region.root);
211
+ needed.set(region.root, region.rootFrom);
212
+ }
213
+ for (const [name, from] of needed) {
214
+ if (!from) continue;
215
+ if (sf.getImportDeclarations().some((i) => (i.getNamedImports?.() ?? []).some((n) => n.getName() === name))) continue;
216
+ sf.addImportDeclaration({ moduleSpecifier: from, namedImports: [name] });
217
+ }
218
+ void filePath;
219
+ }
220
+
221
+ /**
222
+ * The JSX half of the surgery on one file: `<X.Root>` rewritten to the application's own root, then
223
+ * every other motu tag unwrapped (children kept) or, self-closing, replaced with `null`.
224
+ *
225
+ * Exported so the shape it produces can be pinned without a host and a `tsc` — a region nested in
226
+ * another region's slot (`businessTeamPanel={<Equipage.Region><Equipage.Root /></Equipage.Region>}`)
227
+ * must come out as `businessTeamPanel={<EquipageLayout />}`, not as `null`.
228
+ */
229
+ export function rewriteMotuJsx(sf, regions, tags, resolveSpec) {
230
+ rewriteRegionRoots(sf, regions, tags, sf.getFilePath(), resolveSpec);
231
+ // Form 2: unwrap motu's JSX, keep the children, drop the imports.
232
+ for (const el of [...sf.getDescendantsOfKind(SyntaxKind.JsxElement)].reverse()) {
233
+ // `<Actions.Island>` — a region binding namespaces its surface, so the tag is qualified and the
234
+ // thing that came from motu is the object it hangs off.
235
+ const name = rootTagName(el.getOpeningElement().getTagNameNode().getText());
236
+ if (!tags.has(name)) continue;
237
+ // What the page KEEPS. JSX comments and whitespace are not children in any sense that matters —
238
+ // React never sees them — but joining them in produced `{/* … */}<Thing/>`, which is not one
239
+ // expression, and replacing an element inside a JSX attribute with it threw out of ts-morph and
240
+ // took `motu check` down with a stack trace. A comment inside an island was enough.
241
+ const kept = el
242
+ .getJsxChildren()
243
+ .map((c) => c.getText())
244
+ .filter((t) => t.trim() && !/^\{\s*\/\*[\s\S]*\*\/\s*\}$/.test(t.trim()));
245
+ const inner = kept.join('').trim();
246
+ const single = kept.length === 1 && (inner.startsWith('<') || inner.startsWith('{'));
247
+ // WHERE THE WRAPPER STOOD DECIDES WHAT REPLACES IT.
248
+ //
249
+ // `{expr}` is how a JSX child holds an expression, and it is an OBJECT LITERAL anywhere else. So
250
+ // unwrapping `return <MotuRegion>{screen}</MotuRegion>` by keeping the child verbatim produced
251
+ // `return {screen}` — valid TypeScript, a completely different program, and a removal proof that
252
+ // failed with "type '{ screen: Element }' is not a valid JSX element type". Strip the braces when
253
+ // the wrapper was NOT itself a JSX child; keep them when it was, or `<div>{x}</div>` unwraps to a
254
+ // div containing the letter x.
255
+ const parentKind = el.getParent()?.getKind();
256
+ const inJsxChild = parentKind === SyntaxKind.JsxElement || parentKind === SyntaxKind.JsxFragment;
257
+ const braced = single && inner.startsWith('{') && inner.endsWith('}') && !inJsxChild;
258
+ el.replaceWithText(
259
+ inner === '' ? 'null' : braced ? inner.slice(1, -1).trim() : single ? inner : `<>${inner}</>`,
260
+ );
261
+ }
262
+ for (const el of [...sf.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement)].reverse()) {
263
+ if (tags.has(rootTagName(el.getTagNameNode().getText()))) el.replaceWithText('null');
264
+ }
265
+ }
266
+
267
+ function unwrappableTags(sf, isMotuSpec, motuOnly, resolveSpec) {
268
+ const names = new Set();
269
+ for (const imp of sf.getImportDeclarations()) {
270
+ const spec = imp.getModuleSpecifierValue();
271
+ const target = resolveSpec(sf, spec);
272
+ if (!isMotuSpec(spec) && !(target && motuOnly.has(target))) continue;
273
+ // A UI KIT IS NOT A SEAM, so its tags are not unwrappable.
274
+ //
275
+ // Unwrapping means "keep the children, drop the wrapper" — right for `<X.Island slot="y">`, which
276
+ // WRAPS host content, and wrong for `<Panel>` or `<PanelHead title={…} sub={<span/>}>`, which IS
277
+ // the content. Unwrapping those threw their attributes away and could leave invalid JSX behind,
278
+ // so the check reported TS1005 (a syntax error it had caused) instead of TS2304 (the true answer:
279
+ // this name no longer exists). The import is still removed below, which is what makes the honest
280
+ // diagnostic appear.
281
+ //
282
+ // It also states something real: an application that builds its UI out of `@motujs/chrome/react` is
283
+ // load-bearing on motu for its APPEARANCE, not just its wiring. That is a legitimate thing to
284
+ // adopt and it should show up here as exactly what it is.
285
+ if (CHROME_KIT.test(spec)) continue;
286
+ for (const named of imp.getNamedImports()) names.add(named.getNameNode().getText());
287
+ const def = imp.getDefaultImport();
288
+ if (def) names.add(def.getText());
289
+ }
290
+ return names;
291
+ }
292
+
293
+ /** motu's design kit: motu's own code, but presentation rather than a boundary. */
294
+ const CHROME_KIT = /^@motujs\/chrome(\/|$)/;
295
+
296
+ /** `Actions.Island` -> `Actions`: what a qualified JSX tag was imported as. */
297
+ function rootTagName(tag) {
298
+ return tag.split('.')[0];
299
+ }
300
+
301
+ /** Framework/runtime imports a composition root legitimately needs; they say nothing about ownership. */
302
+ const INFRA = /^react$|^react-dom|^next\//;
303
+
304
+ /**
305
+ * Which files exist ONLY because motu does — computed to a fixpoint.
306
+ *
307
+ * Two things the first version got wrong, both found by running it:
308
+ * 1. An import can reach motu WITHOUT a motu specifier: acme's composition root imports
309
+ * `@/motu/src/services`, an app-alias path INTO the motu project directory. Anything importing
310
+ * from that directory is motu's.
311
+ * 2. It is TRANSITIVE. The dev page's only import is the composition root — no motu specifier at
312
+ * all — so deleting the root strands it. A file whose imports are all motu-only is motu-only.
313
+ */
314
+ function motuOnlySet(graph, hostRoot, motuDirs) {
315
+ // One or several directories motu owns outright (see the caller: a single `motu/` tree when it has
316
+ // one, otherwise the declared islands/archipelagos/ui/shared/lagoon dirs).
317
+ const dirs = (Array.isArray(motuDirs) ? motuDirs : [motuDirs]).filter(Boolean);
318
+ const escape = (d) => d.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
319
+ const insideMotu = dirs.length
320
+ ? new RegExp(`(^|/)(${dirs.map(escape).join('|')})/`)
321
+ : /^(?!)/; // nothing declared: match nothing rather than guess a path that does not exist
322
+ const isMotuSpec = (spec) => !MOTU_TYPES_ONLY.test(spec) && (MOTU_SPECIFIER.test(spec) || insideMotu.test(spec));
323
+ // Resolution over the TEXT graph: the same candidates the surgery will load, without paying a TS
324
+ // parse for every file in the app to find out which ones matter.
325
+ const resolveFrom = (from, spec) => {
326
+ const base = spec.startsWith('@/') ? resolve(hostRoot, spec.slice(2)) : spec.startsWith('.') ? resolve(dirname(from), spec) : null;
327
+ if (!base) return null;
328
+ for (const ext of ['.tsx', '.ts', '/index.tsx', '/index.ts', '']) {
329
+ if (graph.has(base + ext)) return base + ext;
330
+ }
331
+ return null;
332
+ };
333
+ const resolveSpec = (sf, spec) => resolveFrom(typeof sf === 'string' ? sf : sf.getFilePath(), spec);
334
+
335
+ // A FILE INSIDE A MOTU-OWNED DIRECTORY IS MOTU'S, whatever it imports.
336
+ //
337
+ // The specifier test above can only recognise motu by how a module is SPELLED (`@motujs/…`, or a path
338
+ // through the motu directory), which works while the app reaches motu across a directory boundary
339
+ // and fails completely for a project whose motu lives in its own `src/`: there the composition root
340
+ // says `../islands/registry`, a relative specifier that contains no motu marker at all, and the
341
+ // fixpoint could never seed itself. And it cannot seed itself from the islands either — an island
342
+ // mount point imports the APPLICATION's component by design, which is exactly what `--from` is for,
343
+ // so no island is ever "100% motu" by import alone.
344
+ //
345
+ // The declared directories settle it: `islands/`, `archipelagos/`, `ui/`, `shared/` and the lagoon
346
+ // root are motu's by definition — they are what removal DELETES — so seed them directly and let the
347
+ // fixpoint extend outward from there.
348
+ const motuRoots = dirs.map((d) => resolve(hostRoot, d));
349
+ const insideMotuFile = (file) => motuRoots.some((r) => file === r || file.startsWith(r + '/'));
350
+
351
+ const set = new Set();
352
+ for (const p of graph.keys()) if (insideMotuFile(p)) set.add(p);
353
+ for (let changed = true; changed; ) {
354
+ changed = false;
355
+ for (const [p, imports] of graph) {
356
+ if (set.has(p)) continue;
357
+ if (!imports.length) continue;
358
+ const meaningful = imports.filter((spec) => !INFRA.test(spec));
359
+ if (!meaningful.length) continue;
360
+ const allMotu = meaningful.every((spec) => {
361
+ if (isMotuSpec(spec)) return true;
362
+ const target = resolveFrom(p, spec);
363
+ return target ? set.has(target) : false;
364
+ });
365
+ // At least one import must actually be motu's, or a file importing nothing but a deleted file
366
+ // would qualify for the wrong reason.
367
+ if (allMotu && meaningful.some((spec) => isMotuSpec(spec) || set.has(resolveFrom(p, spec) ?? ''))) {
368
+ set.add(p);
369
+ changed = true;
370
+ }
371
+ }
372
+ }
373
+ return { set, isMotuSpec, resolveSpec };
374
+ }
375
+
376
+ /**
377
+ * Put back what an INTERRUPTED previous run took, before this one can destroy the evidence.
378
+ *
379
+ * Idempotent and cheap: with no manifest there is nothing to do, which is every normal run. It is
380
+ * deliberately loud when it does fire — silently repairing a repository is how you learn nothing
381
+ * about the crash that made it necessary.
382
+ */
383
+ function recoverInterrupted(backupDir, manifestFile, hostRoot, quiet) {
384
+ if (!existsSync(manifestFile)) return;
385
+ let files = [];
386
+ try {
387
+ files = JSON.parse(readFileSync(manifestFile, 'utf8')).files ?? [];
388
+ } catch {
389
+ // An unreadable manifest still means a run was interrupted; restore everything the backup holds
390
+ // rather than nothing, since a partial repair is worse than a full one.
391
+ files = [];
392
+ }
393
+ const restored = [];
394
+ for (const rel of files) {
395
+ const src = resolve(backupDir, rel);
396
+ if (!existsSync(src)) continue;
397
+ const dest = resolve(hostRoot, rel);
398
+ mkdirSync(dirname(dest), { recursive: true });
399
+ cpSync(src, dest);
400
+ restored.push(rel);
401
+ }
402
+ rmSync(manifestFile, { force: true });
403
+ if (!quiet) {
404
+ console.log(
405
+ color.yellow('! removal-check') +
406
+ ` a previous run was INTERRUPTED mid-surgery — restored ${restored.length} file(s) before continuing`,
407
+ );
408
+ for (const rel of restored) console.log(color.dim(` restored ${rel}`));
409
+ }
410
+ }
411
+
412
+ /**
413
+ * The check as data, for `motu check` to aggregate. `quiet` suppresses the report; the surgery, the
414
+ * typecheck and the ALWAYS-restore are identical either way.
415
+ */
416
+ export function runRemovalCheck(argv, { quiet = false } = {}) {
417
+ const cfg = loadMotuConfig();
418
+ const hostRoot = cfg.hostRoot;
419
+ const backupDir = resolve(cfg.cacheDir, 'removal-check');
420
+ // The surgery's own crash log. Written after the backups are taken and before the first file is
421
+ // touched, so its mere EXISTENCE means "a previous run was interrupted between those two points".
422
+ const manifestFile = resolve(backupDir, 'interrupted.json');
423
+
424
+ // CRASH RECOVERY — BEFORE anything else, and in particular before the backup directory is cleared.
425
+ //
426
+ // The restore lives in a `finally`, which covers a throw and covers nothing else: a `finally` does
427
+ // not run on SIGTERM, SIGINT or SIGHUP. So `timeout`, Ctrl-C, a killed CI step or an OOM between
428
+ // the delete and the restore left the host repository stripped — composition roots gone, pages
429
+ // unwrapped — with no way back. The next run then made it PERMANENT, because it began by deleting
430
+ // the backup directory holding the only copy of the originals, and recomputed the surgery from a
431
+ // tree that was already missing files.
432
+ //
433
+ // Measured on peps: 13 `components/motu/*` deleted and six dashboard pages rewritten, recovered by
434
+ // hand from git. A check that proves motu is removable must not be the thing that removes it.
435
+ recoverInterrupted(backupDir, manifestFile, hostRoot, quiet);
436
+
437
+ // A PROJECT THAT NEVER CLAIMED REMOVABILITY. See `removable` in the config loader for why motu's
438
+ // own tools cannot answer this question. Said out loud rather than passed over, and reported as a
439
+ // SKIP: opting out proves nothing, and the verdict must not read like proof.
440
+ if (!cfg.removable) {
441
+ if (!quiet) {
442
+ console.log(
443
+ color.dim('– removal-check') +
444
+ color.dim(' this project declares `removable: false` — motu is meant to be load-bearing here, ' +
445
+ 'so "does it compile without motu" is not a question about its integration'),
446
+ );
447
+ }
448
+ return { pass: true, skipped: true, scanned: 0, deleted: [], stripped: [], ejected: [], errors: [] };
449
+ }
450
+
451
+ // The app's own sources — never motu's own tree, which is what is being removed.
452
+ // THE FRAMEWORK CHECKOUT, not the project root.
453
+ //
454
+ // This passed `cfg.root` — the directory holding motu.config.json — into a parameter named
455
+ // `motuRoot` and used as "skip motu's own source". Every project keeps its sources under its own
456
+ // root, so the exclusion swallowed the entire application and the walk returned nothing, for every
457
+ // project, always. That is the real reason removal-check kept reporting "scanned 0 files"; the
458
+ // roots guess above it was fixed twice for a symptom this was causing.
459
+ const graph = importGraph(hostRoot, cfg.motuRoot, cfg);
460
+ // WHEN MOTU HAS NO SUBDIRECTORY OF ITS OWN, do not invent one.
461
+ //
462
+ // `relative(hostRoot, cfg.root) || 'motu'` guessed a directory named `motu/` whenever motu was
463
+ // initialised INTO the host app root — which is exactly what `motu init . --host next` produces and
464
+ // therefore the common Next layout. No such directory exists there, so `insideMotu` matched nothing
465
+ // and every motu-generated file under `src/` read as application code. The visible symptom was a
466
+ // composition root reported as "imports the application" while the very imports it named were
467
+ // listed as motu-deletable two lines above in the same report.
468
+ //
469
+ // The declared directories are the answer and were available all along: `islands`, `archipelagos`,
470
+ // `ui`, `shared` and the lagoon root are motu's by definition, whether or not they sit under a
471
+ // shared parent.
472
+ const motuDir = relative(hostRoot, cfg.root);
473
+ //
474
+ // THE PROPERTY NAMES ARE `…Dir`, and getting that wrong made this worse than the bug it replaced.
475
+ // `loadMotuConfig()` exposes ABSOLUTE `islandsDir`/`archipelagosDir`/`uiDir`/`sharedDir`/`lagoonDir`;
476
+ // reading `cfg.islands` & co. gave `undefined` for all five, so the list was always empty, the
477
+ // regex became match-nothing, and NO project's composition root could be recognised as motu's own.
478
+ // The old `|| 'motu'` guessed a directory that did not exist; this guessed nothing at all, for
479
+ // everyone. Found by a cold-start agent that probed `loadMotuConfig()`'s actual return value.
480
+ const motuDirs = motuDir
481
+ ? [motuDir]
482
+ : [cfg.islandsDir, cfg.archipelagosDir, cfg.uiDir, cfg.sharedDir, cfg.lagoonDir]
483
+ .filter(Boolean)
484
+ .map((d) => relative(hostRoot, d))
485
+ .filter((d) => d && !d.startsWith('..'));
486
+ const { set: motuOnly, isMotuSpec, resolveSpec } = motuOnlySet(graph, hostRoot, motuDirs);
487
+
488
+ // WHY A COMPOSITION ROOT DID NOT QUALIFY, computed before anything is rewritten.
489
+ //
490
+ // The rule is simple and its consequence is not: a file is deleted whole only if EVERY import it
491
+ // makes is motu's. One application import — `signIn` from the app's auth client, say — and the file
492
+ // is stripped instead, which leaves `createRegion(...)` behind with its imports gone and reports as
493
+ // "the host does not compile without motu" pointing at a line that looks fine. The cause is an
494
+ // import three lines above, and nothing said so; finding it meant reading motu's source.
495
+ //
496
+ // So: any file that composes a region and did NOT qualify gets its disqualifying imports named.
497
+ const disqualified = [];
498
+ for (const [p, specs] of graph) {
499
+ if (motuOnly.has(p)) continue;
500
+ let text = '';
501
+ try {
502
+ text = readFileSync(p, 'utf8');
503
+ } catch {
504
+ continue;
505
+ }
506
+ // COMMENTS BLANKED FIRST, the same rule every other text match here follows: a file that merely
507
+ // MENTIONS `createRegion` in a comment does not compose a region. acme's `mission-helpers.tsx`
508
+ // explains in prose which module holds its region binding, and was reported as an undeletable
509
+ // composition root for saying so — an error whose advice was to restructure a file that was
510
+ // already correct.
511
+ if (!/\bcreateRegion\s*\(/.test(blankComments(text))) continue;
512
+ const offenders = specs.filter((spec) => {
513
+ if (INFRA.test(spec) || isMotuSpec(spec)) return false;
514
+ const target = resolveSpec(p, spec);
515
+ return !(target && motuOnly.has(target));
516
+ });
517
+ if (offenders.length) disqualified.push([p, offenders]);
518
+ }
519
+
520
+ // Only the files the surgery can touch get parsed: what motu owns outright, and what mentions it.
521
+ // A FILE THAT IMPORTS A MOTU-ONLY FILE IS A CANDIDATE, even when the specifier says nothing.
522
+ //
523
+ // This selected files by how their imports are SPELLED (`@motujs/…`), which misses the page that
524
+ // imports its own composition root by a relative path — `./manage-servers-binding`. That page was
525
+ // never loaded into the rewrite project, so its `<X.Island>` tags were never unwrapped and its
526
+ // import of the now-deleted binding was left dangling: removal then "failed" with
527
+ // `Cannot find module './manage-servers-binding'`, an error pointing at the one file the surgery
528
+ // had refused to touch. Same specifier-form-versus-resolution mistake as the allowlist above, one
529
+ // layer up — and the page is precisely where a relative import is the normal spelling.
530
+ const candidates = [...graph]
531
+ .filter(([p, specs]) => motuOnly.has(p) || specs.some((spec) => isMotuSpec(spec) || motuOnly.has(resolveSpec(p, spec) ?? '')))
532
+ .map(([p]) => p);
533
+ const project = new Project({
534
+ skipAddingFilesFromTsConfig: true,
535
+ skipFileDependencyResolution: true,
536
+ compilerOptions: { allowJs: true, jsx: 4 },
537
+ });
538
+ for (const p of candidates) project.addSourceFileAtPath(p);
539
+
540
+ // The wiring the archipelagos hold, read from their configs and the islands' declared outputs.
541
+ const regions = readRegions(paths.archipelagosDir);
542
+ const outputs = readOutputs(listIslands(paths.islandsDir).map((i) => i.element), paths.islandsDir);
543
+
544
+ const deleted = [];
545
+ const stripped = [];
546
+ const touched = [];
547
+ /** Files the surgery could not rewrite — reported, never thrown. */
548
+ const surgeryErrors = [];
549
+ const ejected = [];
550
+
551
+ for (const sf of project.getSourceFiles()) {
552
+ const p = sf.getFilePath();
553
+ if (motuOnly.has(p)) {
554
+ touched.push(p);
555
+ deleted.push(p);
556
+ continue;
557
+ }
558
+ // Wrapped, because this rewrites real source: a shape the surgery cannot handle must come back as
559
+ // "the check could not answer", never as a crash that hides every other result in the run.
560
+ try {
561
+ const tags = unwrappableTags(sf, isMotuSpec, motuOnly, resolveSpec);
562
+ // RESOLVED, not just spelled — the last instance of a mistake this file made three times. Named
563
+ // imports are already covered (`unwrappableTags` resolves, so `tags` would not be empty), but a
564
+ // SIDE-EFFECT import of a motu-only module by relative path has no named import to catch it, and
565
+ // spelling alone would skip the file and leave the import dangling after its target is deleted.
566
+ const reachesMotu = sf
567
+ .getImportDeclarations()
568
+ .some((i) => isMotuSpec(i.getModuleSpecifierValue()) || motuOnly.has(resolveSpec(sf, i.getModuleSpecifierValue()) ?? ''));
569
+ if (!tags.size && !reachesMotu) continue;
570
+ touched.push(p);
571
+ // EJECT FIRST, while `<Island slot="…">` is still there to say which element produces what: region
572
+ // reads and seeds become plain state, and the producer's output prop is wired to its setters.
573
+ const notes = ejectFile(sf, regions, outputs);
574
+ if (notes.length) ejected.push([p, notes]);
575
+ // A ROOT REGION IS REWRITTEN, NOT UNWRAPPED. `<Directory.Root …/>` is self-closing, so the
576
+ // generic pass below would replace it with `null` and take the whole page with it — which is
577
+ // exactly what it did, and the removal proof failed on a page that was perfectly fine.
578
+ //
579
+ // What it becomes is the application's own component with the same props: the island props
580
+ // already hold the page's own JSX, and a host-slot prop holds that component's props, so it
581
+ // grows back into an element. Both imports are added; motu's go with the rest.
582
+ rewriteMotuJsx(sf, regions, tags, resolveSpec);
583
+ for (const imp of sf.getImportDeclarations()) {
584
+ const spec = imp.getModuleSpecifierValue();
585
+ const target = resolveSpec(sf, spec);
586
+ if (isMotuSpec(spec) || (target && motuOnly.has(target))) imp.remove();
587
+ }
588
+ } catch (err) {
589
+ if (process.env.MOTU_DEBUG_SURGERY) {
590
+ console.error('\n=== SURGERY THREW in', relative(hostRoot, p), '===');
591
+ console.error(err?.stack || String(err));
592
+ }
593
+ surgeryErrors.push(`${relative(hostRoot, p)}: ${String(err?.message || err).split('\n')[0]}`);
594
+ }
595
+ stripped.push(p);
596
+ }
597
+
598
+ if (!touched.length) {
599
+ // TWO DIFFERENT ANSWERS, and printing them the same way is what let this command report success
600
+ // over a fully integrated app: it scanned Next's directories, found none of Twenty's `src/`, and
601
+ // called the emptiness a clean bill of health.
602
+ //
603
+ // scanned files, found no motu -> genuinely nothing to remove
604
+ // scanned NOTHING -> the check did not run, and must not read as a pass
605
+ const scanned = graph.size;
606
+ if (scanned === 0) {
607
+ if (!quiet) {
608
+ console.log(
609
+ color.yellow('– removal-check') +
610
+ color.dim(
611
+ ` scanned 0 files under ${paths.rel(hostRoot)} ` +
612
+ `(${describeSources(graph, paths.rel)}) — ` +
613
+ `nothing was examined, so nothing is proved. ` +
614
+ `Set \`hostSources\` in motu.config.json to the host's own source directories.`,
615
+ ),
616
+ );
617
+ }
618
+ return {
619
+ pass: false,
620
+ scanned: 0,
621
+ deleted: [],
622
+ stripped: [],
623
+ ejected: [],
624
+ errors: [`removal-check examined no files under ${paths.rel(hostRoot)}`],
625
+ };
626
+ }
627
+ if (!quiet) {
628
+ console.log(
629
+ color.green('✓ removal-check') +
630
+ color.dim(` no motu references in the host application · ${scanned} host file(s) scanned`),
631
+ );
632
+ }
633
+ return { pass: true, scanned, deleted: [], stripped: [], ejected: [], errors: [] };
634
+ }
635
+
636
+ // --- apply, typecheck, ALWAYS restore -----------------------------------------------------------
637
+ rmSync(backupDir, { recursive: true, force: true });
638
+ mkdirSync(backupDir, { recursive: true });
639
+ const backup = (p) => {
640
+ const dest = resolve(backupDir, relative(hostRoot, p));
641
+ mkdirSync(dirname(dest), { recursive: true });
642
+ cpSync(p, dest);
643
+ };
644
+ // Same inputs as the last PASS? Then the answer is the same answer, and a full application typecheck
645
+ // is four seconds spent re-deriving it. `--force` re-proves it anyway.
646
+ const proofFile = resolve(cfg.cacheDir, 'removal-check.proof.json');
647
+ const fingerprint = proofFingerprint(
648
+ [...deleted, ...stripped],
649
+ [
650
+ ...listIslands(paths.islandsDir).map((i) => i.element),
651
+ // The archipelagos: what eject generates comes from their `writes`, so a change there changes
652
+ // the surgery even when no host file moved.
653
+ ...(existsSync(paths.archipelagosDir)
654
+ ? readdirSync(paths.archipelagosDir, { withFileTypes: true })
655
+ .filter((e) => e.isDirectory())
656
+ .map((e) => paths.archipelagoFile(e.name))
657
+ .filter((f) => existsSync(f))
658
+ : []),
659
+ ],
660
+ );
661
+ if (!argv?.force && existsSync(proofFile)) {
662
+ try {
663
+ const proof = JSON.parse(readFileSync(proofFile, 'utf8'));
664
+ if (proof.pass && proof.fingerprint === fingerprint) {
665
+ if (!quiet) {
666
+ console.log(
667
+ color.green('✓ removal-check') +
668
+ color.dim(` unchanged since the last proof — ${deleted.length} deleted, ${stripped.length} unwrapped (--force to re-prove)`),
669
+ );
670
+ }
671
+ return {
672
+ pass: true,
673
+ cached: true,
674
+ deleted: proof.deleted ?? [],
675
+ stripped: proof.stripped ?? [],
676
+ ejected: proof.ejected ?? [],
677
+ errors: [],
678
+ };
679
+ }
680
+ } catch {
681
+ /* an unreadable proof is no proof */
682
+ }
683
+ }
684
+
685
+ let result;
686
+ const surgical = [...deleted, ...stripped];
687
+ // ONE restore, reachable from three places: the `finally`, a signal, and an uncaught throw. Guarded
688
+ // so the common path (finally, after a clean run) does not redo what a signal handler already did.
689
+ let restored = false;
690
+ const restoreAll = () => {
691
+ if (restored) return;
692
+ restored = true;
693
+ for (const p of surgical) {
694
+ const src = resolve(backupDir, relative(hostRoot, p));
695
+ if (existsSync(src)) cpSync(src, p);
696
+ }
697
+ // The surgery is undone, so the crash log must not outlive it — otherwise the NEXT run reports an
698
+ // interruption that has already been repaired.
699
+ rmSync(manifestFile, { force: true });
700
+ };
701
+ // `finally` does not run on a signal, and this surgery deletes application files. Without these the
702
+ // host repository stays stripped after a `timeout`, a Ctrl-C or a killed CI step — see the recovery
703
+ // note at the top of this function. Exit codes are the conventional 128+n so callers still see a
704
+ // signal death rather than a clean exit.
705
+ const onSignal = (sig, code) => () => {
706
+ restoreAll();
707
+ if (!quiet) console.log(color.yellow(`\n! removal-check ${sig} — restored ${surgical.length} file(s) before exiting`));
708
+ process.exit(code);
709
+ };
710
+ const handlers = [
711
+ ['SIGINT', onSignal('SIGINT', 130)],
712
+ ['SIGTERM', onSignal('SIGTERM', 143)],
713
+ ['SIGHUP', onSignal('SIGHUP', 129)],
714
+ ];
715
+ for (const [sig, fn] of handlers) process.on(sig, fn);
716
+ // Last resort for a throw that escapes the try (a handler above it, an async boundary): `exit` fires
717
+ // for every non-signal termination, and restoreAll is idempotent so a double call costs nothing.
718
+ const onExit = () => restoreAll();
719
+ process.on('exit', onExit);
720
+ try {
721
+ for (const p of surgical) backup(p);
722
+ // AFTER the backups, BEFORE the first mutation — that ordering is what makes the manifest mean
723
+ // "everything listed here has a copy in the backup directory".
724
+ writeFileSync(manifestFile, JSON.stringify({ files: surgical.map((p) => relative(hostRoot, p)) }, null, 1));
725
+ for (const p of deleted) rmSync(p);
726
+ for (const p of stripped) writeFileSync(p, project.getSourceFile(p).getFullText());
727
+
728
+ result = hostTypecheck(hostRoot);
729
+ } finally {
730
+ restoreAll();
731
+ for (const [sig, fn] of handlers) process.off(sig, fn);
732
+ process.off('exit', onExit);
733
+ }
734
+
735
+ if (!quiet) {
736
+ console.log(color.bold('\nmotu removal-check — is motu removable from the host application?\n'));
737
+ for (const p of deleted) console.log(` ${color.dim('delete ')} ${relative(hostRoot, p)} ${color.dim('(100% motu)')}`);
738
+ for (const p of stripped) console.log(` ${color.dim('unwrap ')} ${relative(hostRoot, p)}`);
739
+ for (const [p, notes] of ejected) {
740
+ console.log(` ${color.dim('eject ')} ${relative(hostRoot, p)}`);
741
+ for (const n of notes) console.log(` ${color.dim(n)}`);
742
+ }
743
+ console.log('');
744
+ }
745
+
746
+ const out = ((result?.stdout ?? '') + (result?.stderr ?? '')).trim();
747
+
748
+ // Deleting a route leaves the framework's GENERATED route types pointing at it until the next build.
749
+ // Those are build artifacts, not application code, so they are not evidence that motu is
750
+ // load-bearing — but they are reported rather than dropped, because a check that silently discards
751
+ // errors is how a green result stops meaning anything.
752
+ const lines = out ? out.split('\n') : [];
753
+ const generated = lines.filter((l) => /^\.?next[\/\\]|^\.next[\/\\]/.test(l.trim()));
754
+ let real = lines.filter((l) => l.trim() && !generated.includes(l));
755
+
756
+ // PRE-EXISTING errors are not evidence that motu is load-bearing.
757
+ //
758
+ // The check asks one question — does removing motu BREAK the host — and a host that did not
759
+ // typecheck to begin with still answers it. Real applications fail this the moment you try:
760
+ // Twenty's `tsc` reports 4 errors at a clean checkout because a package's generated file needs a
761
+ // build step that does not run here. Demanding zero would make every such app look like motu had
762
+ // welded itself in, which is the exact opposite of what the check exists to prove.
763
+ //
764
+ // The baseline runs only when something failed, on the RESTORED tree (the `finally` above put every
765
+ // file back). So the common case — a clean removal — still pays for exactly one typecheck.
766
+ let preExisting = [];
767
+ if (real.length) {
768
+ const before = hostTypecheck(hostRoot);
769
+ const beforeOut = ((before?.stdout ?? '') + (before?.stderr ?? '')).trim();
770
+ const baseline = new Set(beforeOut ? beforeOut.split('\n').map((l) => l.trim()).filter(Boolean) : []);
771
+ preExisting = real.filter((l) => baseline.has(l.trim()));
772
+ real = real.filter((l) => !baseline.has(l.trim()));
773
+ }
774
+
775
+ const summary = {
776
+ scanned: graph.size,
777
+ // A file the surgery could not rewrite is an UNANSWERED question, not a pass.
778
+ pass: (result?.status === 0 || real.length === 0) && surgeryErrors.length === 0,
779
+ fingerprint,
780
+ deleted: deleted.map((p) => relative(hostRoot, p)),
781
+ stripped: stripped.map((p) => relative(hostRoot, p)),
782
+ ejected: ejected.map(([p, notes]) => ({ file: relative(hostRoot, p), notes })),
783
+ errors: [...surgeryErrors, ...real].slice(0, 15),
784
+ surgeryErrors,
785
+ disqualified: disqualified.map(([p, specs]) => ({ file: relative(hostRoot, p), imports: specs })),
786
+ deletedModules: deleted.map((p) => relative(hostRoot, p)),
787
+ preExisting: preExisting.length,
788
+ };
789
+ // Remember it, so an unchanged repo does not pay for the same proof twice.
790
+ try {
791
+ mkdirSync(cfg.cacheDir, { recursive: true });
792
+ writeFileSync(
793
+ proofFile,
794
+ JSON.stringify(
795
+ { pass: summary.pass, fingerprint, deleted: summary.deleted, stripped: summary.stripped, ejected: summary.ejected },
796
+ null,
797
+ 2,
798
+ ),
799
+ );
800
+ } catch {
801
+ /* a cache that cannot be written just means the next run re-proves it */
802
+ }
803
+
804
+ if (quiet) return summary;
805
+
806
+ if (summary.pass) {
807
+ console.log(
808
+ color.green(color.bold('PASS')) +
809
+ color.dim(` the host typechecks with motu removed · ${summary.scanned} host file(s) scanned, ` +
810
+ `${summary.deleted.length} deleted, ${summary.stripped.length} unwrapped`) +
811
+ (summary.preExisting ? color.dim(` · ${summary.preExisting} pre-existing error(s) in the host, unchanged by the removal`) : ''),
812
+ );
813
+ if (generated.length) {
814
+ console.log(color.dim(` (${generated.length} stale generated route-type error(s) ignored — build artifacts of the deleted route)`));
815
+ }
816
+ return summary;
817
+ }
818
+ console.log(color.red(color.bold('FAIL')) + ' the host does NOT compile without motu:');
819
+
820
+ // A FILE THE SURGERY COULD NOT REWRITE IS AN UNANSWERED QUESTION, not a verdict about the app — and
821
+ // it used to be invisible here: these went into `summary.errors` for `motu check` to aggregate and
822
+ // were never printed by this command, so the same failure reported differently depending on which
823
+ // entry point you ran. Printed FIRST, because every TypeScript error below may be its consequence:
824
+ // a file that threw mid-surgery keeps the imports the surgery was about to remove.
825
+ if (surgeryErrors.length) {
826
+ console.log(color.yellow('\n motu could not rewrite these files, so removal was never actually tried on them:'));
827
+ for (const e of surgeryErrors) console.log(` ${e}`);
828
+ console.log(
829
+ color.dim(' This is an UNANSWERED question, not a verdict about the app: the file was left as it was,\n') +
830
+ color.dim(' so any error below naming it — a dangling import above all — is a consequence of this and\n') +
831
+ color.dim(' not a finding of its own. It means the rewriter met a JSX shape it cannot express: what a\n') +
832
+ color.dim(' wrapper leaves behind has to be ONE expression, and where it stood decides whether that is\n') +
833
+ color.dim(' `{x}` or `x`. Reduce the wrapper\'s children to a single element or a single `{…}` (lift a\n') +
834
+ color.dim(' branch into a named value if it helps) and run again — and please report the shape, because\n') +
835
+ color.dim(' a shape the surgery cannot rewrite is motu\'s bug before it is yours.'),
836
+ );
837
+ }
838
+
839
+ if (real.length) {
840
+ console.log(color.dim('\n what the host said with motu removed:'));
841
+ console.log(real.slice(0, 15).join('\n'));
842
+ }
843
+
844
+ // A DANGLING IMPORT OF A DELETED FILE has one cause and it is never the line it points at.
845
+ const danglers = real.filter((l) => summary.deletedModules.some((m) => l.includes(m.replace(/\.tsx?$/, ''))));
846
+ if (danglers.length) {
847
+ console.log(
848
+ color.dim('\n Those name a file motu DELETED (it was 100% motu). The import should have gone with it,\n') +
849
+ color.dim(' so this means the surgery on the importing file did not finish — see above.'),
850
+ );
851
+ }
852
+
853
+ // THE OTHER HALF, and the one that cost the most to find by hand.
854
+ if (disqualified.length) {
855
+ console.log(color.yellow('\n these compose a region but are NOT deletable, because they import the application:'));
856
+ for (const [p, specs] of disqualified) {
857
+ console.log(` ${relative(hostRoot, p)}`);
858
+ for (const spec of specs) console.log(color.dim(` ${spec}`));
859
+ }
860
+ console.log(
861
+ color.dim(' A composition root is deleted WHOLE only when every import it makes is motu\'s. One\n') +
862
+ color.dim(' application import and it is stripped instead, leaving `createRegion(...)` with no imports.\n') +
863
+ color.dim(' Move what needs the application — a source\'s port, a service, a browser API — into an app\n') +
864
+ color.dim(' file that renders the real components inside `<R.Island slot="…">`, and keep this file to\n') +
865
+ color.dim(' the wiring motu owns.'),
866
+ );
867
+ }
868
+
869
+ console.log(color.dim('\n motu is load-bearing in the app. Either the file is 100% motu (deletable whole),'));
870
+ console.log(color.dim(' or the wrapper must leave valid JSX behind when removed (C2).'));
871
+ return summary;
872
+ }
873
+
874
+ /** `motu removal-check` — the report, and the exit code CI reads. */
875
+ export async function removalCheckCommand(argv) {
876
+ const summary = await runRemovalCheck(argv);
877
+ process.exit(summary.pass ? 0 : 1);
878
+ }