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