@motujs/cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/bundled/github/agents/archipelago-build.agent.md +298 -0
- package/bundled/github/agents/island-create.agent.md +261 -0
- package/bundled/github/agents/island-extract.agent.md +360 -0
- package/bundled/github/agents/island-locate.agent.md +208 -0
- package/bundled/github/agents/motu-checks.agent.md +158 -0
- package/bundled/github/agents/motu-evidence.agent.md +267 -0
- package/bundled/github/agents/motu-regions.agent.md +304 -0
- package/bundled/github/host-rules.md +43 -0
- package/bundled/github/prompts/archipelago-build.prompt.md +19 -0
- package/bundled/github/prompts/island-create.prompt.md +37 -0
- package/bundled/github/prompts/island-extract.prompt.md +59 -0
- package/bundled/github/prompts/island-locate.prompt.md +43 -0
- package/bundled/packages/adapters/angularjs/vite.mjs +43 -0
- package/bundled/packages/adapters/next/lagoon/next-stubs.tsx +108 -0
- package/bundled/packages/adapters/next/vite.mjs +121 -0
- package/bundled/packages/adapters/vite/vite.mjs +179 -0
- package/package.json +64 -3
- package/src/alias-loader.mjs +63 -0
- package/src/alias-register.mjs +28 -0
- package/src/asset-stub.cjs +3 -0
- package/src/asset-stub.mjs +5 -0
- package/src/cli.mjs +78 -0
- package/src/commands/adopt-root.mjs +194 -0
- package/src/commands/archipelago-init.mjs +314 -0
- package/src/commands/archipelago.mjs +138 -0
- package/src/commands/assurance.mjs +40 -0
- package/src/commands/check.mjs +346 -0
- package/src/commands/codegen.mjs +29 -0
- package/src/commands/coverage.mjs +358 -0
- package/src/commands/create.mjs +277 -0
- package/src/commands/defaults.mjs +129 -0
- package/src/commands/diff.mjs +595 -0
- package/src/commands/fixtures.mjs +153 -0
- package/src/commands/init.mjs +473 -0
- package/src/commands/integrate.mjs +208 -0
- package/src/commands/integration.mjs +1349 -0
- package/src/commands/lagoon-try.mjs +272 -0
- package/src/commands/lagoon.mjs +1419 -0
- package/src/commands/link.mjs +110 -0
- package/src/commands/region-coverage.mjs +604 -0
- package/src/commands/removal-check.mjs +878 -0
- package/src/commands/skills.mjs +156 -0
- package/src/commands/snapshot.mjs +456 -0
- package/src/commands/sync.mjs +38 -0
- package/src/commands/verify/island-runtime.mjs +581 -0
- package/src/commands/verify/island-static.mjs +586 -0
- package/src/commands/verify/island.mjs +135 -0
- package/src/commands/verify/reach.mjs +426 -0
- package/src/commands/verify/region-composition.mjs +391 -0
- package/src/commands/verify/region-config.mjs +330 -0
- package/src/commands/verify/region-flows.mjs +760 -0
- package/src/commands/verify/region-sources.mjs +516 -0
- package/src/commands/verify/region.mjs +271 -0
- package/src/commands/verify/report.mjs +338 -0
- package/src/commands/verify/screens-runtime.mjs +123 -0
- package/src/commands/verify.mjs +18 -0
- package/src/lagoon-build.mjs +25 -0
- package/src/lagoon-dev.mjs +32 -0
- package/src/lib/alias-core.mjs +49 -0
- package/src/lib/archipelagos.mjs +111 -0
- package/src/lib/assurance.mjs +144 -0
- package/src/lib/baselines.mjs +107 -0
- package/src/lib/bundlability.mjs +123 -0
- package/src/lib/changed.mjs +157 -0
- package/src/lib/component-props.mjs +145 -0
- package/src/lib/config.mjs +451 -0
- package/src/lib/contracts.mjs +244 -0
- package/src/lib/data-flow.mjs +47 -0
- package/src/lib/declared-sources.mjs +101 -0
- package/src/lib/dock-inject.mjs +47 -0
- package/src/lib/effects.mjs +97 -0
- package/src/lib/eject.mjs +715 -0
- package/src/lib/evidence-json.mjs +74 -0
- package/src/lib/expect-calls.mjs +53 -0
- package/src/lib/finding-memory.mjs +0 -0
- package/src/lib/flow-calls.mjs +53 -0
- package/src/lib/framework.mjs +98 -0
- package/src/lib/host-rules.mjs +55 -0
- package/src/lib/host-sources.mjs +121 -0
- package/src/lib/island-placement.mjs +64 -0
- package/src/lib/island-render.mjs +105 -0
- package/src/lib/islands.mjs +112 -0
- package/src/lib/lagoon-bundle.mjs +138 -0
- package/src/lib/lagoon-declares.mjs +553 -0
- package/src/lib/lagoon-materialize.mjs +217 -0
- package/src/lib/lagoon-vite.mjs +788 -0
- package/src/lib/live-url.mjs +53 -0
- package/src/lib/node-aliases.mjs +73 -0
- package/src/lib/operations.mjs +145 -0
- package/src/lib/provenance-plugin.mjs +74 -0
- package/src/lib/region-refs.mjs +40 -0
- package/src/lib/remote.mjs +171 -0
- package/src/lib/scaffold.mjs +606 -0
- package/src/lib/screens.mjs +266 -0
- package/src/lib/snapshots.mjs +85 -0
- package/src/lib/stubs.mjs +114 -0
- package/src/lib/ts-project.mjs +42 -0
- package/src/lib/util.mjs +626 -0
- package/src/lib/worlds.mjs +236 -0
- package/src/lib/write-if-changed.mjs +35 -0
- package/src/lib/writes-coverage.mjs +32 -0
- package/src/playwright-lagoon.mjs +2305 -0
- package/src/run.mjs +385 -0
- package/src/runtime-harness.mjs +225 -0
- package/README.md +0 -3
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
// A region's channels and sources: declared, live, and what they reach.
|
|
2
|
+
// Split out of commands/verify.mjs; verify.mjs re-exports the public surface.
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
import { readComponentContract } from '../../lib/component-props.mjs';
|
|
6
|
+
import { readGeneratedContracts } from '../../lib/contracts.mjs';
|
|
7
|
+
import { declaredSourcesOf } from '../../lib/declared-sources.mjs';
|
|
8
|
+
import { readEffectEntries } from '../../lib/effects.mjs';
|
|
9
|
+
import { exclusiveDeclared } from '../../lib/lagoon-declares.mjs';
|
|
10
|
+
import { regionRefsOf } from '../../lib/region-refs.mjs';
|
|
11
|
+
import { exclusiveProblems, undeclaredAlternatives } from '@motujs/core';
|
|
12
|
+
import { blankComments, islandComponentExport, islandComponentPath, lagoonViewports, names, paths, resolveAppImport } from '../../lib/util.mjs';
|
|
13
|
+
import { auditRegionLagoon } from '../../playwright-lagoon.mjs';
|
|
14
|
+
import { declaredWrites, readScenariosFor } from './region-flows.mjs';
|
|
15
|
+
import { environmentalCause, step } from './report.mjs';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What the STORE said while the region was being driven.
|
|
19
|
+
*
|
|
20
|
+
* `no-console-errors` runs on a page that only mounts the region — it never writes, so motu's own
|
|
21
|
+
* ownership guard ("key X is produced by island A, but it was written by B") could fire on every
|
|
22
|
+
* driven page and never reach a report. That is the shape of miss this exists for: the framework had
|
|
23
|
+
* already caught it, in a console nobody was reading.
|
|
24
|
+
*/
|
|
25
|
+
export function reportStoreComplaints(report, diagnostics, where) {
|
|
26
|
+
const motu = (diagnostics ?? []).filter((d) => /console\.error:\s*motu:/.test(d));
|
|
27
|
+
for (const line of [...new Set(motu)]) {
|
|
28
|
+
report.error('store-guard', `${line.replace(/^console\.error:\s*/, '')} (seen during the ${where})`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A channel that ORCHESTRATES must install the application's own logic, not re-implement it.
|
|
34
|
+
*
|
|
35
|
+
* The inbound seam is where a lagoon quietly becomes a second application. A channel that watches the
|
|
36
|
+
* region and answers with host-fed keys is doing what the PAGE does — debounce, page, derive, reset —
|
|
37
|
+
* and a copy of that is free to drift: a different page size in the preview than in production looks
|
|
38
|
+
* like a working preview for months. Both of this project's first channels were written that way, and
|
|
39
|
+
* nothing said a word.
|
|
40
|
+
*
|
|
41
|
+
* The rule is deliberately narrow, so it accuses only what it can see:
|
|
42
|
+
* REACTIVE (it subscribes to the store) + WRITES host-fed keys + imports NO application module.
|
|
43
|
+
* A channel that only publishes a constant is not orchestration and is not flagged; a channel that
|
|
44
|
+
* imports the app's source is doing the right thing by construction, whatever it does with it.
|
|
45
|
+
*/
|
|
46
|
+
export function channelSourceCheck(report, id, region) {
|
|
47
|
+
// WHERE THE CHANNELS ARE WRITTEN, in both shapes — and this check saw only the older one.
|
|
48
|
+
//
|
|
49
|
+
// It looked exclusively for `export const channels = { <id>: [ … ] }` in `src/lagoon.tsx`, the
|
|
50
|
+
// kind-first override map. Regions have since moved to a per-region module,
|
|
51
|
+
// `<lagoon>/src/regions/<id>.tsx`, holding `overridesFor(archipelago, { channels: [ … ] })` — so on
|
|
52
|
+
// every project in this repository the check found nothing and RETURNED SILENTLY. Not a skip, not a
|
|
53
|
+
// pass: no row at all, for the check that stops hand-written channels. It had been dormant since the
|
|
54
|
+
// authoring shape changed and nothing could have told you, which is the failure mode a silent return
|
|
55
|
+
// always is.
|
|
56
|
+
const perRegion = ['tsx', 'ts']
|
|
57
|
+
.map((ext) => resolve(paths.lagoonDir, 'src/regions', `${id}.${ext}`))
|
|
58
|
+
.find((f) => existsSync(f));
|
|
59
|
+
const overridesFile =
|
|
60
|
+
perRegion ??
|
|
61
|
+
['src/lagoon.tsx', 'src/lagoon.ts'].map((f) => resolve(paths.lagoonDir, f)).find((f) => existsSync(f));
|
|
62
|
+
if (!overridesFile) {
|
|
63
|
+
report.skip('channel-source', 'no lagoon overrides module — nothing declares channels for this region');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const overrides = stripComments(readFileSync(overridesFile, 'utf8'));
|
|
67
|
+
|
|
68
|
+
// The per-region module holds ONE region's channels, so the array is unqualified; the shared module
|
|
69
|
+
// keys them by id. Try the qualified form first, since the shared module also contains bare arrays.
|
|
70
|
+
const map = overrides.match(/export const channels[^=]*=\s*\{([\s\S]*?)\n\};/)?.[1];
|
|
71
|
+
const keyed = map?.match(new RegExp(`['"\`]?${id}['"\`]?\\s*:\\s*\\[([\\s\\S]*?)\\n \\]`))?.[1];
|
|
72
|
+
const bare = overrides.match(/\bchannels:\s*\[([\s\S]*?)\n\s*\],/)?.[1];
|
|
73
|
+
const list = keyed ?? bare;
|
|
74
|
+
if (!list) {
|
|
75
|
+
// SAID, not silent: a region with no channels is a fact worth one dim line, because the alternative
|
|
76
|
+
// is indistinguishable from this check being broken again.
|
|
77
|
+
report.skip('channel-source', `no channels declared for "${id}" in ${paths.rel(overridesFile)}`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Split on top-level commas: each element is one channel, and the question is what BUILT it.
|
|
82
|
+
const entries = [];
|
|
83
|
+
let depth = 0;
|
|
84
|
+
let current = '';
|
|
85
|
+
for (const ch of list) {
|
|
86
|
+
if ('([{'.includes(ch)) depth++;
|
|
87
|
+
else if (')]}'.includes(ch)) depth--;
|
|
88
|
+
if (ch === ',' && depth === 0) {
|
|
89
|
+
entries.push(current);
|
|
90
|
+
current = '';
|
|
91
|
+
} else current += ch;
|
|
92
|
+
}
|
|
93
|
+
if (current.trim()) entries.push(current);
|
|
94
|
+
|
|
95
|
+
let vouched = 0;
|
|
96
|
+
for (const entry of entries.map((e) => e.trim()).filter(Boolean)) {
|
|
97
|
+
// Built here, by the tool.
|
|
98
|
+
if (/^(channelFrom|rawChannel)\s*\(/.test(entry)) {
|
|
99
|
+
vouched++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// Or an identifier from a file that builds it with the tool.
|
|
103
|
+
const file = /^[A-Za-z_$][\w$]*$/.test(entry) ? channelFileOf(overrides, overridesFile, entry) : null;
|
|
104
|
+
if (file && /\b(channelFrom|rawChannel)\s*\(/.test(stripComments(readFileSync(file, 'utf8')))) {
|
|
105
|
+
vouched++;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
report.error(
|
|
109
|
+
'channel-source',
|
|
110
|
+
`a channel of "${id}" is not built by the tool — write \`channelFrom({ to, id, args })\`, whose copy ` +
|
|
111
|
+
`into the region comes from the archipelago's \`sources\`, or \`rawChannel('<why>', fn)\` if this seam ` +
|
|
112
|
+
`genuinely is not a source. Hand-written channels forget keys, rename them in transit, and derive ` +
|
|
113
|
+
`what the page already derives — all three have happened here.`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (vouched === entries.length && vouched > 0) {
|
|
117
|
+
report.ok('channel-source', 'every channel is built from a declared source', { n: vouched, of: 'channel(s)' });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* DID THE COMPONENT ACTUALLY EMIT? The half no CLI check could reach until now.
|
|
123
|
+
*
|
|
124
|
+
* `wiring-live` fires each declared output itself, and a flow's `emit` goes through the same seam, so
|
|
125
|
+
* both prove the region APPLIES a write and neither can see a component that stopped producing it.
|
|
126
|
+
* Measured on a real region: silencing a component's `onProgress` changed no check at any tier, even
|
|
127
|
+
* though a flow drove that very event. The seam lens shows it — its region sheet has always had a
|
|
128
|
+
* "never moved" column — and the lens is a panel a human opens. Agents read the CLI, so the one signal
|
|
129
|
+
* that catches this was human-only.
|
|
130
|
+
*
|
|
131
|
+
* WHY IT IS NARROW, and must be. Only outputs the component invokes from an EFFECT are checked: those
|
|
132
|
+
* must fire when the island simply renders, so their absence is a fact. An output fired from a click
|
|
133
|
+
* handler legitimately never fires in a render-only pass, and demanding it would flag every
|
|
134
|
+
* well-behaved island in the project. `calledFromEffect` answers "provably effect-driven" and reports
|
|
135
|
+
* anything it cannot prove as not — costing a missed finding rather than a false one.
|
|
136
|
+
*
|
|
137
|
+
* RUNTIME, and the LAST mile: it needs a browser, a mounted region and its effects to have run, so it
|
|
138
|
+
* belongs with the punctual gate that runs when a region is done — never in the fast loop, which has
|
|
139
|
+
* no browser to observe.
|
|
140
|
+
*
|
|
141
|
+
* A WARNING, like every check that arrives after the regions it judges.
|
|
142
|
+
*/
|
|
143
|
+
export function emittedLiveCheck(report, id, renderOutputs) {
|
|
144
|
+
const islands = declaredWrites(id);
|
|
145
|
+
if (!islands.length || !Array.isArray(renderOutputs)) return;
|
|
146
|
+
|
|
147
|
+
const generated = readGeneratedContracts(paths.islandsDir);
|
|
148
|
+
const fired = new Set(renderOutputs.map((o) => `${o.slot}\u0000${o.event}`));
|
|
149
|
+
|
|
150
|
+
const dead = [];
|
|
151
|
+
let checked = 0;
|
|
152
|
+
for (const island of islands) {
|
|
153
|
+
const tag = island.element;
|
|
154
|
+
const contract = generated[tag];
|
|
155
|
+
if (!contract) continue;
|
|
156
|
+
const { kebab, pascal } = names(String(tag).replace(new RegExp(`^${paths.tagPrefix ?? 'x-'}`), ''));
|
|
157
|
+
const componentFile = islandComponentPath(kebab, pascal);
|
|
158
|
+
const comp = componentFile ? readComponentContract(componentFile, islandComponentExport(kebab, pascal)) : null;
|
|
159
|
+
if (!comp?.output) continue;
|
|
160
|
+
// prop -> event, from the generated contract; prop -> effect-driven, from the component itself.
|
|
161
|
+
const effectProps = new Set(comp.output.filter((o) => o.effectDriven).map((o) => o.prop));
|
|
162
|
+
for (const [prop, event] of Object.entries(contract.output ?? {})) {
|
|
163
|
+
if (!effectProps.has(prop)) continue;
|
|
164
|
+
if (!island.writes?.[event]) continue;
|
|
165
|
+
checked++;
|
|
166
|
+
if (!fired.has(`${island.slot}\u0000${event}`)) dead.push({ slot: island.slot, prop, event });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!checked) return;
|
|
171
|
+
if (!dead.length) {
|
|
172
|
+
report.ok('emitted-live', `${checked} effect-driven output(s) fired while the region rendered`, {
|
|
173
|
+
n: checked,
|
|
174
|
+
of: 'effect-driven output(s)',
|
|
175
|
+
});
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
report.warn(
|
|
179
|
+
'emitted-live',
|
|
180
|
+
`${dead.length}/${checked} declared output(s) never fired while the region rendered: ` +
|
|
181
|
+
`${dead.map((d) => `${d.slot}.${d.prop} (on "${d.event}")`).join(', ')} — the component calls it from an effect, so ` +
|
|
182
|
+
`rendering should produce it. A flow that emits the same event proves only that the region APPLIES the write; ` +
|
|
183
|
+
`this says nothing produced it.`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Does the region FIT, and does axe find anything in the composed page.
|
|
189
|
+
*
|
|
190
|
+
* Reported per viewport rather than as one verdict: "the region overflows" is not actionable, and the
|
|
191
|
+
* width it starts at is the whole finding.
|
|
192
|
+
*/
|
|
193
|
+
export async function regionAuditCheck(report, id, port) {
|
|
194
|
+
const viewports = lagoonViewports();
|
|
195
|
+
if (!viewports.length) return;
|
|
196
|
+
// The states the flows establish, deduped — the same list `archipelago snapshot` pictures, so the
|
|
197
|
+
// page is audited in the shapes somebody thought worth driving rather than in one arbitrary one.
|
|
198
|
+
const seen = new Set();
|
|
199
|
+
const states = [];
|
|
200
|
+
for (const f of readScenariosFor(id)) {
|
|
201
|
+
const key = JSON.stringify(f.seed ?? {});
|
|
202
|
+
if (seen.has(key)) continue;
|
|
203
|
+
seen.add(key);
|
|
204
|
+
states.push({ name: f.name ?? 'flow', seed: f.seed ?? {} });
|
|
205
|
+
}
|
|
206
|
+
let out;
|
|
207
|
+
try {
|
|
208
|
+
out = await step('region-audit', () => auditRegionLagoon({ id, port, states, viewports }));
|
|
209
|
+
} catch (err) {
|
|
210
|
+
report[environmentalCause(err) ? 'inconclusive' : 'warn']('region-responsive', `could not audit the region: ${err.message}`);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// A LAYOUT CANNOT BE JUDGED ON AN UNSTYLED PAGE. The runtime region lane renders through a mount
|
|
215
|
+
// path that never inserts the island stylesheet, so every box is block-level and nothing ever
|
|
216
|
+
// overflows: this reported "fits every declared viewport" about a page a human had just watched
|
|
217
|
+
// scroll sideways by 197px. A false green is worse than no check, so it says what it could not see.
|
|
218
|
+
if (out.measurements.some((m) => m.styled === false)) {
|
|
219
|
+
report.inconclusive(
|
|
220
|
+
'region-responsive',
|
|
221
|
+
'the region rendered WITHOUT the island stylesheet, so its layout cannot be judged here — ' +
|
|
222
|
+
'every box is block-level and nothing overflows by construction. Look at the published region ' +
|
|
223
|
+
'(`motu lagoon publish --remote`) or `motu archipelago snapshot`, which render it styled.',
|
|
224
|
+
);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const over = out.measurements.filter((m) => m.overflow > 1);
|
|
228
|
+
if (!over.length) {
|
|
229
|
+
report.ok('region-responsive', `the composed page fits every declared viewport (${viewports.map((v) => `${v.name} ${v.width}px`).join(', ')})`, {
|
|
230
|
+
n: out.measurements.length,
|
|
231
|
+
of: 'measurement(s)',
|
|
232
|
+
});
|
|
233
|
+
} else {
|
|
234
|
+
// Narrowest first: that is where it starts, and the wider ones are usually the same cause.
|
|
235
|
+
const worst = [...over].sort((a, b) => a.width - b.width);
|
|
236
|
+
report.error(
|
|
237
|
+
'region-responsive',
|
|
238
|
+
`the composed page overflows at ${worst.length}/${out.measurements.length} viewport(s): ` +
|
|
239
|
+
`${worst.map((m) => `${m.viewport} ${m.width}px by ${m.overflow}px${m.scenario !== 'default' ? ` (${m.scenario})` : ''}`).join(', ')} ` +
|
|
240
|
+
`— every island fits on its own; this is the ARRANGEMENT, which no island check can see`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const severe = (out.violations ?? []).filter((v) => v.impact === 'critical' || v.impact === 'serious');
|
|
245
|
+
if (!out.violations?.length) {
|
|
246
|
+
report.ok('region-a11y', 'axe finds nothing in the composed page', { n: 1, of: 'axe pass' });
|
|
247
|
+
} else {
|
|
248
|
+
for (const v of severe) report.warn('region-a11y', `${v.impact}: ${v.help} — ${v.target} (${v.nodes} node(s))`);
|
|
249
|
+
if (!severe.length) report.ok('region-a11y', `${out.violations.length} minor axe finding(s), none serious`, { n: out.violations.length, of: 'finding(s)' });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* PROVENANCE: what the channels really produced, against what the region declared.
|
|
255
|
+
*
|
|
256
|
+
* `sources` is a static promise — "these keys come from that source". This is the runtime half, the
|
|
257
|
+
* same idea as `wiring-live` on the island side: a declaration nobody can see firing is one nobody can
|
|
258
|
+
* trust. A channel writing a key no source claims is an ERROR; a source no channel produced is a
|
|
259
|
+
* WARNING, because seeding it in the lagoon is legitimate.
|
|
260
|
+
*/
|
|
261
|
+
export function sourcesLiveCheck(report, id, channels, region, held = []) {
|
|
262
|
+
const declared = declaredSources(id);
|
|
263
|
+
if (!Object.keys(declared).length || !channels) return;
|
|
264
|
+
const claimed = new Map();
|
|
265
|
+
for (const [name, src] of Object.entries(declared)) for (const key of src.produces) claimed.set(key, name);
|
|
266
|
+
// A source the region REFERENCES carries its keys in the source file, not here; the compiler checks
|
|
267
|
+
// those. What is checkable at runtime is that nothing OUTSIDE the declared set was written.
|
|
268
|
+
const byReference = Object.values(declared).some((src) => src.byReference);
|
|
269
|
+
|
|
270
|
+
const written = new Set();
|
|
271
|
+
for (const channel of channels) {
|
|
272
|
+
const stray = claimed.size ? channel.keys.filter((k) => !claimed.has(k)) : [];
|
|
273
|
+
for (const key of channel.keys) written.add(key);
|
|
274
|
+
if (stray.length && !byReference) {
|
|
275
|
+
report.error(
|
|
276
|
+
'sources-live',
|
|
277
|
+
`channel "${channel.name}" wrote ${stray.join(', ')}, which no declared source claims — either the ` +
|
|
278
|
+
`region's \`sources\` are incomplete, or this channel is orchestrating outside them`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const islandOwned = region ? producedKeysOf(region) : new Set();
|
|
283
|
+
const silent = [...claimed.keys()].filter((k) => !written.has(k) && !islandOwned.has(k));
|
|
284
|
+
// PER SOURCE, and split by whether the keys hold anything — "seeded, or dead?" asked the reader to
|
|
285
|
+
// go and find out, and the two answers call for opposite actions. Seeded means the lagoon writes
|
|
286
|
+
// the answer down: the region renders, every check passes, and the page's own derivation is not
|
|
287
|
+
// exercised until production. Installing the source (a channel over the same fixtures, the way
|
|
288
|
+
// `week` and `networkStats` do it) is what turns a preview into evidence about the app's code.
|
|
289
|
+
const holds = new Set(held);
|
|
290
|
+
const bySource = new Map();
|
|
291
|
+
for (const key of silent) {
|
|
292
|
+
const name = claimed.get(key);
|
|
293
|
+
const entry = bySource.get(name) ?? { keys: [], module: declared[name]?.module };
|
|
294
|
+
entry.keys.push(key);
|
|
295
|
+
bySource.set(name, entry);
|
|
296
|
+
}
|
|
297
|
+
for (const [name, entry] of bySource) {
|
|
298
|
+
const anyHeld = entry.keys.some((k) => holds.has(k));
|
|
299
|
+
const where = entry.module ? ` (${entry.module})` : '';
|
|
300
|
+
if (anyHeld) {
|
|
301
|
+
report.warn(
|
|
302
|
+
'sources-live',
|
|
303
|
+
`source "${name}"${where} is SEEDED here, not installed: ${entry.keys.join(', ')} ` +
|
|
304
|
+
`${entry.keys.length > 1 ? 'hold values' : 'holds a value'} no channel produced. The region renders and ` +
|
|
305
|
+
`the page's own derivation is never exercised — ` +
|
|
306
|
+
`install the source with \`channelFrom({ to, id: '${name}', args: [...] })\` and drop them from the seed. ` +
|
|
307
|
+
// THE CALL IS NOT THE DESIGN. This line used to end at the channelFrom signature, which
|
|
308
|
+
// tells you what to type and nothing about what to build — and the thing to build is the
|
|
309
|
+
// source, not the channel. Whoever reads this is usually mid-migration and has never
|
|
310
|
+
// written one.
|
|
311
|
+
`That means extracting a SOURCE: a port the page and the lagoon fill differently, a factory ` +
|
|
312
|
+
`holding the state with subscribe/getState, and \`intents\` for what islands ask the host. ` +
|
|
313
|
+
`See docs/05-archipelagos-and-regions.md "Writing a source"`,
|
|
314
|
+
);
|
|
315
|
+
} else {
|
|
316
|
+
report.warn(
|
|
317
|
+
'sources-live',
|
|
318
|
+
`source "${name}"${where} produced nothing and its keys hold nothing: ${entry.keys.join(', ')} — ` +
|
|
319
|
+
`declared, and fed by neither a channel nor the seed`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const live = [...written].filter((k) => claimed.has(k) || byReference);
|
|
324
|
+
if (live.length) report.ok('sources-live', `${live.length} key(s) produced by a declared source at runtime`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Comments blanked, because an apostrophe in prose opens a string as far as a regex is concerned —
|
|
328
|
+
* "the week's missions" inside a `produces` array became a key named ` missions, so it comes...`. */
|
|
329
|
+
export const stripComments = blankComments;
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The text of the `factory:` belonging to one `channelFrom({ … id: '<id>' … })`, brace-balanced.
|
|
333
|
+
*
|
|
334
|
+
* Scoped deliberately: the question is not whether the app's source is mentioned in the file, it is
|
|
335
|
+
* whether the factory INSTALLS it.
|
|
336
|
+
*/
|
|
337
|
+
export function factoryBody(text, id) {
|
|
338
|
+
// The CALL, not the word: `import { channelFrom }` matched first, and balancing from the next paren
|
|
339
|
+
// walked into whatever function happened to be declared above the call — which reported the real
|
|
340
|
+
// channel as never installing its source.
|
|
341
|
+
const call = /\bchannelFrom\s*\(/.exec(text);
|
|
342
|
+
if (!call) return null;
|
|
343
|
+
const open = call.index + call[0].length - 1;
|
|
344
|
+
let depth = 0;
|
|
345
|
+
let end = -1;
|
|
346
|
+
for (let i = open; i < text.length; i++) {
|
|
347
|
+
if (text[i] === '(') depth++;
|
|
348
|
+
else if (text[i] === ')' && --depth === 0) {
|
|
349
|
+
end = i;
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const spec = text.slice(open, end === -1 ? undefined : end);
|
|
354
|
+
if (!new RegExp(`\\bid:\\s*'${id}'`).test(spec)) return null;
|
|
355
|
+
const factory = spec.indexOf('factory');
|
|
356
|
+
return factory === -1 ? null : spec.slice(factory);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* `sources` as data, straight from the archipelago file.
|
|
361
|
+
*
|
|
362
|
+
* PARSED, NOT MATCHED, and shared with `integrate check` — which had its own regex over the same
|
|
363
|
+
* declaration, handling only the `{ module, produces }` form. Two readers of one shape drift, and
|
|
364
|
+
* this pair had: moving a region to the by-reference form turned off the integration check that says
|
|
365
|
+
* the page installs the source, silently. What is left here is the part that is this command's own:
|
|
366
|
+
* where a by-reference source's `reaches` comes from.
|
|
367
|
+
*/
|
|
368
|
+
export function declaredSources(id) {
|
|
369
|
+
const file = paths.archipelagoFile(id);
|
|
370
|
+
const out = {};
|
|
371
|
+
for (const [name, src] of Object.entries(declaredSourcesOf(file))) {
|
|
372
|
+
out[name] = src.byReference
|
|
373
|
+
? { ...src, reaches: reachesOfSourceModule(src.module, file) }
|
|
374
|
+
: { ...src, reaches: readEffectEntries(src.reachesText ?? '') };
|
|
375
|
+
}
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* A by-reference source's `reaches`, read from the SOURCE MODULE.
|
|
381
|
+
*
|
|
382
|
+
* `shots: shotsSource` puts the declaration in the app's own file, beside `produces` — one place that
|
|
383
|
+
* says what this source hands over and what it touches. The archipelago cannot see either; the
|
|
384
|
+
* compiler checks `produces`, and this reads `reaches` the same way `declaredSources` reads a module
|
|
385
|
+
* specifier: from the one place it is written.
|
|
386
|
+
*/
|
|
387
|
+
export function reachesOfSourceModule(module, fromFile) {
|
|
388
|
+
try {
|
|
389
|
+
// `resolveAppImport` takes the importing file (a relative specifier needs it) and returns the path
|
|
390
|
+
// WITHOUT an extension, so the candidates have to be tried here.
|
|
391
|
+
const base = resolveAppImport(fromFile, module);
|
|
392
|
+
const file = base && ['.ts', '.tsx', '/index.ts', ''].map((e) => `${base}${e}`).find((f) => existsSync(f));
|
|
393
|
+
if (!file) return [];
|
|
394
|
+
const text = stripComments(readFileSync(file, 'utf8'));
|
|
395
|
+
return readEffectEntries(text.match(/reaches:\s*\[([\s\S]*?)\]/)?.[1] ?? '');
|
|
396
|
+
} catch {
|
|
397
|
+
return [];
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The file a channel identifier resolves to, or null. */
|
|
402
|
+
export function channelFileOf(overrides, overridesFile, name) {
|
|
403
|
+
const spec = overrides.match(new RegExp(`import\\s*\\{[^}]*\\b${name}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`))?.[1];
|
|
404
|
+
if (!spec || !spec.startsWith('.')) return null;
|
|
405
|
+
return (
|
|
406
|
+
['', '.ts', '.tsx']
|
|
407
|
+
.map((ext) => resolve(dirname(overridesFile), spec.replace(/\.js$/, ext || '.ts')))
|
|
408
|
+
.find((f) => existsSync(f)) ?? null
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Every key the region's islands bind (the map form's targets included). */
|
|
413
|
+
export function bindKeysOf(region) {
|
|
414
|
+
const text = stripComments(readFileSync(paths.archipelagoFile(region.id), 'utf8'));
|
|
415
|
+
const keys = [];
|
|
416
|
+
for (const m of text.matchAll(/bind:\s*\[([\s\S]*?)\]/g)) {
|
|
417
|
+
// `^\s*` as well as a delimiter: the FIRST bare key sits right after the `[` the outer match
|
|
418
|
+
// consumed, so anchoring only on `[,{` silently dropped one key per island — `members` among them.
|
|
419
|
+
for (const [, bare] of m[1].matchAll(/(?:^\s*|[[,{]\s*)'([^']+)'/g)) keys.push(bare);
|
|
420
|
+
for (const [, target] of m[1].matchAll(/\w+:\s*'([^']+)'/g)) keys.push(target);
|
|
421
|
+
}
|
|
422
|
+
return keys;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Every key an island of the region produces. */
|
|
426
|
+
export function producedKeysOf(region) {
|
|
427
|
+
const keys = new Set();
|
|
428
|
+
for (const island of region.islands) {
|
|
429
|
+
for (const target of Object.values(island.writes ?? {})) {
|
|
430
|
+
if (typeof target === 'string') keys.add(target);
|
|
431
|
+
else for (const key of Object.values(target)) keys.add(key);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return keys;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** One region's checks, as data — see `runIslandVerify`. */
|
|
438
|
+
/**
|
|
439
|
+
* A region inside a region: `slots: { prop: { region: '<id>' } }`.
|
|
440
|
+
*
|
|
441
|
+
* Refused, not rendered as a default: a reference to an id no archipelago has, one that leads back to
|
|
442
|
+
* the region making it (a region cannot contain itself), or one from a region with no `root` (there is
|
|
443
|
+
* no prop for it to fill). The verdict is `regionRefProblems` — the function the lagoon uses to refuse
|
|
444
|
+
* the placeholder — so the check and the preview give one answer.
|
|
445
|
+
*
|
|
446
|
+
* Says nothing on a region that neither references nor is referenced: that is nearly every region,
|
|
447
|
+
* and a row there would be a check that examined nothing reporting as one that passed.
|
|
448
|
+
*/
|
|
449
|
+
export function regionRefCheck(report, id) {
|
|
450
|
+
const { refs, problems, referencedBy, unreadable } = regionRefsOf(id, paths.archipelagosDir);
|
|
451
|
+
if (unreadable) {
|
|
452
|
+
report.warn('region-ref', `${paths.rel(paths.archipelagoFile(id))} could not be parsed, so its region references were not read`);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
for (const p of problems) report.error('region-ref', p.message);
|
|
456
|
+
const entries = Object.entries(refs);
|
|
457
|
+
if (entries.length && !problems.length) {
|
|
458
|
+
report.ok(
|
|
459
|
+
'region-ref',
|
|
460
|
+
`${entries.map(([prop, ref]) => `${prop} → region ${ref}`).join(', ')} — a separate region: its islands, keys ` +
|
|
461
|
+
`and flows are its own and are not counted here; the lagoon shows a placeholder until it is loaded`,
|
|
462
|
+
{ n: entries.length, of: 'region reference(s)' },
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
if (referencedBy.length) {
|
|
466
|
+
report.ok('region-ref', `fills ${referencedBy.join(', ')} — composed inside another region, verified on its own`, {
|
|
467
|
+
n: referencedBy.length,
|
|
468
|
+
of: 'slot(s) this region fills',
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* ALTERNATIVES: `exclusive: [['accept', 'connect']]` — root props of which at most one is non-null.
|
|
475
|
+
*
|
|
476
|
+
* The guarantee itself is the compiler's (`RootPropsOf` takes one union per group) and the runtime's
|
|
477
|
+
* (`<X.Root>` reports a cast that got past it). What is left for a static check is the DECLARATION:
|
|
478
|
+
* a group on a region with no root, a group of one, a prop in two groups — and the either/or the
|
|
479
|
+
* region describes with `when`/`unless` but never declares, which the lagoon follows and nothing holds
|
|
480
|
+
* the page to.
|
|
481
|
+
*
|
|
482
|
+
* Says nothing on a region that declares no group and no `when`/`unless` pair: that is nearly every
|
|
483
|
+
* region, and a row there would be a check that examined nothing reporting as one that passed.
|
|
484
|
+
*/
|
|
485
|
+
export function exclusiveCheck(report, id) {
|
|
486
|
+
const file = paths.archipelagoFile(id);
|
|
487
|
+
const declared = exclusiveDeclared(file);
|
|
488
|
+
if (declared === null) {
|
|
489
|
+
if (existsSync(file)) report.warn('exclusive', `${paths.rel(file)} could not be parsed, so its exclusive groups were not read`);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
for (const text of declared.unreadable) {
|
|
493
|
+
report.error(
|
|
494
|
+
'exclusive',
|
|
495
|
+
`exclusive group ${text} is not an array of string literals — it cannot be read, so it is not checked. ` +
|
|
496
|
+
`Write the prop names inline: \`exclusive: [['a', 'b']]\``,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
const problems = exclusiveProblems(declared);
|
|
500
|
+
for (const p of problems) report.error('exclusive', p.message);
|
|
501
|
+
for (const alt of undeclaredAlternatives(declared)) {
|
|
502
|
+
report.warn(
|
|
503
|
+
'exclusive',
|
|
504
|
+
`\`${alt.when}\` (when: '${alt.key}') and \`${alt.unless}\` (unless: '${alt.key}') are alternatives the lagoon ` +
|
|
505
|
+
`follows and nothing holds the page to — add \`exclusive: [['${alt.when}', '${alt.unless}']]\` and the page ` +
|
|
506
|
+
`cannot pass both`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
if (declared.exclusive.length && !problems.length && !declared.unreadable.length) {
|
|
510
|
+
report.ok(
|
|
511
|
+
'exclusive',
|
|
512
|
+
`${declared.exclusive.map((g) => g.join(' | ')).join(', ')} — at most one of each, held by the type of the region's Root`,
|
|
513
|
+
{ n: declared.exclusive.length, of: 'exclusive group(s)' },
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
}
|