@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,135 @@
|
|
|
1
|
+
// `motu island verify` — the command and the per-island run `check` composes.
|
|
2
|
+
// Split out of commands/verify.mjs; verify.mjs re-exports the public surface.
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { listIslands } from '../../lib/islands.mjs';
|
|
5
|
+
import { LEGACY_FIT, islandComponentExport, islandComponentPath, names, paths } from '../../lib/util.mjs';
|
|
6
|
+
import { a11yCheck, adapterChecks, inputCoverageCheck, readScenarios, responsiveCheck, runtimeCheckBrowser, runtimeCheckFast, runtimeDifferentiationCheck, runtimeErrorCheck, seedTransportCheck } from './island-runtime.mjs';
|
|
7
|
+
import { configChecks, cssChecks, islandOwnCss, staticChecks } from './island-static.mjs';
|
|
8
|
+
import { reportBrowserOverride } from './reach.mjs';
|
|
9
|
+
import { makeReport, printReport, printSweep, setProgressScope, setVerbose, summaryOf, sweepExitCode, verifyExitCode } from './report.mjs';
|
|
10
|
+
|
|
11
|
+
export async function verifyCommand(argv) {
|
|
12
|
+
const name = argv._[0];
|
|
13
|
+
if (!name && !argv.all) {
|
|
14
|
+
console.error('usage: motu island verify <name|--all> [--no-runtime] [--fast] [--standalone] [--json]');
|
|
15
|
+
process.exit(2);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// --all: every island in the project, one after another. Sequential on purpose — each runtime check
|
|
19
|
+
// boots a lagoon on its own port, and a CI box does not thank you for fourteen browsers at once.
|
|
20
|
+
if (argv.all) {
|
|
21
|
+
const islands = listIslands(paths.islandsDir);
|
|
22
|
+
const results = [];
|
|
23
|
+
for (const island of islands) results.push(await runIslandVerify(argv, island.kebab));
|
|
24
|
+
if (argv.json) {
|
|
25
|
+
console.log(JSON.stringify({ islands: results.map(summaryOf) }, null, 2));
|
|
26
|
+
} else {
|
|
27
|
+
printSweep('motu island verify — all islands', results);
|
|
28
|
+
}
|
|
29
|
+
process.exit(sweepExitCode(results));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const result = await runIslandVerify(argv, name);
|
|
33
|
+
if (argv.json) {
|
|
34
|
+
console.log(JSON.stringify({ island: result.kebab, tag: result.tag, pass: result.errors.length === 0, findings: result.report.findings }, null, 2));
|
|
35
|
+
} else {
|
|
36
|
+
printReport(result.report, `motu island verify — ${result.kebab} (${result.tag})`, result.errors, result.warns);
|
|
37
|
+
}
|
|
38
|
+
process.exit(verifyExitCode(result));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One island's checks, as data — printing and exiting belong to the caller (see `--all`). */
|
|
42
|
+
export async function runIslandVerify(argv, name) {
|
|
43
|
+
setVerbose(argv?.verbose);
|
|
44
|
+
const { pascal, kebab, tag } = names(name);
|
|
45
|
+
setProgressScope(kebab);
|
|
46
|
+
// Follow element.ts to the component it mounts: on a React host the island wraps a component the
|
|
47
|
+
// app already owns, which does not live under ui/.
|
|
48
|
+
const componentPath = islandComponentPath(kebab, pascal);
|
|
49
|
+
// What the component is actually CALLED where it lives — see islandComponentExport.
|
|
50
|
+
const componentName = islandComponentExport(kebab, pascal);
|
|
51
|
+
const uiDir = paths.uiDir(kebab);
|
|
52
|
+
const isReactIsland = existsSync(componentPath);
|
|
53
|
+
// An AngularJS island has no React `.tsx` — its component is a `*.ng.ts` spec (in ui/<kebab>/).
|
|
54
|
+
const isAdapterIsland =
|
|
55
|
+
!isReactIsland &&
|
|
56
|
+
existsSync(uiDir) &&
|
|
57
|
+
readdirSync(uiDir).some((f) => f.endsWith('.ng.ts'));
|
|
58
|
+
const report = makeReport();
|
|
59
|
+
if (!isReactIsland && !isAdapterIsland) {
|
|
60
|
+
report.error('component', `no island component at ${paths.rel(componentPath)}`);
|
|
61
|
+
return { kebab, tag, report, errors: report.findings, warns: [] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let componentProps = null;
|
|
65
|
+
let resolvedTag = tag;
|
|
66
|
+
if (isReactIsland) {
|
|
67
|
+
componentProps = staticChecks(report, componentPath, componentName);
|
|
68
|
+
resolvedTag = configChecks(report, kebab, componentName, tag, argv.standalone, componentProps, componentPath) ?? tag;
|
|
69
|
+
} else {
|
|
70
|
+
// A SKIP, NOT AN OK. This says the React-only checks did not run — which is the report model's own
|
|
71
|
+
// definition of a skip ("a rule silently reported as passing is indistinguishable from a rule that
|
|
72
|
+
// ran"). Reported as `ok` it added a green tick for checks that never happened.
|
|
73
|
+
report.skip('adapter-island', `AngularJS island (no React component) — the React checks do not apply; running config-lite + adapter + runtime`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Adapter-owned checks (e.g. the AngularJS host-scope contract) fold in regardless of island kind.
|
|
77
|
+
await adapterChecks(report, kebab, isReactIsland ? componentPath : null);
|
|
78
|
+
|
|
79
|
+
// CSS lint of the island's OWN stylesheet if it owns one (opt-in; the shared sheet is linted at
|
|
80
|
+
// region scope by `motu archipelago verify`).
|
|
81
|
+
const ownCss = islandOwnCss(kebab);
|
|
82
|
+
if (ownCss) cssChecks(report, readFileSync(ownCss, 'utf8'), paths.rel(ownCss));
|
|
83
|
+
|
|
84
|
+
// OPT-IN, not default. `verify` answers "has this island drifted from what it declares?", and that
|
|
85
|
+
// question is static: props against the component, contract against the generated one, imports,
|
|
86
|
+
// registration. Everything below drives a real browser once per scenario × viewport, which is a
|
|
87
|
+
// different question ("does it still behave?") and a different cost — a few seconds per island, so a
|
|
88
|
+
// migrated project pays minutes for a check most edits do not need. It belongs in `--runtime`
|
|
89
|
+
// (and in `motu check --runtime`), where someone asked for it.
|
|
90
|
+
if (argv.runtime === true || argv.audit === true) {
|
|
91
|
+
// Randomize the base port so parallel/back-to-back verifies don't collide on a strict port.
|
|
92
|
+
let port = 5300 + Math.floor(Math.random() * 400);
|
|
93
|
+
const fixturesPath = existsSync(paths.fixturesFile(kebab)) ? paths.fixturesFile(kebab) : '';
|
|
94
|
+
// Reading the evidence costs a tsx spawn, so this rides with the tiers that already load it rather
|
|
95
|
+
// than slowing the static sweep. It needs no browser and runs under `--fast` too.
|
|
96
|
+
if (fixturesPath) inputCoverageCheck(report, readScenarios(fixturesPath));
|
|
97
|
+
// The island lane drives a browser too, unless --fast put it under happy-dom.
|
|
98
|
+
if (!argv.fast) reportBrowserOverride(report);
|
|
99
|
+
// 'legacy' fit re-mounts the island under the host's legacy skin. Skip it where there is no
|
|
100
|
+
// legacy skin — it would verify the same thing twice and double the wall clock.
|
|
101
|
+
for (const fit of LEGACY_FIT ? ['native', 'legacy'] : ['native']) {
|
|
102
|
+
if (argv.fast) {
|
|
103
|
+
await runtimeCheckFast(report, resolvedTag, fixturesPath, fit);
|
|
104
|
+
} else {
|
|
105
|
+
await runtimeCheckBrowser(report, resolvedTag, fit, port++);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Error-resilience mount (real browser only; the happy-dom --fast path can't host the lagoon).
|
|
109
|
+
if (!argv.fast) await runtimeErrorCheck(report, resolvedTag, port++);
|
|
110
|
+
// A seed only helps if it SURVIVES the trip. Checked before the checks that use one, because
|
|
111
|
+
// every one of them fails misleadingly otherwise.
|
|
112
|
+
if (!argv.fast) await seedTransportCheck(report, kebab);
|
|
113
|
+
// Data-flow differentiation (opt-in via declared `scenarios`).
|
|
114
|
+
await runtimeDifferentiationCheck(report, resolvedTag, fixturesPath, port++, Boolean(argv.fast));
|
|
115
|
+
// Every declared viewport — the phone included, which nothing checked before.
|
|
116
|
+
// AUDIT, not dev loop. Layout at every viewport and axe over every scenario answer "is this fit
|
|
117
|
+
// for people", which changes when the RENDERING changes — not on the edit that moved a key. They
|
|
118
|
+
// are the two most expensive per-island checks and they belong where their answer is acted on:
|
|
119
|
+
// before integrating, and in CI as a non-regression gate. `--audit` asks for them.
|
|
120
|
+
if (!argv.fast && argv.audit) await responsiveCheck(report, resolvedTag, kebab, port++);
|
|
121
|
+
// Accessibility, in the browser that is already open.
|
|
122
|
+
if (!argv.fast && argv.audit) await a11yCheck(report, resolvedTag, kebab, port++);
|
|
123
|
+
if (!argv.fast && !argv.audit) {
|
|
124
|
+
report.skip('audit', 'responsive + a11y not run — they are the `--audit` gate (before integrating, and in CI)');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
kebab,
|
|
130
|
+
tag: resolvedTag,
|
|
131
|
+
report,
|
|
132
|
+
errors: report.findings.filter((f) => f.level === 'error'),
|
|
133
|
+
warns: report.findings.filter((f) => f.level === 'warn'),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
// What an island or region ASKS for — declared reach against observed calls, and the operation universe.
|
|
2
|
+
// Split out of commands/verify.mjs; verify.mjs re-exports the public surface.
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { coversEffect, isDataKind, readEffectEntries } from '../../lib/effects.mjs';
|
|
5
|
+
import { readRegions } from '../../lib/eject.mjs';
|
|
6
|
+
import { listIslands } from '../../lib/islands.mjs';
|
|
7
|
+
import { askKind, indexOperations, readOperationNames, resolveOperation } from '../../lib/operations.mjs';
|
|
8
|
+
import { REPO_ROOT, names, paths } from '../../lib/util.mjs';
|
|
9
|
+
import { browserOverrideNote } from '../../playwright-lagoon.mjs';
|
|
10
|
+
import { declaredSources } from './region-sources.mjs';
|
|
11
|
+
|
|
12
|
+
/** A data entry — the kinds the runtime can observe, as opposed to a module or a host-scope name. */
|
|
13
|
+
export const isDataReach = isDataKind;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* WHAT EACH OWNER SAYS IT REACHES, keyed the way `DataReach.by` keys what it observed.
|
|
17
|
+
*
|
|
18
|
+
* One concept, declared where the reaching happens. An island that hits a backend directly declares it
|
|
19
|
+
* in its own `contract.effects`, beside the host modules it stands down — a data entry is what an
|
|
20
|
+
* module entry USED to imply, before wire mocking pushed the stub below the import graph and the
|
|
21
|
+
* module check (direct imports only) stopped being able to see it. A declared SOURCE reads inside a
|
|
22
|
+
* channel, at region level and outside any island's window, so it declares its own `reaches` and the
|
|
23
|
+
* runtime attributes to it directly — charging a source's tables to whichever island was rendering
|
|
24
|
+
* would report a correct declaration as a violation.
|
|
25
|
+
*/
|
|
26
|
+
export function declaredReach({ tag = null, regionId = null } = {}) {
|
|
27
|
+
const out = {};
|
|
28
|
+
// FROM THE ISLAND FILE, not from `contracts.generated.ts`.
|
|
29
|
+
//
|
|
30
|
+
// The generated contract's `effects` is DERIVED — read from the component's own imports — so it only
|
|
31
|
+
// ever holds module entries. A `{ table: … }` is a decision nobody can infer from an import, so it is
|
|
32
|
+
// hand-written in the island file and is invisible here otherwise: this read the generated map and
|
|
33
|
+
// would have reported every island's data reach as undeclared, forever, while the declaration sat in
|
|
34
|
+
// the file next to it. Caught by round-tripping one through `motu sync`.
|
|
35
|
+
const byTag = new Map();
|
|
36
|
+
for (const i of listIslands(paths.islandsDir)) {
|
|
37
|
+
const text = readFileSync(i.element, 'utf8');
|
|
38
|
+
const tag = (text.match(/\btag:\s*'([^']+)'/) ?? text.match(/\bisland\(\s*'([^']+)'/))?.[1] ?? `${names(i.kebab).tag}`;
|
|
39
|
+
const declared = text.match(/effects:\s*\[([\s\S]*?)\]/)?.[1];
|
|
40
|
+
if (declared !== undefined) byTag.set(tag, readEffectEntries(declared));
|
|
41
|
+
}
|
|
42
|
+
const addIsland = (t) => {
|
|
43
|
+
const entries = (byTag.get(t) ?? []).filter(isDataReach);
|
|
44
|
+
if (entries.length) out[`island:${t}`] = [...entries].sort();
|
|
45
|
+
};
|
|
46
|
+
if (tag) addIsland(tag);
|
|
47
|
+
if (regionId) {
|
|
48
|
+
const region = readRegions(paths.archipelagosDir).find((r) => r.id === regionId);
|
|
49
|
+
for (const i of region?.islands ?? []) if (i.element) addIsland(i.element);
|
|
50
|
+
for (const [name, src] of Object.entries(declaredSources(regionId))) {
|
|
51
|
+
const entries = (src.reaches ?? []).filter(isDataReach);
|
|
52
|
+
if (entries.length) out[`source:${name}`] = [...entries].sort();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Observed reach against declared reach — the half `data-reach` could not do while it was a readout.
|
|
60
|
+
*
|
|
61
|
+
* WARNINGS, both ways, and deliberately not errors: every project that adopts this lights up on the
|
|
62
|
+
* day it lands, and the module half of `effects` — the declaration this extends — is warnings-only for exactly
|
|
63
|
+
* that reason. An entry observed under an owner that declared nothing at all is NOT reported: the
|
|
64
|
+
* declaration is opt-in per island and per source, and reporting its absence would make adopting the
|
|
65
|
+
* feature indistinguishable from failing it.
|
|
66
|
+
*/
|
|
67
|
+
export function reportReachDrift(report, observed, declared, label) {
|
|
68
|
+
const owners = Object.keys(declared);
|
|
69
|
+
if (!owners.length) return;
|
|
70
|
+
let clean = 0;
|
|
71
|
+
for (const owner of owners) {
|
|
72
|
+
const saw = [...new Set(observed?.[owner] ?? [])].sort();
|
|
73
|
+
const said = declared[owner];
|
|
74
|
+
// `{ table: 'shots' }` covers every operation on that table and `{ route: '/api/x' }` every method
|
|
75
|
+
// on that route — naming a dependency without pinning how it is used is a legitimate declaration.
|
|
76
|
+
const missing = saw.filter((e) => !said.some((d) => coversEffect(d, e)));
|
|
77
|
+
const stale = said.filter((d) => !saw.some((e) => coversEffect(d, e)));
|
|
78
|
+
if (missing.length) {
|
|
79
|
+
report.warn('data-reach', `${owner} reaches undeclared: ${missing.join(', ')} (${label}) — add it to ${owner.startsWith('source:') ? '`reaches`' : '`contract.effects`'}`);
|
|
80
|
+
}
|
|
81
|
+
if (stale.length) {
|
|
82
|
+
// NOT "declares what it does not use" — that is the module half's wording, and it can say it
|
|
83
|
+
// because it reads the component's imports STATICALLY. This half is observed at runtime, so an
|
|
84
|
+
// unreached declaration has two causes and only one of them is a stale claim: the review
|
|
85
|
+
// region's own accept route is declared, real, and simply not driven by any flow. Say what was
|
|
86
|
+
// actually seen and name both readings, or the honest answer to a true warning is to delete a
|
|
87
|
+
// correct declaration.
|
|
88
|
+
report.warn(
|
|
89
|
+
'data-reach',
|
|
90
|
+
`${owner} declares reach that nothing exercised: ${stale.join(', ')} (${label}) — either the ` +
|
|
91
|
+
`declaration is stale, or no flow drives that path`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (!missing.length && !stale.length) clean++;
|
|
95
|
+
}
|
|
96
|
+
if (clean) report.ok('data-reach', `${clean} owner(s) reach exactly what they declare (${label})`, clean);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `1 portal` / `2 portals` — the body-level nodes an island's own mount portaled and a check counted. */
|
|
100
|
+
export function portalNote(n) {
|
|
101
|
+
return `${n} portal${n === 1 ? '' : 's'}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Map the runtime result's diagnostics + remount signal onto report findings (shared by both engines). */
|
|
105
|
+
export function reportRuntimeDiagnostics(report, r, label, declared = null) {
|
|
106
|
+
const diagnostics = r?.diagnostics ?? [];
|
|
107
|
+
if (diagnostics.length === 0) {
|
|
108
|
+
report.ok('no-console-errors', `no console errors / unhandled rejections (${label})`);
|
|
109
|
+
} else {
|
|
110
|
+
report.error('no-console-errors', `console error / unhandled rejection (${label}): ${diagnostics[0]}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Both renders include what the island portaled (lib/island-render.mjs), and the line says so.
|
|
114
|
+
const portaled = r?.portals ? ` · ${portalNote(r.portals)}` : '';
|
|
115
|
+
if (r?.remountIdentical === true) {
|
|
116
|
+
report.ok('remount-stable', `re-mount produces identical output (${label}${portaled})`);
|
|
117
|
+
} else if (r?.remountIdentical === false) {
|
|
118
|
+
report.error('remount-stable', `re-mount changed the output (${label}${portaled}) — likely accidental module-level state`);
|
|
119
|
+
}
|
|
120
|
+
// remountIdentical == null → the island never rendered; the mount error already covers it.
|
|
121
|
+
|
|
122
|
+
// Only present when the lagoon's fake fetch (`@motujs/runtime/postgrest-fetch`) is in play — a project
|
|
123
|
+
// still on module-alias stubs never populates this, so `undefined` skips it rather than reporting a
|
|
124
|
+
// false pass for a mechanism that never ran. `seen` is the TOTAL request count, not the unscoped
|
|
125
|
+
// one: a run that made zero requests has zero unscoped requests too, and `report.ok`'s own "0 seen
|
|
126
|
+
// is a skip, not an ok" rule is what tells those two apart, the same as every other check here.
|
|
127
|
+
// IS THIS ISLAND STANDING ON ITS STUBS? Two halves of one question, under one id.
|
|
128
|
+
//
|
|
129
|
+
// They were `fixture-coverage` and `network-sealed`, and the second's own comment called it "the
|
|
130
|
+
// counterpart" to the first: one asks whether the requests the fake fetch SAW were declared, the
|
|
131
|
+
// other whether any request got PAST the stubs entirely. Both are errors, both mean the island is
|
|
132
|
+
// standing on something that is not there, and reading them as separate results invited fixing one.
|
|
133
|
+
//
|
|
134
|
+
// The preconditions differ and that is why this is not a concatenation: the escape half applies to
|
|
135
|
+
// EVERY runtime run, including a project still on module-alias stubs where no fake fetch is
|
|
136
|
+
// installed and `fixtureCoverage` is undefined. So each half reports only when it has something to
|
|
137
|
+
// say, and the single `ok` names whichever halves actually held.
|
|
138
|
+
if (r?.fixtureCoverage !== undefined || r?.networkEscapes !== undefined) {
|
|
139
|
+
const unscoped = r?.fixtureCoverage?.unscoped ?? [];
|
|
140
|
+
const escapes = r?.networkEscapes ?? [];
|
|
141
|
+
// THE THIRD HALF, and the one that made the name only two-thirds true. `escapes` counts requests
|
|
142
|
+
// that left for a non-loopback host; an unstubbed APP route is SAME-ORIGIN, so it 404s against the
|
|
143
|
+
// dev server and neither of the other two ever saw it. Recorded at the delegate seam inside
|
|
144
|
+
// `installFakeFetch`, which is the one place that knows a request was not claimed.
|
|
145
|
+
const unanswered = r?.fixtureCoverage?.unanswered ?? [];
|
|
146
|
+
if (unscoped.length) {
|
|
147
|
+
const first = unscoped[0];
|
|
148
|
+
report.error(
|
|
149
|
+
'stubs-sealed',
|
|
150
|
+
`${unscoped.length} request(s) matched no declared table/fixture (${label}): ` +
|
|
151
|
+
`${first.method} ${first.path} — ${first.reason}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (escapes.length) {
|
|
155
|
+
const first = escapes[0];
|
|
156
|
+
const hosts = [...new Set(escapes.map((e) => { try { return new URL(e.url).host; } catch { return e.url; } }))];
|
|
157
|
+
report.error(
|
|
158
|
+
'stubs-sealed',
|
|
159
|
+
`${escapes.length} request(s) escaped the lagoon to ${hosts.join(', ')} (${label}): ` +
|
|
160
|
+
`${first.method} ${first.url} — a host module reached a real backend, so this island is not ` +
|
|
161
|
+
`standing on its stubs. The failure is normally invisible: the island catches it and renders empty`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (unanswered.length) {
|
|
165
|
+
const first = unanswered[0];
|
|
166
|
+
report.error(
|
|
167
|
+
'stubs-sealed',
|
|
168
|
+
`${unanswered.length} request(s) reached the dev server instead of a stub (${label}): ` +
|
|
169
|
+
`${first.method} ${first.url} — ${first.why}. No fixture claimed it, so the island is standing on ` +
|
|
170
|
+
`nothing. Add the path to \`appRoutes\` (and a fixture for it), or point \`baseUrl\` at its origin`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
if (!unscoped.length && !escapes.length && !unanswered.length) {
|
|
174
|
+
// `seen` is the FAKE-FETCH request count, not the escape count: zero escapes proves nothing on
|
|
175
|
+
// its own (an island that made no backend call at all also escapes nothing). Passing the count
|
|
176
|
+
// of requests actually answered lets `report.ok`'s "0 examined is a skip" rule tell the two
|
|
177
|
+
// apart — which is precisely "is the fake fetch wired, or is this check vacuous?".
|
|
178
|
+
const answered = r?.fixtureCoverage !== undefined;
|
|
179
|
+
report.ok(
|
|
180
|
+
'stubs-sealed',
|
|
181
|
+
answered
|
|
182
|
+
? `every request matched a declared table/fixture, and nothing escaped the lagoon (${label})`
|
|
183
|
+
: `nothing escaped the lagoon — every backend call was answered locally (${label})`,
|
|
184
|
+
r?.fixtureCoverage?.seen,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// WHAT DOES THIS ISLAND NEED FROM THE BACKEND? Observed, not declared — see `DataReach` in
|
|
190
|
+
// `@motujs/runtime/postgrest-fetch` for why this exists at all: mocking at the wire made an island's
|
|
191
|
+
// data dependency transitive, so the module half of `effects` stopped being able to see it. A
|
|
192
|
+
// table-and-RPC list is a better answer than the module name it replaces, and it is already the
|
|
193
|
+
// vocabulary `.assay/operations.json` uses — which is where a motu↔assay drift check would compare.
|
|
194
|
+
//
|
|
195
|
+
// IT NOW HAS SOMETHING TO COMPARE AGAINST. This was a readout, on the argument that "declaring a
|
|
196
|
+
// per-island table list before assay's comparison exists would invent a format that then has to
|
|
197
|
+
// survive contact with it". What changed is the format question: the entries below are already
|
|
198
|
+
// assay's granularity (tables read/written, functions called), so the declaration is not an
|
|
199
|
+
// invention — and the readout alone left a real hole. An island that reached a table nobody expected
|
|
200
|
+
// failed `fixture-coverage`, and the fix was to add the table to the lagoon's `tables:` — so the
|
|
201
|
+
// FIXTURE SET was doing double duty as the declaration, and widening it was a one-line edit made by
|
|
202
|
+
// whoever was trying to get to green. `reportReachDrift` is the missing half; it stays a WARNING.
|
|
203
|
+
if (r?.fixtureCoverage?.reach !== undefined) {
|
|
204
|
+
const { tables, rpcs, functions = [], routes = [] } = r.fixtureCoverage.reach;
|
|
205
|
+
const tableNames = Object.keys(tables).sort();
|
|
206
|
+
const parts = [
|
|
207
|
+
...tableNames.map((t) => `${t}(${[...tables[t]].sort().join('/')})`),
|
|
208
|
+
...[...rpcs].sort().map((fn) => `rpc:${fn}`),
|
|
209
|
+
// Named apart from `rpc:` because they are different things to whatever compares this against
|
|
210
|
+
// a backend ledger: an edge function is its own deployable, an app route its own handler.
|
|
211
|
+
...[...functions].sort().map((fn) => `fn:${fn}`),
|
|
212
|
+
...[...routes].sort().map((r2) => `route:${r2}`),
|
|
213
|
+
];
|
|
214
|
+
report.ok('data-reach', `reaches ${parts.join(', ')} (${label})`, {
|
|
215
|
+
n: tableNames.length + rpcs.length + functions.length + routes.length,
|
|
216
|
+
of: 'backend dependencies',
|
|
217
|
+
});
|
|
218
|
+
if (declared) reportReachDrift(report, r.fixtureCoverage.reach.by, declared, label);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* WHERE THE REGION'S INPUT CAME FROM — through every door, in one list.
|
|
225
|
+
*
|
|
226
|
+
* The lagoon replaces host modules so completely that nothing shows a fetch happened: no request, no
|
|
227
|
+
* network row, and the lens shows only the keys that resulted. Looking at a region and seeing no HTTP
|
|
228
|
+
* at all is accurate and tells you nothing about whether an island asked for anything.
|
|
229
|
+
*
|
|
230
|
+
* An island's I/O leaves by one of THREE doors, and "all island I/O goes through the contract" is a
|
|
231
|
+
* rule about an island's own file (`no-bare-fetch` reads the component, not its import graph) rather
|
|
232
|
+
* than about the app: a service the island imports may read a table, and `contract.effects` has
|
|
233
|
+
* `{ table }`/`{ rpc }`/`{ route }` kinds precisely so that can be DECLARED. Each door used to be
|
|
234
|
+
* observed on its own — a traced stub here, `__motuDataReach` at the wire, and the contract's own
|
|
235
|
+
* `CallEvent`, which no check ever printed. One question, so one readout:
|
|
236
|
+
*
|
|
237
|
+
* ✓ provenance contract: shots.list() ×2 · host-module: fetchClubFeed(11) ×4 · wire: table:shots(select)
|
|
238
|
+
*
|
|
239
|
+
* Two things stay visible that nothing else here can see, and now for all three doors: an island that
|
|
240
|
+
* renders content while asking for NOTHING (its data came from somewhere it did not declare), and a
|
|
241
|
+
* dependency it declares but never reaches.
|
|
242
|
+
*/
|
|
243
|
+
export function provenanceCheck(report, id, region, calls, outbound = []) {
|
|
244
|
+
// The unified ledger when the page carries one; the traced-calls list is the fallback for a lagoon
|
|
245
|
+
// built before it existed, so an older bundle still reports what it always did.
|
|
246
|
+
// `args ? name(args) : name` — a wire reach's declared form (`table:shots(select)`) has no argument
|
|
247
|
+
// half, and appending `()` to it would print an operation as an empty call.
|
|
248
|
+
const label = (o) => (o.args ? `${o.name}(${o.args})` : o.name);
|
|
249
|
+
const asks = outbound.length
|
|
250
|
+
? outbound.map((o) => ({ via: o.via, label: label(o), owner: o.owner }))
|
|
251
|
+
: calls.map((c) => ({ via: 'host-module', label: `${c.fn}(${(c.args ?? []).join(', ')})`, owner: `island:${c.island ?? '?'}` }));
|
|
252
|
+
if (!asks.length) {
|
|
253
|
+
report.skip(
|
|
254
|
+
'provenance',
|
|
255
|
+
'no outbound calls observed — the islands asked for nothing through the contract, a traced host ' +
|
|
256
|
+
'module or the wire. If that is wrong, wrap a stub export in `traced(module, fn, impl)`; ' +
|
|
257
|
+
'without it the lagoon shows the result and never the question',
|
|
258
|
+
);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
// GROUPED BY DOOR, THE SAME GROUPING THE LENS SHOWS. Which one an ask left by is the thing a reader
|
|
262
|
+
// is deciding about — a contract call is answered by the transport, a wire reach by fixtures, a
|
|
263
|
+
// host-module call by a stub — and the two surfaces disagreeing about how the seams are grouped is
|
|
264
|
+
// how one of them ends up trusted and the other ignored.
|
|
265
|
+
const order = ['contract', 'host-module', 'wire'];
|
|
266
|
+
const groups = new Map();
|
|
267
|
+
for (const a of asks) {
|
|
268
|
+
const counts = groups.get(a.via) ?? new Map();
|
|
269
|
+
counts.set(a.label, (counts.get(a.label) ?? 0) + 1);
|
|
270
|
+
groups.set(a.via, counts);
|
|
271
|
+
}
|
|
272
|
+
const parts = [...groups]
|
|
273
|
+
.sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]))
|
|
274
|
+
.map(([via, counts]) => {
|
|
275
|
+
const shown = [...counts].map(([k, n]) => (n > 1 ? `${k} \u00d7${n}` : k));
|
|
276
|
+
return `${via}: ${shown.join(', ')}`;
|
|
277
|
+
});
|
|
278
|
+
// WHO ASKED, named — because "nobody asked for anything" was printed for months over a region whose
|
|
279
|
+
// SOURCE was asking constantly, and an owner list is what makes that impossible to print again.
|
|
280
|
+
const owners = [...new Set(asks.map((a) => a.owner))].sort();
|
|
281
|
+
const unowned = asks.filter((a) => a.owner === 'unattributed').length;
|
|
282
|
+
const by = owners.length ? ` \u2014 by ${owners.join(', ')}` : '';
|
|
283
|
+
// ASKED FOR, not "islands fetched": a source asks too, at region level, and calling every ask an
|
|
284
|
+
// island's is the same blind spot that dropped them from the lens.
|
|
285
|
+
report.ok('provenance', `asked for: ${parts.join(' \u00b7 ')}${by}`, { n: asks.length, of: 'outbound call(s)' });
|
|
286
|
+
if (unowned) {
|
|
287
|
+
report.warn(
|
|
288
|
+
'provenance',
|
|
289
|
+
`${unowned} ask(s) attributed to nobody — the request left while no island's and no source's ` +
|
|
290
|
+
`window was open, so nothing can say which declaration should account for it`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Say it when the browser was not the one motu pinned.
|
|
297
|
+
*
|
|
298
|
+
* A WARNING rather than a note in passing: a build motu did not pin can render and behave differently
|
|
299
|
+
* from the one it did, so every runtime verdict below is worth slightly less than it looks and the
|
|
300
|
+
* run should say so where the findings are read. Warnings do not fail `motu check`, which is the
|
|
301
|
+
* right weight — using the browser a machine already has is a legitimate way to run, not a fault.
|
|
302
|
+
*/
|
|
303
|
+
export function reportBrowserOverride(report) {
|
|
304
|
+
const note = browserOverrideNote();
|
|
305
|
+
if (note) {
|
|
306
|
+
report.warn(
|
|
307
|
+
'browser',
|
|
308
|
+
`${note} — the runtime findings below hold for THAT build; a pinned run can still differ`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The backend's list of operations, or null when the project has not pointed at one.
|
|
315
|
+
*
|
|
316
|
+
* Read once per verify run. An unreadable or empty file is an ERROR OBJECT rather than an empty
|
|
317
|
+
* list: an empty universe would make every ask unreachable, which is a wall of red that says the
|
|
318
|
+
* opposite of what happened and is exactly the failure this check exists to be the opposite of.
|
|
319
|
+
*/
|
|
320
|
+
export function operationUniverse() {
|
|
321
|
+
if (!paths.operations) return null;
|
|
322
|
+
const rel = paths.operations.replace(`${REPO_ROOT}/`, '');
|
|
323
|
+
if (!existsSync(paths.operations)) return { error: `\`operations\` names ${rel}, which does not exist` };
|
|
324
|
+
const { names, error } = readOperationNames(readFileSync(paths.operations, 'utf8'));
|
|
325
|
+
if (error) return { error: `${rel} ${error}` };
|
|
326
|
+
if (!names.length) return { error: `${rel} holds no operation names` };
|
|
327
|
+
return { names, index: indexOperations(names), rel };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* DOES THE THING THIS REGION ASKED FOR EXIST?
|
|
332
|
+
*
|
|
333
|
+
* `provenance` above records every ask and validates none of them — it cannot, because motu has no
|
|
334
|
+
* view of a backend. Given a list of the backend's own operations it can: the wire door records
|
|
335
|
+
* `route:<METHOD> <path>` and `fn:<name>`, which is an identifier an operation is keyed by, so an
|
|
336
|
+
* ask resolving to nothing is a region asking for something that is not there.
|
|
337
|
+
*
|
|
338
|
+
* ## Two directions, and only one of them is an error
|
|
339
|
+
*
|
|
340
|
+
* UNREACHABLE — an ask that resolves to no operation. An error: either the path is wrong, or an
|
|
341
|
+
* operation exists that the backend's own declarations have never covered. Both are real, and both
|
|
342
|
+
* are silent today.
|
|
343
|
+
*
|
|
344
|
+
* UNCOVERED — an operation this region DECLARES IT REACHES that no flow ever asked for. A warning,
|
|
345
|
+
* and narrowed hard: the universe is the whole backend, and a login region will never ask for an
|
|
346
|
+
* admin route, so reporting the universe per region would be wrong dozens of times on day one —
|
|
347
|
+
* which is how a check teaches people to ignore it. The narrowing uses motu's OWN existing
|
|
348
|
+
* declaration (`reaches` / `contract.effects`, via `declaredReach`), so both sides are artifacts the
|
|
349
|
+
* project already maintains and there is no third list to rot.
|
|
350
|
+
*
|
|
351
|
+
* ## What it says nothing about
|
|
352
|
+
*
|
|
353
|
+
* A contract call is `service.method` and a traced host module is a bare function name; neither says
|
|
354
|
+
* which backend thing answered, and a table or an rpc is a PostgREST identifier rather than an
|
|
355
|
+
* operation. Those are COUNTED AND NAMED rather than dropped — quietly ignoring the doors that
|
|
356
|
+
* cannot be checked is how this check would come to claim more than it examined.
|
|
357
|
+
*/
|
|
358
|
+
export function operationReachCheck(report, outbound, universe, declared) {
|
|
359
|
+
if (!universe) {
|
|
360
|
+
report.skip(
|
|
361
|
+
'operation-reach',
|
|
362
|
+
'no `operations` in motu.config.json — set it to the backend\'s own list of operations (a path, ' +
|
|
363
|
+
'e.g. ".assay/operations.json") and every ask this region makes is checked against it',
|
|
364
|
+
);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (universe.error) {
|
|
368
|
+
// COULD NOT RUN, not a finding: exit 2, retry, do not repair.
|
|
369
|
+
report.inconclusive('operation-reach', `${universe.error} — nothing was compared`);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const asks = (outbound ?? []).filter((o) => o.via === 'wire');
|
|
374
|
+
const resolvable = asks.filter((o) => askKind(o.name));
|
|
375
|
+
const unresolvable = (outbound ?? []).length - resolvable.length;
|
|
376
|
+
|
|
377
|
+
const reached = new Set();
|
|
378
|
+
const unreachable = new Map();
|
|
379
|
+
for (const ask of resolvable) {
|
|
380
|
+
const hit = resolveOperation(ask.name, universe.index);
|
|
381
|
+
if (hit) reached.add(hit);
|
|
382
|
+
else unreachable.set(ask.name, (unreachable.get(ask.name) ?? new Set()).add(ask.owner));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
for (const [name, owners] of unreachable) {
|
|
386
|
+
report.error(
|
|
387
|
+
'operation-reach',
|
|
388
|
+
`${[...owners].sort().join(', ')} asked for \`${name}\`, which names no operation in ` +
|
|
389
|
+
`${universe.rel} — either the path is wrong, or the backend has an operation nothing declares`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// NARROWED to what this region says it reaches. Everything else in the universe is out of scope
|
|
394
|
+
// for this region by construction, and saying so is what keeps the number believable.
|
|
395
|
+
//
|
|
396
|
+
// Taken as a PARAMETER rather than read in here, the way `reportReachDrift` takes its declarations:
|
|
397
|
+
// a check that goes to the filesystem for half its input cannot be driven by a test without a
|
|
398
|
+
// fixture project, and the half that decides whether a finding is reported is exactly the half
|
|
399
|
+
// worth driving.
|
|
400
|
+
const declaredOps = new Set();
|
|
401
|
+
for (const entries of Object.values(declared ?? {})) {
|
|
402
|
+
for (const entry of entries) {
|
|
403
|
+
if (!askKind(entry)) continue;
|
|
404
|
+
const hit = resolveOperation(entry, universe.index);
|
|
405
|
+
if (hit) declaredOps.add(hit);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const uncovered = [...declaredOps].filter((op) => !reached.has(op)).sort();
|
|
409
|
+
if (uncovered.length) {
|
|
410
|
+
report.warn(
|
|
411
|
+
'operation-reach',
|
|
412
|
+
`declared as reached and never asked for: ${uncovered.join(', ')} — the declaration is a claim ` +
|
|
413
|
+
'no flow has exercised, so either a flow is missing or the declaration is stale',
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const aside = [
|
|
418
|
+
`${universe.names.length} operation(s) declared`,
|
|
419
|
+
unresolvable ? `${unresolvable} ask(s) name no operation (contract, host module, table or rpc)` : '',
|
|
420
|
+
].filter(Boolean).join(' · ');
|
|
421
|
+
report.ok(
|
|
422
|
+
'operation-reach',
|
|
423
|
+
`${reached.size}/${resolvable.length} ask(s) resolve to a declared operation — ${aside}`,
|
|
424
|
+
{ n: resolvable.length, of: 'operation ask(s)' },
|
|
425
|
+
);
|
|
426
|
+
}
|