@motujs/cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/bundled/github/agents/archipelago-build.agent.md +298 -0
- package/bundled/github/agents/island-create.agent.md +261 -0
- package/bundled/github/agents/island-extract.agent.md +360 -0
- package/bundled/github/agents/island-locate.agent.md +208 -0
- package/bundled/github/agents/motu-checks.agent.md +158 -0
- package/bundled/github/agents/motu-evidence.agent.md +267 -0
- package/bundled/github/agents/motu-regions.agent.md +304 -0
- package/bundled/github/host-rules.md +43 -0
- package/bundled/github/prompts/archipelago-build.prompt.md +19 -0
- package/bundled/github/prompts/island-create.prompt.md +37 -0
- package/bundled/github/prompts/island-extract.prompt.md +59 -0
- package/bundled/github/prompts/island-locate.prompt.md +43 -0
- package/bundled/packages/adapters/angularjs/vite.mjs +43 -0
- package/bundled/packages/adapters/next/lagoon/next-stubs.tsx +108 -0
- package/bundled/packages/adapters/next/vite.mjs +121 -0
- package/bundled/packages/adapters/vite/vite.mjs +179 -0
- package/package.json +64 -3
- package/src/alias-loader.mjs +63 -0
- package/src/alias-register.mjs +28 -0
- package/src/asset-stub.cjs +3 -0
- package/src/asset-stub.mjs +5 -0
- package/src/cli.mjs +78 -0
- package/src/commands/adopt-root.mjs +194 -0
- package/src/commands/archipelago-init.mjs +314 -0
- package/src/commands/archipelago.mjs +138 -0
- package/src/commands/assurance.mjs +40 -0
- package/src/commands/check.mjs +346 -0
- package/src/commands/codegen.mjs +29 -0
- package/src/commands/coverage.mjs +358 -0
- package/src/commands/create.mjs +277 -0
- package/src/commands/defaults.mjs +129 -0
- package/src/commands/diff.mjs +595 -0
- package/src/commands/fixtures.mjs +153 -0
- package/src/commands/init.mjs +473 -0
- package/src/commands/integrate.mjs +208 -0
- package/src/commands/integration.mjs +1349 -0
- package/src/commands/lagoon-try.mjs +272 -0
- package/src/commands/lagoon.mjs +1419 -0
- package/src/commands/link.mjs +110 -0
- package/src/commands/region-coverage.mjs +604 -0
- package/src/commands/removal-check.mjs +878 -0
- package/src/commands/skills.mjs +156 -0
- package/src/commands/snapshot.mjs +456 -0
- package/src/commands/sync.mjs +38 -0
- package/src/commands/verify/island-runtime.mjs +581 -0
- package/src/commands/verify/island-static.mjs +586 -0
- package/src/commands/verify/island.mjs +135 -0
- package/src/commands/verify/reach.mjs +426 -0
- package/src/commands/verify/region-composition.mjs +391 -0
- package/src/commands/verify/region-config.mjs +330 -0
- package/src/commands/verify/region-flows.mjs +760 -0
- package/src/commands/verify/region-sources.mjs +516 -0
- package/src/commands/verify/region.mjs +271 -0
- package/src/commands/verify/report.mjs +338 -0
- package/src/commands/verify/screens-runtime.mjs +123 -0
- package/src/commands/verify.mjs +18 -0
- package/src/lagoon-build.mjs +25 -0
- package/src/lagoon-dev.mjs +32 -0
- package/src/lib/alias-core.mjs +49 -0
- package/src/lib/archipelagos.mjs +111 -0
- package/src/lib/assurance.mjs +144 -0
- package/src/lib/baselines.mjs +107 -0
- package/src/lib/bundlability.mjs +123 -0
- package/src/lib/changed.mjs +157 -0
- package/src/lib/component-props.mjs +145 -0
- package/src/lib/config.mjs +451 -0
- package/src/lib/contracts.mjs +244 -0
- package/src/lib/data-flow.mjs +47 -0
- package/src/lib/declared-sources.mjs +101 -0
- package/src/lib/dock-inject.mjs +47 -0
- package/src/lib/effects.mjs +97 -0
- package/src/lib/eject.mjs +715 -0
- package/src/lib/evidence-json.mjs +74 -0
- package/src/lib/expect-calls.mjs +53 -0
- package/src/lib/finding-memory.mjs +0 -0
- package/src/lib/flow-calls.mjs +53 -0
- package/src/lib/framework.mjs +98 -0
- package/src/lib/host-rules.mjs +55 -0
- package/src/lib/host-sources.mjs +121 -0
- package/src/lib/island-placement.mjs +64 -0
- package/src/lib/island-render.mjs +105 -0
- package/src/lib/islands.mjs +112 -0
- package/src/lib/lagoon-bundle.mjs +138 -0
- package/src/lib/lagoon-declares.mjs +553 -0
- package/src/lib/lagoon-materialize.mjs +217 -0
- package/src/lib/lagoon-vite.mjs +788 -0
- package/src/lib/live-url.mjs +53 -0
- package/src/lib/node-aliases.mjs +73 -0
- package/src/lib/operations.mjs +145 -0
- package/src/lib/provenance-plugin.mjs +74 -0
- package/src/lib/region-refs.mjs +40 -0
- package/src/lib/remote.mjs +171 -0
- package/src/lib/scaffold.mjs +606 -0
- package/src/lib/screens.mjs +266 -0
- package/src/lib/snapshots.mjs +85 -0
- package/src/lib/stubs.mjs +114 -0
- package/src/lib/ts-project.mjs +42 -0
- package/src/lib/util.mjs +626 -0
- package/src/lib/worlds.mjs +236 -0
- package/src/lib/write-if-changed.mjs +35 -0
- package/src/lib/writes-coverage.mjs +32 -0
- package/src/playwright-lagoon.mjs +2305 -0
- package/src/run.mjs +385 -0
- package/src/runtime-harness.mjs +225 -0
- package/README.md +0 -3
|
@@ -0,0 +1,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
|
+
}
|