@motujs/cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/bundled/github/agents/archipelago-build.agent.md +298 -0
  3. package/bundled/github/agents/island-create.agent.md +261 -0
  4. package/bundled/github/agents/island-extract.agent.md +360 -0
  5. package/bundled/github/agents/island-locate.agent.md +208 -0
  6. package/bundled/github/agents/motu-checks.agent.md +158 -0
  7. package/bundled/github/agents/motu-evidence.agent.md +267 -0
  8. package/bundled/github/agents/motu-regions.agent.md +304 -0
  9. package/bundled/github/host-rules.md +43 -0
  10. package/bundled/github/prompts/archipelago-build.prompt.md +19 -0
  11. package/bundled/github/prompts/island-create.prompt.md +37 -0
  12. package/bundled/github/prompts/island-extract.prompt.md +59 -0
  13. package/bundled/github/prompts/island-locate.prompt.md +43 -0
  14. package/bundled/packages/adapters/angularjs/vite.mjs +43 -0
  15. package/bundled/packages/adapters/next/lagoon/next-stubs.tsx +108 -0
  16. package/bundled/packages/adapters/next/vite.mjs +121 -0
  17. package/bundled/packages/adapters/vite/vite.mjs +179 -0
  18. package/package.json +64 -3
  19. package/src/alias-loader.mjs +63 -0
  20. package/src/alias-register.mjs +28 -0
  21. package/src/asset-stub.cjs +3 -0
  22. package/src/asset-stub.mjs +5 -0
  23. package/src/cli.mjs +78 -0
  24. package/src/commands/adopt-root.mjs +194 -0
  25. package/src/commands/archipelago-init.mjs +314 -0
  26. package/src/commands/archipelago.mjs +138 -0
  27. package/src/commands/assurance.mjs +40 -0
  28. package/src/commands/check.mjs +346 -0
  29. package/src/commands/codegen.mjs +29 -0
  30. package/src/commands/coverage.mjs +358 -0
  31. package/src/commands/create.mjs +277 -0
  32. package/src/commands/defaults.mjs +129 -0
  33. package/src/commands/diff.mjs +595 -0
  34. package/src/commands/fixtures.mjs +153 -0
  35. package/src/commands/init.mjs +473 -0
  36. package/src/commands/integrate.mjs +208 -0
  37. package/src/commands/integration.mjs +1349 -0
  38. package/src/commands/lagoon-try.mjs +272 -0
  39. package/src/commands/lagoon.mjs +1419 -0
  40. package/src/commands/link.mjs +110 -0
  41. package/src/commands/region-coverage.mjs +604 -0
  42. package/src/commands/removal-check.mjs +878 -0
  43. package/src/commands/skills.mjs +156 -0
  44. package/src/commands/snapshot.mjs +456 -0
  45. package/src/commands/sync.mjs +38 -0
  46. package/src/commands/verify/island-runtime.mjs +581 -0
  47. package/src/commands/verify/island-static.mjs +586 -0
  48. package/src/commands/verify/island.mjs +135 -0
  49. package/src/commands/verify/reach.mjs +426 -0
  50. package/src/commands/verify/region-composition.mjs +391 -0
  51. package/src/commands/verify/region-config.mjs +330 -0
  52. package/src/commands/verify/region-flows.mjs +760 -0
  53. package/src/commands/verify/region-sources.mjs +516 -0
  54. package/src/commands/verify/region.mjs +271 -0
  55. package/src/commands/verify/report.mjs +338 -0
  56. package/src/commands/verify/screens-runtime.mjs +123 -0
  57. package/src/commands/verify.mjs +18 -0
  58. package/src/lagoon-build.mjs +25 -0
  59. package/src/lagoon-dev.mjs +32 -0
  60. package/src/lib/alias-core.mjs +49 -0
  61. package/src/lib/archipelagos.mjs +111 -0
  62. package/src/lib/assurance.mjs +144 -0
  63. package/src/lib/baselines.mjs +107 -0
  64. package/src/lib/bundlability.mjs +123 -0
  65. package/src/lib/changed.mjs +157 -0
  66. package/src/lib/component-props.mjs +145 -0
  67. package/src/lib/config.mjs +451 -0
  68. package/src/lib/contracts.mjs +244 -0
  69. package/src/lib/data-flow.mjs +47 -0
  70. package/src/lib/declared-sources.mjs +101 -0
  71. package/src/lib/dock-inject.mjs +47 -0
  72. package/src/lib/effects.mjs +97 -0
  73. package/src/lib/eject.mjs +715 -0
  74. package/src/lib/evidence-json.mjs +74 -0
  75. package/src/lib/expect-calls.mjs +53 -0
  76. package/src/lib/finding-memory.mjs +0 -0
  77. package/src/lib/flow-calls.mjs +53 -0
  78. package/src/lib/framework.mjs +98 -0
  79. package/src/lib/host-rules.mjs +55 -0
  80. package/src/lib/host-sources.mjs +121 -0
  81. package/src/lib/island-placement.mjs +64 -0
  82. package/src/lib/island-render.mjs +105 -0
  83. package/src/lib/islands.mjs +112 -0
  84. package/src/lib/lagoon-bundle.mjs +138 -0
  85. package/src/lib/lagoon-declares.mjs +553 -0
  86. package/src/lib/lagoon-materialize.mjs +217 -0
  87. package/src/lib/lagoon-vite.mjs +788 -0
  88. package/src/lib/live-url.mjs +53 -0
  89. package/src/lib/node-aliases.mjs +73 -0
  90. package/src/lib/operations.mjs +145 -0
  91. package/src/lib/provenance-plugin.mjs +74 -0
  92. package/src/lib/region-refs.mjs +40 -0
  93. package/src/lib/remote.mjs +171 -0
  94. package/src/lib/scaffold.mjs +606 -0
  95. package/src/lib/screens.mjs +266 -0
  96. package/src/lib/snapshots.mjs +85 -0
  97. package/src/lib/stubs.mjs +114 -0
  98. package/src/lib/ts-project.mjs +42 -0
  99. package/src/lib/util.mjs +626 -0
  100. package/src/lib/worlds.mjs +236 -0
  101. package/src/lib/write-if-changed.mjs +35 -0
  102. package/src/lib/writes-coverage.mjs +32 -0
  103. package/src/playwright-lagoon.mjs +2305 -0
  104. package/src/run.mjs +385 -0
  105. package/src/runtime-harness.mjs +225 -0
  106. package/README.md +0 -3
@@ -0,0 +1,244 @@
1
+ // The generated half of an island's declaration.
2
+ //
3
+ // An island file used to open with twenty lines transcribing its own component: the props, the
4
+ // callback props as events, the host modules it imports. All three are FACTS about code that already
5
+ // exists a few directories away, and a fact is not something to review — it is something to derive.
6
+ // What was left after removing them is what a person actually decided: the tag, the component, and
7
+ // where an event's name differs from the callback's.
8
+ //
9
+ // So `motu sync` writes them here, once, for every island, and the island file reads its own
10
+ // entry back through the generated `island()` helper. The map is `as const`, so `EventsOf` still sees
11
+ // literal event names and `RegionWiringOk` still fails the build on a mistyped wire — the guarantees
12
+ // are unchanged, the transcription is gone.
13
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { dirname, relative, resolve, sep } from 'node:path';
15
+ import { readComponentContract } from './component-props.mjs';
16
+ import { listIslands } from './islands.mjs';
17
+ import { readEffectEntries, writeEffectEntry } from './effects.mjs';
18
+
19
+ export const CONTRACTS_FILE = 'contracts.generated.ts';
20
+
21
+ /** The `events` override an island file passes to `island()`: callback prop -> the region's word. */
22
+ function eventOverrides(text) {
23
+ const at = text.indexOf('events:');
24
+ if (at === -1) return {};
25
+ const open = text.indexOf('{', at);
26
+ if (open === -1) return {};
27
+ let depth = 0;
28
+ for (let i = open; i < text.length; i++) {
29
+ if (text[i] === '{') depth++;
30
+ else if (text[i] === '}' && --depth === 0) {
31
+ const body = text.slice(open + 1, i);
32
+ return Object.fromEntries([...body.matchAll(/(\w+)\s*:\s*'([^']+)'/g)].map((m) => [m[1], m[2]]));
33
+ }
34
+ }
35
+ return {};
36
+ }
37
+
38
+ /** The component an island file mounts: `{ name, from }`, read off its own import. */
39
+ function componentImport(text) {
40
+ const name =
41
+ text.match(/\bcomponent\s*:\s*([A-Za-z_$][\w$]*)/)?.[1] ??
42
+ text.match(/\bisland\(\s*'[^']+'\s*,\s*([A-Za-z_$][\w$]*)/)?.[1];
43
+ if (!name) return null;
44
+ for (const m of text.matchAll(/import\s+(?:type\s+)?\{([^}]*)\}\s*from\s*'([^']+)';/g)) {
45
+ for (const entry of m[1].split(',')) {
46
+ const [exported, alias] = entry.split(/\s+as\s+/).map((n) => n.trim());
47
+ if ((alias ?? exported) === name) return { name: exported, from: m[2] };
48
+ }
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /** What one island declares about itself, read from its file and its component. */
54
+ const islandCache = new Map();
55
+
56
+ export function islandContract(island, util) {
57
+ const cached = islandCache.get(island.element);
58
+ if (cached) return cached;
59
+ const result = islandContractUncached(island, util);
60
+ islandCache.set(island.element, result);
61
+ return result;
62
+ }
63
+
64
+ function islandContractUncached(island, { islandComponentPath, islandComponentExport, names }) {
65
+ const text = readFileSync(island.element, 'utf8');
66
+ const component = componentImport(text);
67
+ const tag = (text.match(/\btag:\s*'([^']+)'/) ?? text.match(/\bisland\(\s*'([^']+)'/))?.[1] ?? `x-${island.kebab}`;
68
+ const { pascal } = names(island.kebab);
69
+ const contract = readComponentContract(islandComponentPath(island.kebab, pascal), islandComponentExport(island.kebab, pascal));
70
+ const overrides = eventOverrides(text);
71
+ const output = {};
72
+ for (const o of contract?.output ?? []) output[o.prop] = overrides[o.prop] ?? o.event;
73
+ // An override for a callback the component does not have is a wire that can never fire; keep it out
74
+ // of the generated map so `RegionWiringOk` reports it instead of blessing it.
75
+ return {
76
+ tag,
77
+ kebab: island.kebab,
78
+ input: contract?.input ?? [],
79
+ output,
80
+ effects: contract?.effects ?? [],
81
+ read: !!contract,
82
+ component,
83
+ // WHERE THE SPECIFIER WAS AUTHORED. `component.from` is copied verbatim off the island's own
84
+ // import, and the island file is not where it lands: a `folder` island sits one directory
85
+ // deeper than `contracts.generated.ts`, so a RELATIVE specifier written there resolves one
86
+ // level too high once it is re-emitted here. Keeping the authoring directory is what lets
87
+ // `renderContracts` rebase it. Alias and bare specifiers are depth-independent and are why this
88
+ // went unseen — every consumer that used `@/…` was already correct.
89
+ elementDir: dirname(island.element),
90
+ };
91
+ }
92
+
93
+ /** Every island's contract, as it will be generated. */
94
+ export function projectContracts(islandsDir, util) {
95
+ return listIslands(islandsDir).map((i) => islandContract(i, util));
96
+ }
97
+
98
+ /** Render `contracts.generated.ts`. */
99
+ /**
100
+ * The specifier as `contracts.generated.ts` must spell it.
101
+ *
102
+ * Relative specifiers are re-anchored from the island file's directory onto the generated file's;
103
+ * anything else (an alias like `@/…`, a bare package name) is depth-independent and passes through.
104
+ */
105
+ function specifierFrom(component, elementDir, outDir) {
106
+ const spec = component.from;
107
+ if (!spec.startsWith('.') || !elementDir || !outDir) return spec;
108
+ const rebased = relative(outDir, resolve(elementDir, spec)).split(sep).join('/');
109
+ return rebased.startsWith('.') ? rebased : `./${rebased}`;
110
+ }
111
+
112
+ export function renderContracts(contracts, outDir) {
113
+ const entry = (c) => {
114
+ const input = c.input.map((p) => `'${p}'`).join(', ');
115
+ const output = Object.entries(c.output).map(([prop, ev]) => `${prop}: '${ev}'`).join(', ');
116
+ // Written back in the AUTHORED form, so the generated file reads like a hand-written one and a
117
+ // person diffing it sees the same vocabulary they would type.
118
+ const effects = c.effects.map((a) => writeEffectEntry(a)).join(', ');
119
+ return [
120
+ ` '${c.tag}': {`,
121
+ ` input: [${input}],`,
122
+ ` output: { ${output} },`,
123
+ ` effects: [${effects}],`,
124
+ ' },',
125
+ ].join('\n');
126
+ };
127
+ const withComponent = contracts.filter((c) => c.component);
128
+ const local = (c) => `C_${c.kebab.replace(/-/g, '_')}`;
129
+ const imports = withComponent
130
+ .map(
131
+ (c) =>
132
+ `import type { ${c.component.name} as ${local(c)} } from '${specifierFrom(c.component, c.elementDir, outDir)}';`,
133
+ )
134
+ .join('\n');
135
+ const assertions = withComponent
136
+ .map(
137
+ (c) =>
138
+ `const _${c.kebab.replace(/-/g, '_')}: ContractFitsComponent<typeof ${local(c)}, '${c.tag}'> = true;\n` +
139
+ `void _${c.kebab.replace(/-/g, '_')};`,
140
+ )
141
+ .join('\n');
142
+ return `// GENERATED by \`motu sync\` — do not edit by hand.
143
+ //
144
+ // Each island's boundary, READ from the component it mounts: the props it takes, the callback props it
145
+ // calls (as event names), and everything it reaches that is not a prop. Editing the component changes this file;
146
+ // editing this file changes nothing.
147
+ //
148
+ // The one part that is a decision — an event whose region name differs from the callback's — is
149
+ // declared in the island file, as \`island(tag, Component, { events: { onProgress: 'week-progress' } })\`,
150
+ // and baked in here so the compiler still sees literal event names.
151
+ import { islandElement } from '@motujs/react';
152
+ ${imports}
153
+
154
+ export const CONTRACTS = {
155
+ ${contracts.map(entry).join('\n')}
156
+ } as const;
157
+
158
+ type Tag = keyof typeof CONTRACTS;
159
+
160
+ type PropsOf<C> = C extends (props: infer P, ...rest: never[]) => unknown ? NonNullable<P> : never;
161
+
162
+ /**
163
+ * The generated contract names only props the component actually has — as a BUILD error.
164
+ *
165
+ * Reading the contract from the component removes the chance of transcribing it wrong, but not the
166
+ * chance of the file going stale: rename a prop and this map still names the old one until the next
167
+ * sync. The assertions below are what makes that a compile failure instead of an island that mounts
168
+ * with a prop nobody fills. Type-only, so nothing here reaches a bundle.
169
+ */
170
+ type ContractFitsComponent<C, T extends Tag> = [
171
+ Exclude<
172
+ (typeof CONTRACTS)[T]['input'][number] | keyof (typeof CONTRACTS)[T]['output'],
173
+ keyof PropsOf<C>
174
+ >,
175
+ ] extends [never]
176
+ ? true
177
+ : ['contract is stale — run \`motu sync\`; the component has no such prop:', Exclude<
178
+ (typeof CONTRACTS)[T]['input'][number] | keyof (typeof CONTRACTS)[T]['output'],
179
+ keyof PropsOf<C>
180
+ >];
181
+
182
+ ${assertions}
183
+
184
+ /**
185
+ * Declare an island: its tag, the component it mounts, and nothing that can be derived.
186
+ *
187
+ * \`options.events\` is read by \`motu sync\` (it is what produced the output names above) and
188
+ * applied here too, so the island is correct even before the next sync.
189
+ */
190
+ export function island<T extends Tag>(
191
+ tag: T,
192
+ component: Parameters<typeof islandElement>[0]['component'],
193
+ options?: Record<string, unknown> & { events?: Record<string, string> },
194
+ ): ReturnType<typeof islandElement> & { options: { contract: (typeof CONTRACTS)[T] } } {
195
+ const { events, ...rest } = options ?? {};
196
+ const base = CONTRACTS[tag];
197
+ const spec = { tag, component, options: { ...rest, contract: { ...base, output: { ...base.output, ...(events ?? {}) } } } };
198
+ return islandElement(spec as never) as never;
199
+ }
200
+ `;
201
+ }
202
+
203
+ /** Write the contracts file. Returns { path, count, changed }. */
204
+ export function syncContracts(islandsDir, util) {
205
+ const out = resolve(islandsDir, CONTRACTS_FILE);
206
+ const next = renderContracts(projectContracts(islandsDir, util), islandsDir);
207
+ const changed = !existsSync(out) || readFileSync(out, 'utf8') !== next;
208
+ if (changed) writeFileSync(out, next);
209
+ return { path: out, count: listIslands(islandsDir).length, changed };
210
+ }
211
+
212
+ /** Is the committed file what the components say it should be? (`motu check`'s drift gate.) */
213
+ export function contractsDrift(islandsDir, util) {
214
+ const out = resolve(islandsDir, CONTRACTS_FILE);
215
+ if (!existsSync(out)) return { missing: true, drifted: false };
216
+ const next = renderContracts(projectContracts(islandsDir, util), islandsDir);
217
+ return { missing: false, drifted: readFileSync(out, 'utf8') !== next };
218
+ }
219
+
220
+ /**
221
+ * The generated contracts as data, read from the file rather than recomputed.
222
+ *
223
+ * Eject needs the event names an island declares, and after the short form there is nowhere else to
224
+ * read them: the island file names its component and nothing more. Parsing the generated file keeps
225
+ * eject working off exactly what the build compiled.
226
+ */
227
+ export function readGeneratedContracts(islandsDir) {
228
+ const file = resolve(islandsDir, CONTRACTS_FILE);
229
+ if (!existsSync(file)) return {};
230
+ const text = readFileSync(file, 'utf8');
231
+ const start = text.indexOf('export const CONTRACTS');
232
+ if (start === -1) return {};
233
+ const out = {};
234
+ const re = /'([^']+)':\s*\{\s*input:\s*\[([^\]]*)\],\s*output:\s*\{([^}]*)\},\s*effects:\s*\[([\s\S]*?)\],\n/g;
235
+ for (const m of text.slice(start).matchAll(re)) {
236
+ const list = (body) => [...body.matchAll(/'([^']+)'/g)].map((x) => x[1]);
237
+ out[m[1]] = {
238
+ input: list(m[2]),
239
+ output: Object.fromEntries([...m[3].matchAll(/(\w+):\s*'([^']+)'/g)].map((x) => [x[1], x[2]])),
240
+ effects: readEffectEntries(m[4]),
241
+ };
242
+ }
243
+ return out;
244
+ }
@@ -0,0 +1,47 @@
1
+ // The `data-flow` judgement, shared by both lanes (the browser in playwright-lagoon.mjs and the
2
+ // browser-free `--fast` harness in runtime-harness.mjs) so they cannot disagree about one island.
3
+ //
4
+ // DISTINCT INPUTS MUST PRODUCE DISTINCT OUTPUT, and a scenario that renders NOTHING cannot be compared
5
+ // — unless it says so. `blank: 'why'` on a scenario declares that rendering nothing IS its answer (a
6
+ // banner silent when nothing is wrong, a palette closed). Declared, the empty render is a value like
7
+ // any other in the comparison — and it is ASSERTED: a blank scenario that renders something fails,
8
+ // so the declaration cannot go stale quietly. Undeclared, an empty render is still an error.
9
+ //
10
+ // This exists because two of motu's own checks disagreed: `data-flow` refused every empty scenario
11
+ // while `input-coverage` asked for exactly those states, and Peps (13 islands) and motu's host-app
12
+ // (2) were red on scenarios their authors had pinned on purpose. Deleting them was the only way out,
13
+ // and it threw away the state the input-coverage check had asked for.
14
+
15
+ /** The scenario's declared reason for rendering nothing, or null. An empty reason is no declaration. */
16
+ export const blankReason = (scenario) =>
17
+ typeof scenario?.blank === 'string' && scenario.blank.trim() !== '' ? scenario.blank.trim() : null;
18
+
19
+ /**
20
+ * @param {string[]} outputs the normalised render of each scenario, in order
21
+ * @param {object[]} scenarios
22
+ */
23
+ export function judgeRenders(outputs, scenarios) {
24
+ const name = (i) => scenarios[i]?.name ?? `#${i + 1}`;
25
+ const empty = []; // rendered nothing and did not say it would
26
+ const blankButRendered = []; // said it would render nothing, and rendered something
27
+ outputs.forEach((o, i) => {
28
+ const isEmpty = !o.trim().length;
29
+ const declared = blankReason(scenarios[i]) !== null;
30
+ if (isEmpty && !declared) empty.push(name(i));
31
+ if (!isEmpty && declared) blankButRendered.push(name(i));
32
+ });
33
+ const distinctOutputs = new Set(outputs).size;
34
+ // Declared-blank scenarios all promise the SAME render — nothing — so several of them are one
35
+ // expected value, not duplicate evidence. Counted once when asking "did any two scenarios collide?".
36
+ const blanks = scenarios.filter((s, i) => blankReason(s) !== null && !outputs[i]?.trim()).length;
37
+ const comparedCount = outputs.length - Math.max(0, blanks - 1);
38
+ const differentiates = empty.length === 0 && blankButRendered.length === 0 && distinctOutputs > 1;
39
+ return { differentiates, empty, blankButRendered, distinctOutputs, comparedCount };
40
+ }
41
+
42
+ /**
43
+ * Every input a scenario hands the island — its `seed` and what arrives `afterMount` — for the checks
44
+ * that ask about inputs rather than about WHEN they land (seed transport, input coverage, the keys a
45
+ * reset must forget). The later value wins, as it does on screen.
46
+ */
47
+ export const scenarioInputs = (scenario) => ({ ...(scenario?.seed ?? {}), ...(scenario?.afterMount ?? {}) });
@@ -0,0 +1,101 @@
1
+ // `sources` from an archipelago, read ONCE and by a parser.
2
+ //
3
+ // WHY THIS FILE EXISTS. Two commands read the same declaration, and each had grown its own regex over
4
+ // it: `verify` handled both forms, `integrate check` handled only `{ module, produces }` and silently
5
+ // skipped `week: weekSource`. So moving a region to the STRONGER declaration — the source imported by
6
+ // reference, which the type system checks — quietly turned off the check that says the page installs
7
+ // it. The better declaration got less checking, and nothing said so.
8
+ //
9
+ // That is what two hand-rolled readers of one shape always end up doing. `integration.mjs` had
10
+ // already reached this conclusion for the host's JSX ("PARSED, NOT MATCHED … ts-morph is already how
11
+ // every other host-source question here is answered; this was the last regex standing in the middle
12
+ // of it") — it was not the last: the archipelago side was still matched, in both files.
13
+ //
14
+ // The scars are worth keeping in view. `verify.mjs` blanks comments before matching, because an
15
+ // apostrophe in prose opens a string as far as a regex is concerned and "the week's missions" turned
16
+ // into a key named ` missions, so it comes...`. `\n \},` as a block terminator means the declaration
17
+ // must be indented exactly two spaces. Neither is a property of the language; both are properties of
18
+ // reading it with the wrong tool.
19
+ import { SyntaxKind } from 'ts-morph';
20
+ import { sourceFileAt } from './ts-project.mjs';
21
+
22
+ /** Strip the quotes ts-morph keeps on a string literal's text. */
23
+ const unquoted = (node) => (node?.getText?.() ?? '').replace(/^['"`]|['"`]$/g, '');
24
+
25
+ /**
26
+ * Where an identifier came from — the import that brought it in.
27
+ *
28
+ * A source declared by reference names its module exactly once, in the import, which is the fact the
29
+ * regex was reconstructing with a RegExp built from the identifier's own name.
30
+ */
31
+ function moduleOfIdentifier(sourceFile, name) {
32
+ for (const decl of sourceFile.getImportDeclarations()) {
33
+ const named = decl.getNamedImports().some((i) => (i.getAliasNode() ?? i.getNameNode()).getText() === name);
34
+ const dflt = decl.getDefaultImport()?.getText() === name;
35
+ if (named || dflt) return decl.getModuleSpecifierValue();
36
+ }
37
+ return null;
38
+ }
39
+
40
+ /**
41
+ * Every declared source of one archipelago file.
42
+ *
43
+ * Returns `{ [name]: { module, produces, byReference, reachesText } }` — both declaration forms, in
44
+ * one shape, so a caller cannot handle one and forget the other. `reachesText` is the raw initializer
45
+ * of `reaches` (or null), because its entries are a mix of strings and objects that each caller
46
+ * already knows how to read.
47
+ *
48
+ * An unparseable file yields `{}` rather than throwing: this answers "what does the region declare",
49
+ * and a file the host's own build accepts but ts-morph does not is not a question about sources.
50
+ */
51
+ export function declaredSourcesOf(archipelagoFile) {
52
+ let sf;
53
+ try {
54
+ sf = sourceFileAt(archipelagoFile, { allowJs: true });
55
+ } catch {
56
+ return {};
57
+ }
58
+
59
+ // The `sources:` property of the archipelago's config object. Found by NAME rather than by walking
60
+ // the call expression, because the config is written as `archipelago<…>()({ … } as const, { … })`
61
+ // and the shape of that wrapper is not this function's business.
62
+ const prop = sf
63
+ .getDescendantsOfKind(SyntaxKind.PropertyAssignment)
64
+ .find((p) => (p.getNameNode?.()?.getText?.() ?? '').replace(/['"]/g, '') === 'sources');
65
+ const obj = prop?.getInitializerIfKind?.(SyntaxKind.ObjectLiteralExpression);
66
+ if (!obj) return {};
67
+
68
+ const out = {};
69
+ for (const entry of obj.getProperties()) {
70
+ if (!entry.getNameNode) continue;
71
+ const name = (entry.getNameNode().getText() ?? '').replace(/['"]/g, '');
72
+ const init = entry.getInitializer?.();
73
+ if (!init) continue;
74
+
75
+ // `week: weekSource` — the source object itself. What it produces lives in the source file and is
76
+ // checked by the compiler there, so there is nothing to read here but the module.
77
+ if (init.getKind() === SyntaxKind.Identifier) {
78
+ const module = moduleOfIdentifier(sf, init.getText());
79
+ if (module) out[name] = { module, produces: [], byReference: true, reachesText: null };
80
+ continue;
81
+ }
82
+
83
+ // `revenue: { module: '…', produces: [...] }` — a claim about somebody else's code.
84
+ if (init.getKind() === SyntaxKind.ObjectLiteralExpression) {
85
+ const moduleProp = init.getProperty?.('module');
86
+ const module = unquoted(moduleProp?.getInitializer?.());
87
+ if (!module) continue;
88
+ const producesInit = init.getProperty?.('produces')?.getInitializer?.();
89
+ const produces = (producesInit?.getElements?.() ?? []).map((el) => unquoted(el));
90
+ const reaches = init.getProperty?.('reaches')?.getInitializer?.();
91
+ out[name] = {
92
+ module,
93
+ produces,
94
+ byReference: false,
95
+ // The INNER text of the array, which is what the existing effect readers take.
96
+ reachesText: reaches?.getText?.()?.replace(/^\[|\]$/g, '') ?? null,
97
+ };
98
+ }
99
+ }
100
+ return out;
101
+ }
@@ -0,0 +1,47 @@
1
+ // THE DOCK, INJECTED — one definition, three consumers.
2
+ //
3
+ // `motu lagoon serve` serves a built artifact, the Vite DEV server serves a module graph, and the
4
+ // motu host frames a published one. All three must draw the SAME dock: a copy per consumer is two
5
+ // chromes that drift, and the dev loop and every runtime check run through this one, so a divergence
6
+ // here is a divergence between what a person sees while working and what the checks assert on.
7
+ //
8
+ // It lived in `lagoon.mjs` and was therefore reachable only from `lagoon serve` — which is why
9
+ // `motu lagoon dev` had no dock at all while the documentation said it did.
10
+ import { motuDockCss, motuDockJs } from '@motujs/chrome/dock';
11
+
12
+ /** Append the dock to a page's HTML. It takes over only a lagoon it can drive; see the script below. */
13
+ export function injectDock(page) {
14
+ return `${page}
15
+ <style>${motuDockCss()}</style>
16
+ <script>
17
+ ${motuDockJs()}
18
+ (function () {
19
+ var tries = 0;
20
+ var take = function () {
21
+ if (tries++ > 60) return;
22
+ if (!window.__motuLagoonControl) return setTimeout(take, 200);
23
+ // The artifact still ships its own while both exist. Hidden, not removed: it owns that element.
24
+ // THE TWO DOCKS SHARE AN ID, and here they share a document as well — so the rule that hides the
25
+ // artifact's would hide the replacement with it. In the host that never came up: the artifact is
26
+ // in a frame and the new dock is in the shell. Mark ours, and hush only what is not ours.
27
+ var mounted = document.createElement('div');
28
+ mounted.setAttribute('data-motu-chrome', '');
29
+ document.body.appendChild(mounted);
30
+ motuMountDock({ mount: mounted, lagoonWindow: function () { return window; } });
31
+ var ours = mounted.querySelector('#tide');
32
+ if (ours) ours.setAttribute('data-hosted', '');
33
+ var hush = document.createElement('style');
34
+ hush.textContent = '#tide:not([data-hosted]){display:none!important}' +
35
+ // THE FLOATING TOOLBAR TOO. It used to be adopted by the in-page dock; with that gone it falls
36
+ // back to floating over the application, and its chips are already in the rig out here.
37
+ '#motu-toolbar{display:none!important}';
38
+ document.head.appendChild(hush);
39
+ // The served page is the lagoon itself, so the dock stands on this document and this document
40
+ // keeps the strip — the same reserve the host's shell makes around a framed one.
41
+ document.documentElement.dataset.motuDock =
42
+ window.matchMedia('(max-width: 760px)').matches ? 'bottom' : 'right';
43
+ };
44
+ take();
45
+ })();
46
+ </script>`;
47
+ }
@@ -0,0 +1,97 @@
1
+ // `contract.effects` / `reaches`, between the form people WRITE and the form checks COMPARE.
2
+ //
3
+ // Authored as objects — `{ table: 'shots', operation: 'select' }` — so a misspelled kind is a build
4
+ // error rather than an entry nothing recognises. Compared as a canonical STRING, because that is what
5
+ // the runtime records: the fake fetch tags every request at the moment it is made and has no view of
6
+ // anyone's declaration. Normalising the declaration to the observation, rather than the other way
7
+ // round, is what keeps `@motujs/runtime` free of this vocabulary entirely.
8
+ //
9
+ // The canonical forms, which are exactly `reachEntry()` in `postgrest-fetch.ts`:
10
+ //
11
+ // '@/lib/x' a host module — the bare form, unchanged
12
+ // 'scope:search'
13
+ // 'table:shots(select)' and 'table:shots' when no operation is named
14
+ // 'rpc:accept_shots'
15
+ // 'fn:notify'
16
+ // 'route:GET /api/x' and 'route:/api/x' when no method is named
17
+
18
+ /** The kinds that are NOT a host module. A bare entry has no prefix, which is what makes it a module. */
19
+ const KINDS = /^(scope|table|rpc|fn|route):/;
20
+
21
+ /** True for a canonical entry that names something other than a host module. */
22
+ export const isKinded = (canonical) => KINDS.test(String(canonical));
23
+
24
+ /** True for a canonical entry the runtime can observe — the data kinds, as opposed to scope/module. */
25
+ export const isDataKind = (canonical) => /^(table|rpc|fn|route):/.test(String(canonical));
26
+
27
+ /**
28
+ * One authored entry as its canonical string, or null when it is not a shape we know.
29
+ *
30
+ * `null` rather than a throw: this runs over source text the user is mid-edit in, and a check that
31
+ * crashes on a half-written declaration is worse than one that ignores it — the TYPE is what rejects a
32
+ * bad entry, and it does so at the line where it was written.
33
+ */
34
+ export function canonicalEffect(entry) {
35
+ if (typeof entry === 'string') return entry;
36
+ if (!entry || typeof entry !== 'object') return null;
37
+ if (entry.scope) return `scope:${entry.scope}`;
38
+ if (entry.rpc) return `rpc:${entry.rpc}`;
39
+ if (entry.fn) return `fn:${entry.fn}`;
40
+ if (entry.table) return entry.operation ? `table:${entry.table}(${entry.operation})` : `table:${entry.table}`;
41
+ if (entry.route) return entry.method ? `route:${entry.method} ${entry.route}` : `route:${entry.route}`;
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * Does a DECLARED entry account for an OBSERVED one?
47
+ *
48
+ * Equal, or the declaration is the same thing with its qualifier left off — `{ table: 'shots' }` covers
49
+ * every operation on that table, `{ route: '/api/x' }` every method on that route. Naming a dependency
50
+ * without pinning how it is used is a legitimate declaration, and the qualifier sits in a different
51
+ * place for each kind (a suffix for a table, a prefix for a route), which is why this is a function
52
+ * rather than a string comparison.
53
+ */
54
+ export function coversEffect(declared, observed) {
55
+ if (declared === observed) return true;
56
+ const d = String(declared);
57
+ const o = String(observed);
58
+ if (d.startsWith('table:') && o.startsWith('table:')) return o.replace(/\(.*\)$/, '') === d;
59
+ if (d.startsWith('route:') && o.startsWith('route:')) return o.replace(/^route:\S+ /, 'route:') === d;
60
+ return false;
61
+ }
62
+
63
+ /**
64
+ * The entries in an `effects: [...]` / `reaches: [...]` array, read from SOURCE TEXT.
65
+ *
66
+ * A tolerant reader rather than a parser: the array holds literals only — quoted strings and flat
67
+ * object literals of quoted values — because that is all the type admits. It is used on the island
68
+ * file, on a source module, and on the generated contracts, so the one implementation keeps those
69
+ * three from disagreeing about what a declaration says.
70
+ */
71
+ export function readEffectEntries(arrayBody) {
72
+ if (!arrayBody) return [];
73
+ const out = [];
74
+ for (const [, obj] of arrayBody.matchAll(/\{([^}]*)\}/g)) {
75
+ const fields = {};
76
+ for (const [, k, v] of obj.matchAll(/(\w+)\s*:\s*['"]([^'"]*)['"]/g)) fields[k] = v;
77
+ const canonical = canonicalEffect(fields);
78
+ if (canonical) out.push(canonical);
79
+ }
80
+ // Bare strings are whatever is quoted OUTSIDE an object literal — the module entries.
81
+ for (const [, str] of arrayBody.replace(/\{[^}]*\}/g, '').matchAll(/['"]([^'"]+)['"]/g)) out.push(str);
82
+ return out;
83
+ }
84
+
85
+ /** Serialise a canonical entry back to the AUTHORED form, for anything that generates a declaration. */
86
+ export function writeEffectEntry(canonical) {
87
+ const s = String(canonical);
88
+ if (!isKinded(s)) return `'${s}'`;
89
+ const [, kind, rest] = s.match(/^(\w+):(.*)$/);
90
+ if (kind === 'scope' || kind === 'rpc' || kind === 'fn') return `{ ${kind}: '${rest}' }`;
91
+ if (kind === 'table') {
92
+ const m = rest.match(/^(.*)\((.*)\)$/);
93
+ return m ? `{ table: '${m[1]}', operation: '${m[2]}' }` : `{ table: '${rest}' }`;
94
+ }
95
+ const m = rest.match(/^(\S+) (.*)$/);
96
+ return m ? `{ route: '${m[2]}', method: '${m[1]}' }` : `{ route: '${rest}' }`;
97
+ }