@motujs/cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/bundled/github/agents/archipelago-build.agent.md +298 -0
  3. package/bundled/github/agents/island-create.agent.md +261 -0
  4. package/bundled/github/agents/island-extract.agent.md +360 -0
  5. package/bundled/github/agents/island-locate.agent.md +208 -0
  6. package/bundled/github/agents/motu-checks.agent.md +158 -0
  7. package/bundled/github/agents/motu-evidence.agent.md +267 -0
  8. package/bundled/github/agents/motu-regions.agent.md +304 -0
  9. package/bundled/github/host-rules.md +43 -0
  10. package/bundled/github/prompts/archipelago-build.prompt.md +19 -0
  11. package/bundled/github/prompts/island-create.prompt.md +37 -0
  12. package/bundled/github/prompts/island-extract.prompt.md +59 -0
  13. package/bundled/github/prompts/island-locate.prompt.md +43 -0
  14. package/bundled/packages/adapters/angularjs/vite.mjs +43 -0
  15. package/bundled/packages/adapters/next/lagoon/next-stubs.tsx +108 -0
  16. package/bundled/packages/adapters/next/vite.mjs +121 -0
  17. package/bundled/packages/adapters/vite/vite.mjs +179 -0
  18. package/package.json +64 -3
  19. package/src/alias-loader.mjs +63 -0
  20. package/src/alias-register.mjs +28 -0
  21. package/src/asset-stub.cjs +3 -0
  22. package/src/asset-stub.mjs +5 -0
  23. package/src/cli.mjs +78 -0
  24. package/src/commands/adopt-root.mjs +194 -0
  25. package/src/commands/archipelago-init.mjs +314 -0
  26. package/src/commands/archipelago.mjs +138 -0
  27. package/src/commands/assurance.mjs +40 -0
  28. package/src/commands/check.mjs +346 -0
  29. package/src/commands/codegen.mjs +29 -0
  30. package/src/commands/coverage.mjs +358 -0
  31. package/src/commands/create.mjs +277 -0
  32. package/src/commands/defaults.mjs +129 -0
  33. package/src/commands/diff.mjs +595 -0
  34. package/src/commands/fixtures.mjs +153 -0
  35. package/src/commands/init.mjs +473 -0
  36. package/src/commands/integrate.mjs +208 -0
  37. package/src/commands/integration.mjs +1349 -0
  38. package/src/commands/lagoon-try.mjs +272 -0
  39. package/src/commands/lagoon.mjs +1419 -0
  40. package/src/commands/link.mjs +110 -0
  41. package/src/commands/region-coverage.mjs +604 -0
  42. package/src/commands/removal-check.mjs +878 -0
  43. package/src/commands/skills.mjs +156 -0
  44. package/src/commands/snapshot.mjs +456 -0
  45. package/src/commands/sync.mjs +38 -0
  46. package/src/commands/verify/island-runtime.mjs +581 -0
  47. package/src/commands/verify/island-static.mjs +586 -0
  48. package/src/commands/verify/island.mjs +135 -0
  49. package/src/commands/verify/reach.mjs +426 -0
  50. package/src/commands/verify/region-composition.mjs +391 -0
  51. package/src/commands/verify/region-config.mjs +330 -0
  52. package/src/commands/verify/region-flows.mjs +760 -0
  53. package/src/commands/verify/region-sources.mjs +516 -0
  54. package/src/commands/verify/region.mjs +271 -0
  55. package/src/commands/verify/report.mjs +338 -0
  56. package/src/commands/verify/screens-runtime.mjs +123 -0
  57. package/src/commands/verify.mjs +18 -0
  58. package/src/lagoon-build.mjs +25 -0
  59. package/src/lagoon-dev.mjs +32 -0
  60. package/src/lib/alias-core.mjs +49 -0
  61. package/src/lib/archipelagos.mjs +111 -0
  62. package/src/lib/assurance.mjs +144 -0
  63. package/src/lib/baselines.mjs +107 -0
  64. package/src/lib/bundlability.mjs +123 -0
  65. package/src/lib/changed.mjs +157 -0
  66. package/src/lib/component-props.mjs +145 -0
  67. package/src/lib/config.mjs +451 -0
  68. package/src/lib/contracts.mjs +244 -0
  69. package/src/lib/data-flow.mjs +47 -0
  70. package/src/lib/declared-sources.mjs +101 -0
  71. package/src/lib/dock-inject.mjs +47 -0
  72. package/src/lib/effects.mjs +97 -0
  73. package/src/lib/eject.mjs +715 -0
  74. package/src/lib/evidence-json.mjs +74 -0
  75. package/src/lib/expect-calls.mjs +53 -0
  76. package/src/lib/finding-memory.mjs +0 -0
  77. package/src/lib/flow-calls.mjs +53 -0
  78. package/src/lib/framework.mjs +98 -0
  79. package/src/lib/host-rules.mjs +55 -0
  80. package/src/lib/host-sources.mjs +121 -0
  81. package/src/lib/island-placement.mjs +64 -0
  82. package/src/lib/island-render.mjs +105 -0
  83. package/src/lib/islands.mjs +112 -0
  84. package/src/lib/lagoon-bundle.mjs +138 -0
  85. package/src/lib/lagoon-declares.mjs +553 -0
  86. package/src/lib/lagoon-materialize.mjs +217 -0
  87. package/src/lib/lagoon-vite.mjs +788 -0
  88. package/src/lib/live-url.mjs +53 -0
  89. package/src/lib/node-aliases.mjs +73 -0
  90. package/src/lib/operations.mjs +145 -0
  91. package/src/lib/provenance-plugin.mjs +74 -0
  92. package/src/lib/region-refs.mjs +40 -0
  93. package/src/lib/remote.mjs +171 -0
  94. package/src/lib/scaffold.mjs +606 -0
  95. package/src/lib/screens.mjs +266 -0
  96. package/src/lib/snapshots.mjs +85 -0
  97. package/src/lib/stubs.mjs +114 -0
  98. package/src/lib/ts-project.mjs +42 -0
  99. package/src/lib/util.mjs +626 -0
  100. package/src/lib/worlds.mjs +236 -0
  101. package/src/lib/write-if-changed.mjs +35 -0
  102. package/src/lib/writes-coverage.mjs +32 -0
  103. package/src/playwright-lagoon.mjs +2305 -0
  104. package/src/run.mjs +385 -0
  105. package/src/runtime-harness.mjs +225 -0
  106. package/README.md +0 -3
@@ -0,0 +1,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
+ }