@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
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
// WHAT A REGION'S LAGOON OVERRIDES DECLARE, read by PARSING rather than by matching.
|
|
2
|
+
//
|
|
3
|
+
// One question so far: does this region declare a `page` — the application's own page module — so
|
|
4
|
+
// that `?view=page` is a real address for it and `motu lagoon states` can print it?
|
|
5
|
+
//
|
|
6
|
+
// PARSED, NOT MATCHED, and the difference is not stylistic. The neighbouring resolver in `verify.mjs`
|
|
7
|
+
// finds a region's frame with a regex, and every shape it has had to learn since — a quoted key, a
|
|
8
|
+
// one-line object, an inline arrow, the kind-first spelling — arrived as a bug where the check
|
|
9
|
+
// silently found nothing and reported the region as declaring nothing. A property lookup on the
|
|
10
|
+
// object literal cannot have that failure mode: either the file parses and the answer is true or
|
|
11
|
+
// false, or it does not parse and this says so.
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { dirname, resolve } from 'node:path';
|
|
14
|
+
import { Project, SyntaxKind } from 'ts-morph';
|
|
15
|
+
import { paths } from './util.mjs';
|
|
16
|
+
|
|
17
|
+
/** The `regions: { … }` object literal in the lagoon overrides module, or null. */
|
|
18
|
+
function regionsLiteral(sourceFile) {
|
|
19
|
+
for (const decl of sourceFile.getVariableDeclarations()) {
|
|
20
|
+
if (decl.getName() !== 'regions') continue;
|
|
21
|
+
const init = decl.getInitializer();
|
|
22
|
+
const obj = init?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
23
|
+
if (obj) return obj;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Does the lagoon declare a `page` for this region?
|
|
30
|
+
*
|
|
31
|
+
* Returns `false` when the overrides module is absent or does not parse — the caller is deciding
|
|
32
|
+
* whether to advertise an address, and advertising one that turns out not to exist is the failure
|
|
33
|
+
* worth avoiding. A caller that needs to distinguish "no" from "could not tell" should parse itself.
|
|
34
|
+
*/
|
|
35
|
+
export function regionDeclaresPage(id) {
|
|
36
|
+
for (const name of ['src/lagoon.tsx', 'src/lagoon.ts']) {
|
|
37
|
+
const file = resolve(paths.lagoonDir, name);
|
|
38
|
+
if (!existsSync(file)) continue;
|
|
39
|
+
try {
|
|
40
|
+
// Created per call and thrown away: this runs once per region in a CLI command, and holding a
|
|
41
|
+
// project open would tie the answer to a cache nothing here invalidates.
|
|
42
|
+
const project = new Project({ useInMemoryFileSystem: true, skipAddingFilesFromTsConfig: true });
|
|
43
|
+
const sf = project.createSourceFile('lagoon.tsx', readFileSync(file, 'utf8'));
|
|
44
|
+
const regions = regionsLiteral(sf);
|
|
45
|
+
if (!regions) continue;
|
|
46
|
+
for (const prop of regions.getProperties()) {
|
|
47
|
+
const assignment = prop.asKind(SyntaxKind.PropertyAssignment);
|
|
48
|
+
if (!assignment) continue;
|
|
49
|
+
// `'manage-servers'` and `manageServers` are both legal keys; compare the TEXT either way.
|
|
50
|
+
const key = assignment.getName().replace(/^['"`]|['"`]$/g, '');
|
|
51
|
+
if (key !== id) continue;
|
|
52
|
+
const body = assignment.getInitializer()?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
53
|
+
if (!body) return false;
|
|
54
|
+
return body.getProperties().some((p) => {
|
|
55
|
+
const a = p.asKind(SyntaxKind.PropertyAssignment) ?? p.asKind(SyntaxKind.ShorthandPropertyAssignment);
|
|
56
|
+
return a ? a.getName().replace(/^['"`]|['"`]$/g, '') === 'page' : false;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// A syntax this project's own build accepts and ts-morph does not: answer "no address" rather
|
|
61
|
+
// than crash a command whose job is to list what can be opened.
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// THE ARRAY FORM, which is the other half of how a lagoon declares its regions.
|
|
66
|
+
//
|
|
67
|
+
// `regions = [loginRegion, directoryRegion, …]`, each built by `overridesFor(archipelago, {…})` in
|
|
68
|
+
// its own module. There is no region id in that list to match on — the id lives inside the
|
|
69
|
+
// archipelago the call points at — so this reads the PER-REGION MODULE instead, the same
|
|
70
|
+
// `<lagoonDir>/src/regions/<id>` convention the rest of the CLI already maps a changed file by.
|
|
71
|
+
//
|
|
72
|
+
// Missing this cost the address on a real project: the check found the page and reported on it
|
|
73
|
+
// while `motu lagoon states` said the region had none, which is the two-answers-to-one-question
|
|
74
|
+
// shape this file exists to avoid.
|
|
75
|
+
return regionModuleDeclaresPage(id);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Does `<lagoonDir>/src/regions/<id>.(tsx|ts)` pass a `page` to `overridesFor`? */
|
|
79
|
+
function regionModuleDeclaresPage(id) {
|
|
80
|
+
for (const ext of ['.tsx', '.ts']) {
|
|
81
|
+
const file = resolve(paths.lagoonDir, 'src/regions', `${id}${ext}`);
|
|
82
|
+
if (!existsSync(file)) continue;
|
|
83
|
+
try {
|
|
84
|
+
const project = new Project({ useInMemoryFileSystem: true, skipAddingFilesFromTsConfig: true });
|
|
85
|
+
const sf = project.createSourceFile(`region${ext}`, readFileSync(file, 'utf8'));
|
|
86
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
87
|
+
if (call.getExpression().getText() !== 'overridesFor') continue;
|
|
88
|
+
const spec = call.getArguments()[1]?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
89
|
+
if (spec?.getProperty('page')) return true;
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The slots a region declares CONDITIONAL — `when` or `unless` on the root's slot mapping.
|
|
100
|
+
*
|
|
101
|
+
* Two slots can be alternatives: peps' login declares `auth-error` `when: 'authError'` and
|
|
102
|
+
* `login-form` `unless: 'authError'`, and exactly one of them can ever render. A check that demands
|
|
103
|
+
* every declared slot be reached calls a correctly-written region broken, every time, and an error
|
|
104
|
+
* nobody can act on is how a check teaches people to ignore it.
|
|
105
|
+
*
|
|
106
|
+
* PARSED STRUCTURALLY — an object literal carrying BOTH a `slot` and a `when`/`unless` — rather than
|
|
107
|
+
* matched positionally, because these mappings are written in several shapes and a pattern that
|
|
108
|
+
* assumes one of them fails silently on the others.
|
|
109
|
+
*/
|
|
110
|
+
export function conditionalSlots(file) {
|
|
111
|
+
const out = new Set();
|
|
112
|
+
if (!existsSync(file)) return out;
|
|
113
|
+
try {
|
|
114
|
+
const project = new Project({ useInMemoryFileSystem: true, skipAddingFilesFromTsConfig: true });
|
|
115
|
+
const sf = project.createSourceFile('archipelago.ts', readFileSync(file, 'utf8'));
|
|
116
|
+
for (const obj of sf.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)) {
|
|
117
|
+
const prop = (name) => obj.getProperty(name)?.asKind(SyntaxKind.PropertyAssignment);
|
|
118
|
+
const slot = prop('slot')?.getInitializer()?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
|
|
119
|
+
if (slot && (prop('when') || prop('unless'))) out.add(slot);
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// Unparseable: report nothing as conditional rather than guess. The check then warns where it
|
|
123
|
+
// would have stayed quiet, which is the safe direction for a warning.
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The lagoon overrides module, parsed once, or null. */
|
|
129
|
+
function overridesSource(project) {
|
|
130
|
+
for (const name of ['src/lagoon.tsx', 'src/lagoon.ts']) {
|
|
131
|
+
const file = resolve(paths.lagoonDir, name);
|
|
132
|
+
if (!existsSync(file)) continue;
|
|
133
|
+
try {
|
|
134
|
+
return { file, sf: project.createSourceFile(name.endsWith('x') ? 'l.tsx' : 'l.ts', readFileSync(file, 'utf8')) };
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A property's name with any quoting removed — `'manage-servers'` and `manageServers` both answer plainly. */
|
|
143
|
+
function keyOf(prop) {
|
|
144
|
+
const a = prop.asKind(SyntaxKind.PropertyAssignment) ?? prop.asKind(SyntaxKind.ShorthandPropertyAssignment);
|
|
145
|
+
return a ? a.getName().replace(/^['"`]|['"`]$/g, '') : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Resolve a local identifier to the module file it is imported from. */
|
|
149
|
+
function moduleOfIdent(sf, fromFile, ident) {
|
|
150
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
151
|
+
const named = imp.getNamedImports().some((n) => (n.getAliasNode() ?? n.getNameNode()).getText() === ident);
|
|
152
|
+
const dflt = imp.getDefaultImport()?.getText() === ident;
|
|
153
|
+
if (!named && !dflt) continue;
|
|
154
|
+
const base = resolve(dirname(fromFile), imp.getModuleSpecifierValue().replace(/\.js$/, ''));
|
|
155
|
+
return ['.tsx', '.ts', '/index.tsx', '/index.ts', ''].map((e) => base + e).find((c) => existsSync(c)) ?? null;
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The `export const <name> = …` initializer, whatever its type annotation. */
|
|
161
|
+
function exportedInitializer(sf, name) {
|
|
162
|
+
return sf.getVariableDeclaration(name)?.getInitializer() ?? null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* WHERE A REGION'S FRAME LIVES — the module holding its `layout`, and whether one was declared at all.
|
|
167
|
+
*
|
|
168
|
+
* PARSED, after years of not being. The regex this replaces had learned six shapes the hard way, each
|
|
169
|
+
* arriving as a SILENT MISS that reported a correctly-declared region as declaring nothing: a quoted
|
|
170
|
+
* key, a one-line `export const regions = [a, b];`, an entry that is an object rather than an
|
|
171
|
+
* identifier, an inline arrow, a `.js` specifier, the array form. Two cold-start agents lost their
|
|
172
|
+
* longest debugging stretch to the third of those, and found the cause only by reading motu's source.
|
|
173
|
+
*
|
|
174
|
+
* Every one of those shapes is a normal way to write the file, and none of them is visible to a
|
|
175
|
+
* pattern that matches text. They are all the same two nodes to a parser.
|
|
176
|
+
*
|
|
177
|
+
* Returns the same four-field answer the regex did:
|
|
178
|
+
* `{ module, declared, exportName?, inline? }`
|
|
179
|
+
* — `declared` says the region has an entry, `module` is the file to open (null when there is nothing
|
|
180
|
+
* to open), and `inline` marks a layout written in place, which is declared but not inspectable.
|
|
181
|
+
*/
|
|
182
|
+
export function frameModuleFor(id) {
|
|
183
|
+
const project = new Project({ useInMemoryFileSystem: true, skipAddingFilesFromTsConfig: true });
|
|
184
|
+
const found = overridesSource(project);
|
|
185
|
+
if (!found) return { module: null, declared: false };
|
|
186
|
+
const { file, sf } = found;
|
|
187
|
+
const moduleOf = (ident) => moduleOfIdent(sf, file, ident);
|
|
188
|
+
|
|
189
|
+
// KIND-FIRST: `export const layout = { <id>: X }` — the older spelling, still honoured. The value
|
|
190
|
+
// IS the layout, so anything that is not an identifier is an inline one rather than a missing one.
|
|
191
|
+
const layoutMap = exportedInitializer(sf, 'layout')?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
192
|
+
if (layoutMap) {
|
|
193
|
+
for (const prop of layoutMap.getProperties()) {
|
|
194
|
+
if (keyOf(prop) !== id) continue;
|
|
195
|
+
const init = prop.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
|
|
196
|
+
const ident = init?.asKind(SyntaxKind.Identifier)?.getText();
|
|
197
|
+
if (ident) return { module: moduleOf(ident), declared: true, exportName: ident };
|
|
198
|
+
return { module: null, declared: true, inline: true };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const regions = exportedInitializer(sf, 'regions');
|
|
203
|
+
|
|
204
|
+
// RECORD: `export const regions = { <id>: X }` or `{ <id>: { layout: X, … } }`.
|
|
205
|
+
const asRecord = regions?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
206
|
+
if (asRecord) {
|
|
207
|
+
for (const prop of asRecord.getProperties()) {
|
|
208
|
+
if (keyOf(prop) !== id) continue;
|
|
209
|
+
const init = prop.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
|
|
210
|
+
const ident = init?.asKind(SyntaxKind.Identifier)?.getText();
|
|
211
|
+
if (ident) return { module: moduleOf(ident), declared: true, exportName: ident };
|
|
212
|
+
const entry = init?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
213
|
+
const layout = entry?.getProperty('layout')?.asKind(SyntaxKind.PropertyAssignment);
|
|
214
|
+
if (!layout) return { module: null, declared: true };
|
|
215
|
+
const li = layout.getInitializer()?.asKind(SyntaxKind.Identifier)?.getText();
|
|
216
|
+
if (li) return { module: moduleOf(li), declared: true, exportName: li };
|
|
217
|
+
return { module: null, declared: true, inline: true };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ARRAY: `export const regions = [loginRegion, …]`, each built by `overridesFor(<x>Archipelago, …)`
|
|
222
|
+
// in its own module. The identifier alone cannot say which region it is — two regions' camel-case
|
|
223
|
+
// can collide — so the archipelago it points at has to name this id.
|
|
224
|
+
const asArray = regions?.asKind(SyntaxKind.ArrayLiteralExpression);
|
|
225
|
+
if (asArray) {
|
|
226
|
+
let sawSomeModule = false;
|
|
227
|
+
for (const el of asArray.getElements()) {
|
|
228
|
+
const ident = el.asKind(SyntaxKind.Identifier)?.getText();
|
|
229
|
+
const module = ident ? moduleOf(ident) : null;
|
|
230
|
+
if (!module) continue;
|
|
231
|
+
try {
|
|
232
|
+
const body = project.createSourceFile(`r${sawSomeModule}${ident}.tsx`, readFileSync(module, 'utf8'));
|
|
233
|
+
const call = body
|
|
234
|
+
.getDescendantsOfKind(SyntaxKind.CallExpression)
|
|
235
|
+
.find((c) => c.getExpression().getText() === 'overridesFor');
|
|
236
|
+
const bound = call?.getArguments()[0]?.asKind(SyntaxKind.Identifier)?.getText();
|
|
237
|
+
if (!bound) continue;
|
|
238
|
+
sawSomeModule = true;
|
|
239
|
+
const from = body
|
|
240
|
+
.getImportDeclarations()
|
|
241
|
+
.find((i) => i.getNamedImports().some((n) => (n.getAliasNode() ?? n.getNameNode()).getText() === bound))
|
|
242
|
+
?.getModuleSpecifierValue();
|
|
243
|
+
if (!from || !new RegExp(`(^|/)${id}\\.archipelago(\\.[jt]sx?)?$`).test(from)) continue;
|
|
244
|
+
return { module, declared: true };
|
|
245
|
+
} catch {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// The array exists and nothing in it could be read: unresolvable, so loud rather than quiet.
|
|
250
|
+
if (!sawSomeModule) return { module: null, declared: true };
|
|
251
|
+
}
|
|
252
|
+
return { module: null, declared: false };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The slots an archipelago declares INSIDE its islands — nested composition.
|
|
257
|
+
*
|
|
258
|
+
* motu fills these into the outer island's props, in the page and in the lagoon alike, so a frame
|
|
259
|
+
* that never names them is composing them all the same and must not be reported for omitting them.
|
|
260
|
+
*
|
|
261
|
+
* THE REGEX THIS REPLACES DEPENDED ON INDENTATION. It removed the root's own mapping with
|
|
262
|
+
* `/^ {2}slots\s*:[\s\S]*?^ {2}\},/m` — two literal spaces — and then matched what was left. Change
|
|
263
|
+
* the formatter's `tabWidth`, use tabs, or nest the config one level deeper and it silently matches
|
|
264
|
+
* nothing: no error, just a region that appears to declare no nested slots and a frame reported for
|
|
265
|
+
* not composing them. It worked because every project so far happens to indent by two.
|
|
266
|
+
*
|
|
267
|
+
* Structurally there is no ambiguity: nested slots are the `slots` of an ISLAND ENTRY, the root's are
|
|
268
|
+
* the `slots` of the config. A parser can tell those apart; a pattern over text cannot.
|
|
269
|
+
*/
|
|
270
|
+
export function nestedSlots(file) {
|
|
271
|
+
const out = new Set();
|
|
272
|
+
if (!existsSync(file)) return out;
|
|
273
|
+
try {
|
|
274
|
+
const project = new Project({ useInMemoryFileSystem: true, skipAddingFilesFromTsConfig: true });
|
|
275
|
+
const sf = project.createSourceFile('archipelago.ts', readFileSync(file, 'utf8'));
|
|
276
|
+
// The config is whichever object literal declares `islands: [...]` — it may sit inside a call
|
|
277
|
+
// (`archipelago<…>()({…}, {…})`), so it is found by SHAPE rather than by position.
|
|
278
|
+
const config = sf
|
|
279
|
+
.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)
|
|
280
|
+
.find((o) => o.getProperty('islands')?.asKind(SyntaxKind.PropertyAssignment)?.getInitializer()?.asKind(SyntaxKind.ArrayLiteralExpression));
|
|
281
|
+
const islands = config?.getProperty('islands')?.asKind(SyntaxKind.PropertyAssignment)?.getInitializer()?.asKind(SyntaxKind.ArrayLiteralExpression);
|
|
282
|
+
for (const entry of islands?.getElements() ?? []) {
|
|
283
|
+
const slots = entry
|
|
284
|
+
.asKind(SyntaxKind.ObjectLiteralExpression)
|
|
285
|
+
?.getProperty('slots')
|
|
286
|
+
?.asKind(SyntaxKind.PropertyAssignment)
|
|
287
|
+
?.getInitializer()
|
|
288
|
+
?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
289
|
+
for (const prop of slots?.getProperties() ?? []) {
|
|
290
|
+
const init = prop.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
|
|
291
|
+
// `prop: 'slot-name'` and `prop: { slot: 'slot-name', … }` are both legal spellings.
|
|
292
|
+
const direct = init?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
|
|
293
|
+
const viaSlot = init
|
|
294
|
+
?.asKind(SyntaxKind.ObjectLiteralExpression)
|
|
295
|
+
?.getProperty('slot')
|
|
296
|
+
?.asKind(SyntaxKind.PropertyAssignment)
|
|
297
|
+
?.getInitializer()
|
|
298
|
+
?.asKind(SyntaxKind.StringLiteral)
|
|
299
|
+
?.getLiteralValue();
|
|
300
|
+
if (direct || viaSlot) out.add(direct ?? viaSlot);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
} catch {
|
|
304
|
+
// Unparseable: report no nested slots, which is what the regex did on a shape it could not read —
|
|
305
|
+
// except this one cannot be defeated by whitespace.
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The REGION's own `slots` — its root component's prop -> the slot that fills it — or null when the
|
|
312
|
+
* region declares no `root` (a page composed by hand fills nothing).
|
|
313
|
+
*
|
|
314
|
+
* SIBLING OF `nestedSlots`, and it should have been written next to it. `integrate check` read this
|
|
315
|
+
* with two alternating regexes whose comment records what they cost: an unanchored multi-line pattern
|
|
316
|
+
* "ran on to the next line beginning with `},` — swallowing the island entries below and reading
|
|
317
|
+
* their `bind` pairs as slot mappings. The page's own props then matched those invented names, and
|
|
318
|
+
* five props of one card were reported as slots the archipelago had never declared." The fix at the
|
|
319
|
+
* time was a second pattern for the one-line form and an anchor at two spaces of indent — which is a
|
|
320
|
+
* rule about formatting, not about the language.
|
|
321
|
+
*/
|
|
322
|
+
export function rootSlots(file) {
|
|
323
|
+
if (!existsSync(file)) return null;
|
|
324
|
+
let sf;
|
|
325
|
+
try {
|
|
326
|
+
const project = new Project({ useInMemoryFileSystem: true, skipAddingFilesFromTsConfig: true });
|
|
327
|
+
sf = project.createSourceFile('archipelago.ts', readFileSync(file, 'utf8'));
|
|
328
|
+
} catch {
|
|
329
|
+
// Unparseable: null, which the caller already reads as "no answer" rather than "no slots". An
|
|
330
|
+
// EMPTY map would say this root fills nothing, and every prop the page passes would then be
|
|
331
|
+
// reported as a slot the archipelago never declared — the exact failure the regex had.
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
const config = configLiteral(sf);
|
|
335
|
+
if (!config || !config.getProperty('root')) return null;
|
|
336
|
+
const slots = propInit(config, 'slots')?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
337
|
+
const map = {};
|
|
338
|
+
for (const prop of slots?.getProperties() ?? []) {
|
|
339
|
+
const name = nameOf(prop);
|
|
340
|
+
const init = prop.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
|
|
341
|
+
if (!name || !init) continue;
|
|
342
|
+
// `prop: 'slot-name'` and the exclusive form `prop: { slot: 'slot-name', when|unless: 'key' }`.
|
|
343
|
+
const slot = str(init) ?? str(propInit(init.asKind(SyntaxKind.ObjectLiteralExpression), 'slot'));
|
|
344
|
+
if (slot) map[name] = slot;
|
|
345
|
+
}
|
|
346
|
+
return map;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The root props this region fills with ANOTHER REGION — `slots: { prop: { region: '<id>' } }` — as
|
|
351
|
+
* prop -> region id. Read whether or not the region declares a `root`: a reference from a region with
|
|
352
|
+
* none is an error worth naming (`region-ref`), not a declaration to drop.
|
|
353
|
+
*
|
|
354
|
+
* `{}` when there are none; null when the file does not parse, which is "could not tell" — the caller
|
|
355
|
+
* must not read it as "references nothing".
|
|
356
|
+
*/
|
|
357
|
+
export function regionRefsDeclared(file) {
|
|
358
|
+
if (!existsSync(file)) return {};
|
|
359
|
+
let sf;
|
|
360
|
+
try {
|
|
361
|
+
const project = new Project({ useInMemoryFileSystem: true, skipAddingFilesFromTsConfig: true });
|
|
362
|
+
sf = project.createSourceFile('archipelago.ts', readFileSync(file, 'utf8'));
|
|
363
|
+
} catch {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
const slots = propInit(configLiteral(sf), 'slots')?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
367
|
+
const map = {};
|
|
368
|
+
for (const prop of slots?.getProperties() ?? []) {
|
|
369
|
+
const name = nameOf(prop);
|
|
370
|
+
const obj = prop.asKind(SyntaxKind.PropertyAssignment)?.getInitializer()?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
371
|
+
const region = str(propInit(obj, 'region'));
|
|
372
|
+
if (name && region) map[name] = region;
|
|
373
|
+
}
|
|
374
|
+
return map;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Every slot this region DECLARES — the `slot` of each island entry.
|
|
379
|
+
*
|
|
380
|
+
* Read from the island entries rather than from every `slot:` in the file. The regex it replaces took
|
|
381
|
+
* all of them, which quietly included the region-level `slots` map's own object form — so a slot
|
|
382
|
+
* NAMED there but declared by no island entry counted as declared, and the check that exists to catch
|
|
383
|
+
* exactly that could not.
|
|
384
|
+
*/
|
|
385
|
+
export function declaredSlotNames(file) {
|
|
386
|
+
const out = new Set();
|
|
387
|
+
if (!existsSync(file)) return out;
|
|
388
|
+
try {
|
|
389
|
+
const project = new Project({ useInMemoryFileSystem: true, skipAddingFilesFromTsConfig: true });
|
|
390
|
+
const sf = project.createSourceFile('archipelago.ts', readFileSync(file, 'utf8'));
|
|
391
|
+
const islands = propInit(configLiteral(sf), 'islands')?.asKind(SyntaxKind.ArrayLiteralExpression);
|
|
392
|
+
for (const entry of islands?.getElements() ?? []) {
|
|
393
|
+
const slot = str(propInit(entry.asKind(SyntaxKind.ObjectLiteralExpression), 'slot'));
|
|
394
|
+
if (slot) out.add(slot);
|
|
395
|
+
}
|
|
396
|
+
} catch {
|
|
397
|
+
// Unparseable: an empty set, so a composition check reports every filled slot as undeclared
|
|
398
|
+
// rather than silently approving one. Loud is the safe direction for an error.
|
|
399
|
+
}
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** The object literal that declares `islands: [...]`, wherever it sits. */
|
|
404
|
+
/**
|
|
405
|
+
* What a region declares about ALTERNATIVES, as the plain data `exclusiveProblems` and
|
|
406
|
+
* `undeclaredAlternatives` (from @motujs/core) read: whether it has a `root`, its slots (with their
|
|
407
|
+
* `when`/`unless`), its host slots, and its `exclusive` groups.
|
|
408
|
+
*
|
|
409
|
+
* `null` when the file did not parse — "unknown", which the caller must not read as "none". A group
|
|
410
|
+
* written as anything but an array of string literals is reported in `unreadable` rather than
|
|
411
|
+
* dropped: a group silently missing from the check is a guarantee nobody is checking.
|
|
412
|
+
*/
|
|
413
|
+
export function exclusiveDeclared(file) {
|
|
414
|
+
if (!existsSync(file)) return null;
|
|
415
|
+
let sf;
|
|
416
|
+
try {
|
|
417
|
+
const project = new Project({ useInMemoryFileSystem: true, skipAddingFilesFromTsConfig: true });
|
|
418
|
+
sf = project.createSourceFile('archipelago.ts', readFileSync(file, 'utf8'));
|
|
419
|
+
} catch {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
const config = configLiteral(sf);
|
|
423
|
+
if (!config) return null;
|
|
424
|
+
const slots = {};
|
|
425
|
+
for (const prop of propInit(config, 'slots')?.asKind(SyntaxKind.ObjectLiteralExpression)?.getProperties() ?? []) {
|
|
426
|
+
const name = nameOf(prop);
|
|
427
|
+
const init = prop.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
|
|
428
|
+
if (!name || !init) continue;
|
|
429
|
+
const obj = init.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
430
|
+
slots[name] = obj
|
|
431
|
+
? Object.fromEntries(['slot', 'when', 'unless', 'region'].map((k) => [k, str(propInit(obj, k))]).filter(([, v]) => v))
|
|
432
|
+
: (str(init) ?? name);
|
|
433
|
+
}
|
|
434
|
+
const hostSlots = {};
|
|
435
|
+
for (const prop of propInit(config, 'hostSlots')?.asKind(SyntaxKind.ObjectLiteralExpression)?.getProperties() ?? []) {
|
|
436
|
+
const name = nameOf(prop);
|
|
437
|
+
if (name) hostSlots[name] = true;
|
|
438
|
+
}
|
|
439
|
+
const exclusive = [];
|
|
440
|
+
const unreadable = [];
|
|
441
|
+
const groups = propInit(config, 'exclusive');
|
|
442
|
+
if (groups) {
|
|
443
|
+
const arr = groups.asKind(SyntaxKind.ArrayLiteralExpression);
|
|
444
|
+
if (!arr) unreadable.push(groups.getText());
|
|
445
|
+
for (const g of arr?.getElements() ?? []) {
|
|
446
|
+
const members = g.asKind(SyntaxKind.ArrayLiteralExpression)?.getElements().map(str);
|
|
447
|
+
if (!members || members.some((m) => m === undefined)) unreadable.push(g.getText());
|
|
448
|
+
else exclusive.push(members);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return { root: Boolean(config.getProperty('root')), slots, hostSlots, exclusive, unreadable };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function configLiteral(sf) {
|
|
455
|
+
return sf
|
|
456
|
+
.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)
|
|
457
|
+
.find((o) =>
|
|
458
|
+
o.getProperty('islands')?.asKind(SyntaxKind.PropertyAssignment)?.getInitializer()?.asKind(SyntaxKind.ArrayLiteralExpression),
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const propInit = (obj, name) => obj?.getProperty(name)?.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
|
|
463
|
+
const str = (node) => node?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
|
|
464
|
+
/** A property name with quoting removed: `searchTerm` and `'search-term'` answer the same way. */
|
|
465
|
+
const nameOf = (p) => {
|
|
466
|
+
const a = p.asKind(SyntaxKind.PropertyAssignment) ?? p.asKind(SyntaxKind.ShorthandPropertyAssignment);
|
|
467
|
+
return a ? a.getName().replace(/^['"`]|['"`]$/g, '') : null;
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* The islands an archipelago declares, read from its SOURCE by parsing it.
|
|
472
|
+
*
|
|
473
|
+
* THIS EXISTS BECAUSE `writes` WAS INVISIBLE ON ORDINARY CODE. The reader this replaces matched
|
|
474
|
+
* `/'([^']+)':\s*…/` — it required the EVENT NAME TO BE QUOTED — so a region writing the idiomatic
|
|
475
|
+
* `writes: { searchTerm: 'searchTerm' }` parsed as writing NOTHING.
|
|
476
|
+
*
|
|
477
|
+
* That is not a cosmetic miss. `ownership` — "a key has ONE producer", the rule the whole
|
|
478
|
+
* parallel-agent story rests on — is computed from these writes, so on any project with unquoted keys
|
|
479
|
+
* it compared empty sets and could never fire. Measured on shlink: two islands declaring the same
|
|
480
|
+
* key, `motu check` green with zero warnings, and the region then reported that key as HOST-FED
|
|
481
|
+
* because the producer had vanished. The guarantee the documentation makes ("a second claim on a key
|
|
482
|
+
* fails on their first `motu check`") was not true there, and nothing said so.
|
|
483
|
+
*
|
|
484
|
+
* Quoted or not, nested or not, commented or not — they are the same nodes to a parser.
|
|
485
|
+
*/
|
|
486
|
+
export function islandsFromSource(text) {
|
|
487
|
+
const out = [];
|
|
488
|
+
let sf;
|
|
489
|
+
try {
|
|
490
|
+
const project = new Project({ useInMemoryFileSystem: true, skipAddingFilesFromTsConfig: true });
|
|
491
|
+
sf = project.createSourceFile('a.ts', text);
|
|
492
|
+
} catch {
|
|
493
|
+
return null; // unparseable: the caller keeps its previous answer rather than losing the islands
|
|
494
|
+
}
|
|
495
|
+
const islands = propInit(configLiteral(sf), 'islands')?.asKind(SyntaxKind.ArrayLiteralExpression);
|
|
496
|
+
if (!islands) return out;
|
|
497
|
+
for (const el of islands.getElements()) {
|
|
498
|
+
const entry = el.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
499
|
+
if (!entry) continue;
|
|
500
|
+
const slot = str(propInit(entry, 'slot'));
|
|
501
|
+
if (!slot) continue;
|
|
502
|
+
|
|
503
|
+
// `writes: { <event>: '<key>' }` and the rename form `{ <event>: { <field>: '<key>' } }`.
|
|
504
|
+
const writes = {};
|
|
505
|
+
for (const p of propInit(entry, 'writes')?.asKind(SyntaxKind.ObjectLiteralExpression)?.getProperties() ?? []) {
|
|
506
|
+
const event = nameOf(p);
|
|
507
|
+
const init = p.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
|
|
508
|
+
if (!event || !init) continue;
|
|
509
|
+
const direct = str(init);
|
|
510
|
+
if (direct) {
|
|
511
|
+
writes[event] = direct;
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
const map = init.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
515
|
+
if (map) {
|
|
516
|
+
writes[event] = Object.fromEntries(
|
|
517
|
+
map.getProperties().map((q) => [nameOf(q), str(q.asKind(SyntaxKind.PropertyAssignment)?.getInitializer())]).filter(([k, v]) => k && v),
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// `bind` is either the rename map `{ prop: 'key' }` or the array `['key', { prop: 'key' }]`.
|
|
523
|
+
const bind = {};
|
|
524
|
+
const bindInit = propInit(entry, 'bind');
|
|
525
|
+
const bindMap = bindInit?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
526
|
+
if (bindMap) {
|
|
527
|
+
for (const p of bindMap.getProperties()) {
|
|
528
|
+
const key = str(p.asKind(SyntaxKind.PropertyAssignment)?.getInitializer());
|
|
529
|
+
if (nameOf(p) && key) bind[nameOf(p)] = key;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
for (const item of bindInit?.asKind(SyntaxKind.ArrayLiteralExpression)?.getElements() ?? []) {
|
|
533
|
+
const bare = str(item);
|
|
534
|
+
// A bare entry binds a prop of the same name; an object entry is a rename.
|
|
535
|
+
if (bare) bind[bare] = bare;
|
|
536
|
+
for (const p of item.asKind(SyntaxKind.ObjectLiteralExpression)?.getProperties() ?? []) {
|
|
537
|
+
const key = str(p.asKind(SyntaxKind.PropertyAssignment)?.getInitializer());
|
|
538
|
+
if (nameOf(p) && key) bind[nameOf(p)] = key;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
out.push({
|
|
543
|
+
slot,
|
|
544
|
+
element: str(propInit(entry, 'element')),
|
|
545
|
+
writes,
|
|
546
|
+
member: str(propInit(entry, 'member')),
|
|
547
|
+
reads: (propInit(entry, 'reads')?.asKind(SyntaxKind.ArrayLiteralExpression)?.getElements() ?? []).map(str).filter(Boolean),
|
|
548
|
+
planned: propInit(entry, 'planned')?.getText() === 'true',
|
|
549
|
+
bind,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
return out;
|
|
553
|
+
}
|