@motujs/cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/bundled/github/agents/archipelago-build.agent.md +298 -0
  3. package/bundled/github/agents/island-create.agent.md +261 -0
  4. package/bundled/github/agents/island-extract.agent.md +360 -0
  5. package/bundled/github/agents/island-locate.agent.md +208 -0
  6. package/bundled/github/agents/motu-checks.agent.md +158 -0
  7. package/bundled/github/agents/motu-evidence.agent.md +267 -0
  8. package/bundled/github/agents/motu-regions.agent.md +304 -0
  9. package/bundled/github/host-rules.md +43 -0
  10. package/bundled/github/prompts/archipelago-build.prompt.md +19 -0
  11. package/bundled/github/prompts/island-create.prompt.md +37 -0
  12. package/bundled/github/prompts/island-extract.prompt.md +59 -0
  13. package/bundled/github/prompts/island-locate.prompt.md +43 -0
  14. package/bundled/packages/adapters/angularjs/vite.mjs +43 -0
  15. package/bundled/packages/adapters/next/lagoon/next-stubs.tsx +108 -0
  16. package/bundled/packages/adapters/next/vite.mjs +121 -0
  17. package/bundled/packages/adapters/vite/vite.mjs +179 -0
  18. package/package.json +64 -3
  19. package/src/alias-loader.mjs +63 -0
  20. package/src/alias-register.mjs +28 -0
  21. package/src/asset-stub.cjs +3 -0
  22. package/src/asset-stub.mjs +5 -0
  23. package/src/cli.mjs +78 -0
  24. package/src/commands/adopt-root.mjs +194 -0
  25. package/src/commands/archipelago-init.mjs +314 -0
  26. package/src/commands/archipelago.mjs +138 -0
  27. package/src/commands/assurance.mjs +40 -0
  28. package/src/commands/check.mjs +346 -0
  29. package/src/commands/codegen.mjs +29 -0
  30. package/src/commands/coverage.mjs +358 -0
  31. package/src/commands/create.mjs +277 -0
  32. package/src/commands/defaults.mjs +129 -0
  33. package/src/commands/diff.mjs +595 -0
  34. package/src/commands/fixtures.mjs +153 -0
  35. package/src/commands/init.mjs +473 -0
  36. package/src/commands/integrate.mjs +208 -0
  37. package/src/commands/integration.mjs +1349 -0
  38. package/src/commands/lagoon-try.mjs +272 -0
  39. package/src/commands/lagoon.mjs +1419 -0
  40. package/src/commands/link.mjs +110 -0
  41. package/src/commands/region-coverage.mjs +604 -0
  42. package/src/commands/removal-check.mjs +878 -0
  43. package/src/commands/skills.mjs +156 -0
  44. package/src/commands/snapshot.mjs +456 -0
  45. package/src/commands/sync.mjs +38 -0
  46. package/src/commands/verify/island-runtime.mjs +581 -0
  47. package/src/commands/verify/island-static.mjs +586 -0
  48. package/src/commands/verify/island.mjs +135 -0
  49. package/src/commands/verify/reach.mjs +426 -0
  50. package/src/commands/verify/region-composition.mjs +391 -0
  51. package/src/commands/verify/region-config.mjs +330 -0
  52. package/src/commands/verify/region-flows.mjs +760 -0
  53. package/src/commands/verify/region-sources.mjs +516 -0
  54. package/src/commands/verify/region.mjs +271 -0
  55. package/src/commands/verify/report.mjs +338 -0
  56. package/src/commands/verify/screens-runtime.mjs +123 -0
  57. package/src/commands/verify.mjs +18 -0
  58. package/src/lagoon-build.mjs +25 -0
  59. package/src/lagoon-dev.mjs +32 -0
  60. package/src/lib/alias-core.mjs +49 -0
  61. package/src/lib/archipelagos.mjs +111 -0
  62. package/src/lib/assurance.mjs +144 -0
  63. package/src/lib/baselines.mjs +107 -0
  64. package/src/lib/bundlability.mjs +123 -0
  65. package/src/lib/changed.mjs +157 -0
  66. package/src/lib/component-props.mjs +145 -0
  67. package/src/lib/config.mjs +451 -0
  68. package/src/lib/contracts.mjs +244 -0
  69. package/src/lib/data-flow.mjs +47 -0
  70. package/src/lib/declared-sources.mjs +101 -0
  71. package/src/lib/dock-inject.mjs +47 -0
  72. package/src/lib/effects.mjs +97 -0
  73. package/src/lib/eject.mjs +715 -0
  74. package/src/lib/evidence-json.mjs +74 -0
  75. package/src/lib/expect-calls.mjs +53 -0
  76. package/src/lib/finding-memory.mjs +0 -0
  77. package/src/lib/flow-calls.mjs +53 -0
  78. package/src/lib/framework.mjs +98 -0
  79. package/src/lib/host-rules.mjs +55 -0
  80. package/src/lib/host-sources.mjs +121 -0
  81. package/src/lib/island-placement.mjs +64 -0
  82. package/src/lib/island-render.mjs +105 -0
  83. package/src/lib/islands.mjs +112 -0
  84. package/src/lib/lagoon-bundle.mjs +138 -0
  85. package/src/lib/lagoon-declares.mjs +553 -0
  86. package/src/lib/lagoon-materialize.mjs +217 -0
  87. package/src/lib/lagoon-vite.mjs +788 -0
  88. package/src/lib/live-url.mjs +53 -0
  89. package/src/lib/node-aliases.mjs +73 -0
  90. package/src/lib/operations.mjs +145 -0
  91. package/src/lib/provenance-plugin.mjs +74 -0
  92. package/src/lib/region-refs.mjs +40 -0
  93. package/src/lib/remote.mjs +171 -0
  94. package/src/lib/scaffold.mjs +606 -0
  95. package/src/lib/screens.mjs +266 -0
  96. package/src/lib/snapshots.mjs +85 -0
  97. package/src/lib/stubs.mjs +114 -0
  98. package/src/lib/ts-project.mjs +42 -0
  99. package/src/lib/util.mjs +626 -0
  100. package/src/lib/worlds.mjs +236 -0
  101. package/src/lib/write-if-changed.mjs +35 -0
  102. package/src/lib/writes-coverage.mjs +32 -0
  103. package/src/playwright-lagoon.mjs +2305 -0
  104. package/src/run.mjs +385 -0
  105. package/src/runtime-harness.mjs +225 -0
  106. package/README.md +0 -3
@@ -0,0 +1,123 @@
1
+ // Screens at runtime: every declared screen opened alone, its calls held to `flow-calls`, and the
2
+ // screens' own FLOWS replayed — a step can click the app's own links, `navigate` to a path, and assert
3
+ // which screen is open (`expectScreen`) and what a slot renders (`expectRender`).
4
+ //
5
+ // Screen flows live in `src/screens/screens.evidence.ts`, beside `screens.ts`, exporting `scenarios`:
6
+ //
7
+ // export const scenarios = [{
8
+ // name: 'the sidebar opens the team base',
9
+ // screen: 'club',
10
+ // steps: [{ click: 'Ma base - Mes équipes', expectScreen: 'base' }],
11
+ // }];
12
+ //
13
+ // Read with the same loader as a region's evidence, so the same rule holds: no `@/` imports there.
14
+ import { existsSync } from 'node:fs';
15
+ import { dirname, resolve } from 'node:path';
16
+ import { declaredScreens, screensFile } from '../../lib/screens.mjs';
17
+ import { describeCall } from '../../lib/flow-calls.mjs';
18
+ import { runScreens } from '../../playwright-lagoon.mjs';
19
+ import { readScenarios } from './island-runtime.mjs';
20
+
21
+ export function screenFlowsFile() {
22
+ return resolve(dirname(screensFile()), 'screens.evidence.ts');
23
+ }
24
+
25
+ /**
26
+ * Findings, in the shape `screenFindings()` already uses ({ level, check, msg }), so `motu check`
27
+ * prints them in the screens section. Null when the project declares no screens.
28
+ */
29
+ export async function screenRuntimeFindings({ only = null } = {}) {
30
+ const decl = declaredScreens();
31
+ if (!decl?.screens?.length) return null;
32
+ const known = new Set(decl.screens.map((s) => s.id));
33
+ const findings = [];
34
+ const add = (level, check, msg) => findings.push({ level, check, msg });
35
+ if (only && !known.has(only)) {
36
+ add('error', 'screen-runtime', `no declared screen "${only}" — declared: ${[...known].join(', ')}`);
37
+ return findings;
38
+ }
39
+ const ids = only ? [only] : [...known];
40
+
41
+ const file = screenFlowsFile();
42
+ const declared = existsSync(file) ? readScenarios(file) : [];
43
+ const flows = [];
44
+ for (const f of declared) {
45
+ if (!known.has(f.screen)) {
46
+ add('error', 'screen-flow', `"${f.name ?? 'screen flow'}" starts on screen "${f.screen}", which is not declared`);
47
+ continue;
48
+ }
49
+ if (!only || f.screen === only) flows.push(f);
50
+ }
51
+
52
+ let run;
53
+ try {
54
+ run = await runScreens({ port: 5600 + Math.floor(Math.random() * 300), screens: ids, flows });
55
+ } catch (err) {
56
+ add('inconclusive', 'screen-runtime', `could not open the screens: ${String(err?.message ?? err).split('\n')[0]}`);
57
+ return findings;
58
+ }
59
+
60
+ let calls = 0;
61
+ let bad = 0;
62
+ // ONE FINDING PER CALL, naming every screen that makes it. The layout makes the same call on every
63
+ // screen, so per-screen findings were 21 copies of one fix.
64
+ const byCall = new Map();
65
+ for (const s of run.screens) {
66
+ calls += s.calls;
67
+ if (!s.ok) add('error', 'screen-render', `screen "${s.screen}" did not open: ${s.error}`);
68
+ for (const c of s.undeclared) {
69
+ const k = `${c.method} ${c.label || c.url} ${c.status}`;
70
+ if (!byCall.has(k)) byCall.set(k, { call: c, screens: new Set() });
71
+ byCall.get(k).screens.add(s.screen);
72
+ }
73
+ }
74
+ for (const { call, screens } of byCall.values()) {
75
+ bad++;
76
+ const list = [...screens];
77
+ const where = list.length === run.screens.length && list.length > 1 ? `every screen (${list.length})` : `screen${list.length > 1 ? 's' : ''} ${list.map((x) => `"${x}"`).join(', ')}`;
78
+ add('error', 'flow-calls', `${where}: ${describeCall(call)}`);
79
+ }
80
+ const opened = run.screens.filter((s) => s.ok).length;
81
+ if (opened === run.screens.length) add('ok', 'screen-render', `${opened} screen(s) open from their own address`);
82
+
83
+ for (const r of run.flows) {
84
+ calls += r.calls;
85
+ const where = `"${r.flow}" step ${r.step}`;
86
+ if (!r.ok) {
87
+ add('error', 'screen-flow', `${where}: ${r.error ?? r.mismatches.map((m) => `${m.key}: expected ${m.expected}, got ${m.actual}`).join('; ')}`);
88
+ }
89
+ for (const c of r.undeclared) {
90
+ bad++;
91
+ add('error', 'flow-calls', `${where}: ${describeCall(c)}`);
92
+ }
93
+ }
94
+ if (run.flows.length) {
95
+ const passed = run.flows.filter((r) => r.ok).length;
96
+ if (passed === run.flows.length) add('ok', 'screen-flow', `${passed} screen flow step(s) end as declared`);
97
+ } else {
98
+ add('warn', 'screen-flow', `no screen flows declared — ${file.replace(process.cwd() + '/', '')} would hold what navigating between screens must do`);
99
+ }
100
+ // A CHECK THAT LOOKED AT NOTHING HAS NOT PASSED: no calls at all is a skip, not a clean bill.
101
+ if (!bad) add(calls ? 'ok' : 'skip', 'flow-calls', calls ? `every call the screens made was answered by a declaration · ${calls} call(s)` : 'the screens made no backend calls — nothing to hold to a declaration');
102
+ return findings;
103
+ }
104
+
105
+ /** `motu screen verify [id]` — the screens' runtime pass on its own, for the loop on one screen. */
106
+ export async function screenVerifyCommand(argv) {
107
+ const only = typeof argv._?.[0] === 'string' ? argv._[0] : null;
108
+ const findings = await screenRuntimeFindings({ only });
109
+ if (!findings) {
110
+ console.error('this project declares no screens (src/screens/screens.ts)');
111
+ return 2;
112
+ }
113
+ if (argv.json) console.log(JSON.stringify({ findings }, null, 2));
114
+ else {
115
+ for (const f of findings) {
116
+ const mark = f.level === 'error' ? '✗' : f.level === 'warn' ? '!' : f.level === 'ok' ? '✓' : '–';
117
+ console.log(` ${mark} ${f.check.padEnd(16)} ${f.msg}`);
118
+ }
119
+ }
120
+ if (findings.some((f) => f.level === 'error')) return 1;
121
+ if (findings.some((f) => f.level === 'inconclusive')) return 2;
122
+ return 0;
123
+ }
@@ -0,0 +1,18 @@
1
+ // `motu island verify <name>` — the deterministic gate that turns the README's island rules into
2
+ // machine-checkable pass/fail, so an agent can close the loop without a human eyeballing the host.
3
+ //
4
+ // static (AST over the component): no bare fetch, no history/pushState, no document reach-out into
5
+ // the host, all server I/O via @motujs/contract, renders from default props alone.
6
+ // config (AST over the registry): registered with a REQUIRED legacy-fit strategy; archipelago
7
+ // membership (warn if standalone).
8
+ // runtime (lagoon): boots the focused lagoon and asserts the island renders a
9
+ // non-empty shadow DOM in both native and legacy fit. Default: a REAL browser (Playwright/
10
+ // Chromium) so layout/CSS/paint are exercised; `--fast` uses an in-process happy-dom mount.
11
+ //
12
+ // The checks live in ./verify/, one module per concern; this file is the public surface.
13
+
14
+ export { setProgressScope, setVerbose, profiledMs, step, verifyExitCode, sweepExitCode, summaryOf, printSweep } from './verify/report.mjs';
15
+ export { reportReachDrift, operationReachCheck } from './verify/reach.mjs';
16
+ export { readScenarios } from './verify/island-runtime.mjs';
17
+ export { verifyCommand, runIslandVerify } from './verify/island.mjs';
18
+ export { archipelagoVerifyCommand, runArchipelagoVerify } from './verify/region.mjs';
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ // The lagoon build, run as its own process.
3
+ //
4
+ // `motu lagoon publish|serve` shells out to this rather than building in-process, for the same reason
5
+ // it used to shell out to the vite CLI: the build runs inside sync watcher and request callbacks, and
6
+ // a subprocess keeps those call sites unchanged. What changed is WHAT is run — the config now comes
7
+ // from @motujs/cli (see lib/lagoon-vite.mjs) instead of a vite.config.ts scaffolded into the project.
8
+ //
9
+ // Invoked with the project as cwd; every MOTU_* input arrives through the environment, exactly as the
10
+ // vite CLI received it.
11
+ import { resolve } from 'node:path';
12
+ import { loadMotuConfig } from './lib/config.mjs';
13
+ import { buildLagoonViteConfig, resolveVite } from './lib/lagoon-vite.mjs';
14
+
15
+ const paths = loadMotuConfig();
16
+ const vite = await resolveVite(paths);
17
+ const config = await buildLagoonViteConfig(paths, process.env);
18
+ // Vite resolves `outDir` against ROOT, and root is now `.motu/cache/lagoon` for a project that does
19
+ // not own its entries — so the artifact would land in the cache while `publish`/`serve` look for it
20
+ // under the project's lagoon. Pin it back with an absolute path: where the build INPUT lives is a
21
+ // framework detail, where the OUTPUT lands is the project's.
22
+ config.build.outDir = resolve(paths.lagoonDir, 'dist');
23
+ config.build.emptyOutDir = true;
24
+ config.logLevel = process.env.MOTU_VITE_LOGLEVEL ?? 'warn';
25
+ await vite.build(config);
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ // The lagoon dev server, run as its own process.
3
+ //
4
+ // Two callers: `motu lagoon dev` (a human iterating) and `motu island|archipelago verify`, which boots
5
+ // a FOCUSED server (MOTU_TARGET) in its own process group so it can be killed cleanly and cannot
6
+ // outlive the run holding a strict port. Both used to spawn the vite CLI against a vite.config.ts
7
+ // scaffolded into the project; the config now comes from @motujs/cli instead.
8
+ //
9
+ // Invoked with the project as cwd. Flags mirror the vite CLI's, because verify's process handling
10
+ // depends on them: --port, --strictPort.
11
+ import { loadMotuConfig } from './lib/config.mjs';
12
+ import { buildLagoonViteConfig, resolveVite } from './lib/lagoon-vite.mjs';
13
+
14
+ const argv = process.argv.slice(2);
15
+ const flag = (name) => {
16
+ const i = argv.indexOf(`--${name}`);
17
+ return i >= 0 ? (argv[i + 1]?.startsWith('--') ? true : (argv[i + 1] ?? true)) : undefined;
18
+ };
19
+
20
+ const paths = loadMotuConfig();
21
+ const vite = await resolveVite(paths);
22
+ const config = await buildLagoonViteConfig(paths, process.env);
23
+
24
+ const port = flag('port');
25
+ if (port) config.server.port = Number(port);
26
+ if (flag('strictPort')) config.server.strictPort = true;
27
+ config.clearScreen = false;
28
+ config.logLevel = process.env.MOTU_VITE_LOGLEVEL ?? 'info';
29
+
30
+ const server = await vite.createServer(config);
31
+ await server.listen();
32
+ server.printUrls();
@@ -0,0 +1,49 @@
1
+ // The alias map, shared by the ESM hook and the CJS patch so the two cannot disagree.
2
+ //
3
+ // Two module systems reach for the same specifier: an island's ESM `import` and, further down, a
4
+ // dependency's CJS `require`. acme got past `@/app` through the ESM hook and then died on
5
+ // `require('@/lib/utils')`, because a resolver hook is not in that path at all.
6
+ import { existsSync, statSync } from 'node:fs';
7
+ import { dirname, resolve as resolvePath } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const HERE = dirname(fileURLToPath(import.meta.url));
11
+
12
+ /** A bundler resolves these; node cannot even parse them. */
13
+ export const ASSET = /\.(css|scss|sass|less|svg|png|jpe?g|gif|webp|avif|woff2?|ttf|eot)(\?.*)?$/i;
14
+ /** What an asset import becomes: an inert module, so the component's own code still runs. */
15
+ export const ASSET_STUB = resolvePath(HERE, '..', 'asset-stub.mjs');
16
+ export const ASSET_STUB_CJS = resolvePath(HERE, '..', 'asset-stub.cjs');
17
+
18
+ const CANDIDATES = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '/index.ts', '/index.tsx', '/index.js'];
19
+
20
+ export function aliasEntries() {
21
+ return JSON.parse(process.env.MOTU_NODE_ALIASES ?? '[]').map((e) => ({
22
+ find: e.source ? new RegExp(e.source, e.flags) : e.find,
23
+ replacement: e.replacement,
24
+ }));
25
+ }
26
+
27
+ /** A bundler finds `app/index.tsx` for `@/app`; node looks for a file called exactly `app`. */
28
+ export function onDisk(base) {
29
+ for (const suffix of CANDIDATES) {
30
+ const candidate = base + suffix;
31
+ try {
32
+ if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
33
+ } catch {
34
+ /* keep looking */
35
+ }
36
+ }
37
+ return null;
38
+ }
39
+
40
+ /** The file this specifier means, or null to let the host resolver have it. */
41
+ export function mapSpecifier(specifier, entries) {
42
+ for (const { find, replacement } of entries) {
43
+ const hit = find instanceof RegExp ? find.test(specifier) : specifier === find;
44
+ if (!hit) continue;
45
+ const mapped = find instanceof RegExp ? specifier.replace(find, replacement) : replacement;
46
+ return onDisk(resolvePath(mapped));
47
+ }
48
+ return null;
49
+ }
@@ -0,0 +1,111 @@
1
+ // What the REGION side of a project needs generated.
2
+ //
3
+ // WHY THIS FILE EXISTS AT ALL, and it is a correction. Coverage's deployment config —
4
+ // `configureCoverage({ enabled, regions })` — used to be emitted into the ISLAND registry by
5
+ // the island registry's generator. Nothing about it is island-shaped: `regions: ["actions"]` names regions, and the
6
+ // two other pieces of coverage already live where they belong (`coverage: { enums }` is declared on
7
+ // the archipelago, and `offerRegionToCoverage` is called by `defineArchipelago`).
8
+ //
9
+ // It went there because the island registry was the one generated module an application is guaranteed
10
+ // to import and execute at startup. That is a fact about which files happened to exist, not a reason,
11
+ // and it made the island registry quietly responsible for something outside its own noun.
12
+ //
13
+ // A SEPARATE FILE RATHER THAN A HAND-OWNED ONE. `archipelagos/registry.ts` is scaffolded once and
14
+ // edited by `motu archipelago init` — it is the project's, and generated content inside it would
15
+ // blur the line that says a generated file is never hand-edited. So this writes its own neighbour and
16
+ // the registry imports it.
17
+ //
18
+ // WRITTEN EVEN WHEN COVERAGE IS OFF, as an empty module. The load-bearing property is that a project
19
+ // with coverage off never NAMES `@motujs/coverage` — a module nothing imports is a module no bundler
20
+ // ships. An empty generated file satisfies that while keeping the registry's import line constant, so
21
+ // turning coverage on and off never edits a hand-owned file.
22
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
23
+ import { resolve } from 'node:path';
24
+
25
+ export const COVERAGE_MODULE = 'coverage.generated.ts';
26
+
27
+ /** The import the archipelago registry needs, exactly as it must appear. */
28
+ const IMPORT_LINE = "import './coverage.generated';";
29
+
30
+ export function renderCoverageModule(coverage) {
31
+ const head = `// GENERATED by \`motu sync\` — do not edit.
32
+ //
33
+ // Region-level deployment facts from motu.config.json. Imported for its SIDE EFFECT: it configures
34
+ // the coverage fold before any region mounts, and exports nothing.
35
+ `;
36
+ if (!coverage?.enabled) {
37
+ return `${head}//
38
+ // Coverage is off in motu.config.json, so this module is deliberately empty — and empty is the point.
39
+ // Nothing here names \`@motujs/coverage\`, so no bundler ships it, while the registry's import line
40
+ // stays constant whether coverage is on or off. Turning it on never edits a hand-owned file.
41
+ export {};
42
+ `;
43
+ }
44
+ return `${head}//
45
+ // THE IMPORT IS THE SWITCH. \`@motujs/coverage\` is its own package precisely so a project with coverage
46
+ // off never names it. Core holds only a seam (\`offerRegionToCoverage\`) that stays a dead branch until
47
+ // this line fills it.
48
+ //
49
+ // It arrives by IMPORT rather than as a build constant. \`__MOTU_DEBUG__\` exists so the seam lens'
50
+ // whole import tree dead-code-eliminates — the lens must not ship. Coverage is MEANT to ship, so a
51
+ // define would only oblige every host's bundler to declare a global or fail with a ReferenceError.
52
+ //
53
+ // NOT HERE: which of a region's keys are closed sets — that is a fact about the key, declared on the
54
+ // archipelago beside it. Nor the ADDRESSES: the lagoon imports this, and a published lagoon is a
55
+ // public page, so an endpoint baked here becomes a public string. The application renders those as
56
+ // meta tags instead:
57
+ //
58
+ // <meta name="motu-coverage-endpoint" content="…" />
59
+ // <meta name="motu-coverage-known" content="…" />
60
+ import { configureCoverage } from '@motujs/coverage';
61
+
62
+ configureCoverage(${JSON.stringify({ enabled: true, ...(coverage.regions ? { regions: coverage.regions } : {}) })});
63
+ `;
64
+ }
65
+
66
+ /**
67
+ * Make sure the project's archipelago registry imports the generated module.
68
+ *
69
+ * The registry is HAND-OWNED, so this touches it once and never again: a single import line, added at
70
+ * the top if it is absent, left alone if it is there. `motu archipelago init` already edits this
71
+ * file, so tooling writing one line into it is the established arrangement rather than a new liberty.
72
+ */
73
+ function ensureRegistryImports(registryPath) {
74
+ if (!existsSync(registryPath)) return 'no registry';
75
+ const src = readFileSync(registryPath, 'utf8');
76
+ if (src.includes(IMPORT_LINE)) return 'already imported';
77
+ const lines = src.split('\n');
78
+ // BEFORE THE OTHER IMPORTS, and the first version of this got it backwards.
79
+ //
80
+ // It went after them, reasoning that "after the imports is where a reader looks for a side effect".
81
+ // True of a side effect that ACTS; wrong for one that CONFIGURES. ES modules evaluate dependencies
82
+ // in source order, so every archipelago below this line defined itself — and offered itself to the
83
+ // coverage seam — before the module that fills that seam had run. The offers went nowhere and
84
+ // coverage was silently off in production.
85
+ //
86
+ // The seam remembers offers now, so this no longer decides whether coverage works. It is still
87
+ // first, because a configuration side effect belongs above the thing it configures and the next
88
+ // reader should not have to know the seam is forgiving.
89
+ let at = 0;
90
+ for (let i = 0; i < lines.length; i++) {
91
+ if (/^import\s/.test(lines[i])) { at = i; break; }
92
+ }
93
+ lines.splice(
94
+ at,
95
+ 0,
96
+ '// Region-level deployment facts, generated by `motu sync`. Imported for its side',
97
+ '// effect, and FIRST: it configures the seam every archipelago below offers itself to.',
98
+ IMPORT_LINE,
99
+ '',
100
+ );
101
+ writeFileSync(registryPath, lines.join('\n'));
102
+ return 'import added';
103
+ }
104
+
105
+ /** Write the region-level generated module for a project. Returns what it did. */
106
+ export function syncArchipelagos(archipelagosDir, coverage = null) {
107
+ const out = resolve(archipelagosDir, COVERAGE_MODULE);
108
+ writeFileSync(out, renderCoverageModule(coverage));
109
+ const registry = ensureRegistryImports(resolve(archipelagosDir, 'registry.ts'));
110
+ return { path: out, enabled: Boolean(coverage?.enabled), registry };
111
+ }
@@ -0,0 +1,144 @@
1
+ // ASSURANCE — one row per DECLARED THING, with the strongest evidence that holds it.
2
+ //
3
+ // A check report has one row per CHECK, and "green" means "nothing contradicted what was checked" —
4
+ // the reader cannot see how much was. This inverts the axis (docs/proposals/constraint-loop.md, item
5
+ // 3): every slot, write, shared key and flow's calls, each with a LEVEL, the evidence behind it, and
6
+ // what is missing. A residual motu cannot hold — a handler driven only through `emit` — is then SHOWN
7
+ // as such instead of passing green, and a person can decide about it (a unit test, or accept it).
8
+ //
9
+ // Derived from the declarations and the evidence files alone, so it is static and fast. A level says
10
+ // what the evidence CLAIMS; `motu check --runtime` is what confirms the claim holds.
11
+ //
12
+ // unchecked nothing in the evidence exercises it
13
+ // static declared, typed and placed — the static checks hold it, no flow does
14
+ // runtime·emit a flow drives it through the island's DECLARED OUTPUT — past the component's handler
15
+ // runtime a flow asserts it (a slot's render, a key a reader shows)
16
+ // runtime·user a flow reaches it by the path a person takes: a `click` on the rendered control
17
+ //
18
+ // KNOWN LIMIT: a `click` is credited to a write only when its step `expect`s the written key. A click
19
+ // asserted by its render alone (demo-app's "sorting by level ranks the whole club") cannot be tied to a
20
+ // declared write without running it — the runtime trace `motu diff` records is where that belongs.
21
+ //
22
+ // Pure: everything it reads is passed in, so it is tested without a project.
23
+
24
+ export const LEVELS = ['unchecked', 'static', 'runtime·emit', 'runtime', 'runtime·user'];
25
+ const rank = (l) => LEVELS.indexOf(l);
26
+
27
+ const where = (flow, i) => `"${flow.name ?? 'flow'}" step ${i + 1}`;
28
+ const keyOf = (target) => (typeof target === 'string' ? target : (target?.key ?? Object.keys(target ?? {})[0] ?? '?'));
29
+
30
+ /** The region keys a slot's island READS — its `bind` (both forms) and its declared `reads`. */
31
+ function readKeys(island) {
32
+ const out = new Set(island.reads ?? []);
33
+ for (const entry of [].concat(island.bind ?? [])) {
34
+ if (typeof entry === 'string') out.add(entry);
35
+ else if (entry && typeof entry === 'object') for (const v of Object.values(entry)) if (typeof v === 'string') out.add(v);
36
+ }
37
+ return out;
38
+ }
39
+
40
+ /**
41
+ * Rows for one region. `region` is what `readRegions` returns (`islands` with slot, element, writes,
42
+ * bind, reads, planned); `flows` is the region's declared scenarios.
43
+ */
44
+ export function assuranceRows(region, flows) {
45
+ const rows = [];
46
+ const islands = (region?.islands ?? []).filter((i) => !i.planned);
47
+ const steps = flows.flatMap((f) => (f.steps ?? []).map((st, i) => ({ flow: f, st, i })));
48
+
49
+ // Who produces each key, so a slot can be asked about the coupling it actually HAS.
50
+ const producerOf = new Map();
51
+ for (const isl of islands) for (const t of Object.values(isl.writes ?? {})) producerOf.set(keyOf(t), isl.slot);
52
+
53
+ // SLOTS — does any flow assert what this slot renders?
54
+ for (const isl of islands) {
55
+ // Only a slot that READS another island's key has a coupling to show; a pure producer (a search
56
+ // box) does not, and saying it is missing one was noise on every region.
57
+ const coupled = [...readKeys(isl)].some((k) => producerOf.has(k) && producerOf.get(k) !== isl.slot);
58
+ const asserting = steps.filter(({ st }) => st.expectRender && Object.hasOwn(st.expectRender, isl.slot));
59
+ const driven = asserting.filter(({ st }) => st.emit && st.emit.slot !== isl.slot);
60
+ rows.push(
61
+ asserting.length
62
+ ? {
63
+ kind: 'slot',
64
+ subject: isl.slot,
65
+ level: 'runtime',
66
+ evidence: `expectRender in ${asserting.length} step(s), first ${where(asserting[0].flow, asserting[0].i)}` +
67
+ (driven.length ? `; asserted after ANOTHER island moved in ${driven.length}` : ''),
68
+ missing: coupled && !driven.length ? 'it reads another island’s key, and no step asserts it after that island moved — the coupling is not shown' : null,
69
+ }
70
+ : {
71
+ kind: 'slot',
72
+ subject: isl.slot,
73
+ level: 'static',
74
+ evidence: 'declared and placed',
75
+ missing: 'no flow asserts what it renders — it could show a neighbour’s data and stay green',
76
+ },
77
+ );
78
+ }
79
+
80
+ // WRITES — is the island's declared output exercised, and by which path?
81
+ for (const isl of islands) {
82
+ for (const [event, target] of Object.entries(isl.writes ?? {})) {
83
+ const key = keyOf(target);
84
+ const subject = `${isl.slot} · ${event} → ${key}`;
85
+ const clicked = steps.filter(({ st }) => st.click && st.expect && Object.hasOwn(st.expect, key));
86
+ const emitted = steps.filter(({ st }) => st.emit?.slot === isl.slot && st.emit?.event === event);
87
+ if (clicked.length) {
88
+ rows.push({ kind: 'write', subject, level: 'runtime·user', evidence: `a click moves ${key} in ${where(clicked[0].flow, clicked[0].i)}`, missing: null });
89
+ } else if (emitted.length) {
90
+ rows.push({
91
+ kind: 'write',
92
+ subject,
93
+ level: 'runtime·emit',
94
+ evidence: `emitted in ${emitted.length} step(s), first ${where(emitted[0].flow, emitted[0].i)}`,
95
+ missing: 'the component’s own handler is never driven — `emit` enters past it, so a handler that ignores its input passes. A `click` step, or a unit test',
96
+ });
97
+ } else {
98
+ rows.push({ kind: 'write', subject, level: 'static', evidence: 'declared and typed (`wiring`)', missing: 'no flow drives it — the island could stop producing it and nothing would change' });
99
+ }
100
+ }
101
+ }
102
+
103
+ // SHARED KEYS — a key one island writes and another reads: is a reader shown REACTING to it?
104
+ for (const [key, producer] of producerOf) {
105
+ const readers = islands.filter((i) => i.slot !== producer && readKeys(i).has(key)).map((i) => i.slot);
106
+ if (!readers.length) {
107
+ rows.push({ kind: 'key', subject: key, level: 'unchecked', evidence: `written by ${producer}`, missing: 'no other island reads it — a key nobody reads is not shared state' });
108
+ continue;
109
+ }
110
+ const writers = new Set(Object.entries(islands.find((i) => i.slot === producer)?.writes ?? {}).filter(([, t]) => keyOf(t) === key).map(([e]) => e));
111
+ const shown = steps.find(
112
+ ({ st }) =>
113
+ ((st.emit?.slot === producer && writers.has(st.emit?.event)) || (st.click && st.expect && Object.hasOwn(st.expect, key))) &&
114
+ readers.some((r) => st.expectRender && Object.hasOwn(st.expectRender, r)),
115
+ );
116
+ rows.push(
117
+ shown
118
+ ? { kind: 'key', subject: key, level: shown.st.click ? 'runtime·user' : 'runtime', evidence: `${producer} → ${readers.join(', ')}: a reader’s render is asserted as it moves, ${where(shown.flow, shown.i)}`, missing: null }
119
+ : { kind: 'key', subject: key, level: 'static', evidence: `${producer} → ${readers.join(', ')}, declared`, missing: 'no flow moves it and asserts what a reader then shows' },
120
+ );
121
+ }
122
+
123
+ // CALLS — what each flow's states ask the backend for. `flow-calls` holds every call to an ANSWER;
124
+ // only `expectCalls` holds a state to WHICH calls it makes.
125
+ for (const f of flows) {
126
+ const n = (f.steps ?? []).length;
127
+ const declared = (f.steps ?? []).filter((st) => st.expectCalls !== undefined).length;
128
+ rows.push({
129
+ kind: 'calls',
130
+ subject: `flow "${f.name ?? 'flow'}"`,
131
+ level: declared ? 'runtime' : 'static',
132
+ evidence: declared ? `${declared}/${n} step(s) declare \`expectCalls\`` : 'every call is held to an answer (`flow-calls`)',
133
+ missing: declared === n ? null : `${n - declared}/${n} step(s) do not say which calls they make — an extra request that is answered goes unseen`,
134
+ });
135
+ }
136
+ return rows;
137
+ }
138
+
139
+ /** Counts per level, for the summary line. */
140
+ export function summarize(rows) {
141
+ const by = Object.fromEntries(LEVELS.map((l) => [l, 0]));
142
+ for (const r of rows) by[r.level]++;
143
+ return { total: rows.length, byLevel: by, weakest: rows.reduce((m, r) => (rank(r.level) < rank(m) ? r.level : m), 'runtime·user') };
144
+ }
@@ -0,0 +1,107 @@
1
+ // Talking to the lagoon host about visual baselines.
2
+ //
3
+ // The repository stores no PNGs. A run renders locally, POSTs each shot, and the HOST decides whether
4
+ // it is new, matches the accepted baseline, or differs from it — because "accepted" is a decision
5
+ // somebody made, not whatever happened to be on disk last. That distinction is the whole reason this
6
+ // lives on the host: `--update` overwriting files is why a stale baseline and a real regression look
7
+ // identical.
8
+ //
9
+ // Diff images are still produced, and still locally: the host hands back the accepted bytes, the same
10
+ // pixelmatch comparison runs here, and the artifacts land under `.motu/` where they are ignored. The
11
+ // answer to "what changed?" stays an image you can open.
12
+ import { mkdirSync, writeFileSync } from 'node:fs';
13
+ import { resolve } from 'node:path';
14
+ import { gzipSync } from 'node:zlib';
15
+ import { REPO_ROOT } from './util.mjs';
16
+ import { loadHostConfig, gitIdentity } from './remote.mjs';
17
+
18
+ /** Where the host is, by the same precedence `lagoon publish --remote` uses. */
19
+ export function resolveBaselineHost(argv) {
20
+ const cfg = loadHostConfig();
21
+ const url = (typeof argv.remote === 'string' ? argv.remote : null) || process.env.MOTU_HOST_URL || cfg.url;
22
+ const token = (typeof argv.token === 'string' ? argv.token : null) || process.env.MOTU_HOST_TOKEN || cfg.token || null;
23
+ if (!url) return null;
24
+ return { base: String(url).replace(/\/+$/, ''), token, id: gitIdentity(REPO_ROOT) };
25
+ }
26
+
27
+ async function api(host, path, init = {}) {
28
+ let res;
29
+ try {
30
+ res = await fetch(`${host.base}${path}`, {
31
+ ...init,
32
+ headers: { ...(init.headers ?? {}), ...(host.token ? { authorization: `Bearer ${host.token}` } : {}) },
33
+ });
34
+ } catch (err) {
35
+ throw new Error(`cannot reach the lagoon host at ${host.base} — ${err.message}`);
36
+ }
37
+ const text = await res.text();
38
+ let payload = null;
39
+ try {
40
+ payload = JSON.parse(text);
41
+ } catch {
42
+ /* something other than the host answered */
43
+ }
44
+ if (!res.ok) throw new Error(payload?.error ?? `${res.status} ${text.slice(0, 160).replace(/\s+/g, ' ').trim()}`);
45
+ return payload;
46
+ }
47
+
48
+ /** `<scenario>@<viewport>` — the shot's identity, derived from the same name the files used. */
49
+ export const shotId = (file) => file.replace(/\.png$/, '');
50
+
51
+ /** Send one rendered shot. Returns { status: 'new'|'match'|'changed', accepted, hash }. */
52
+ export async function putShot(host, island, file, png) {
53
+ const q = new URLSearchParams({ repo: host.id.repo, island, shot: shotId(file) });
54
+ if (host.id.sha) q.set('sha', host.id.sha);
55
+ if (host.id.branch) q.set('branch', host.id.branch);
56
+ return api(host, `/api/baseline?${q}`, {
57
+ method: 'POST',
58
+ headers: { 'content-type': 'image/png', 'content-encoding': 'gzip' },
59
+ body: gzipSync(png),
60
+ });
61
+ }
62
+
63
+ /** The accepted bytes, so a difference can be shown rather than reported as a percentage. */
64
+ export async function fetchShot(host, hash) {
65
+ const res = await fetch(`${host.base}/shot/${hash}`);
66
+ if (!res.ok) return null;
67
+ return Buffer.from(await res.arrayBuffer());
68
+ }
69
+
70
+ /**
71
+ * Every shot this repo has, with its status.
72
+ *
73
+ * THROUGH `api`, WHICH SENDS THE TOKEN, and that is the whole reason this function exists rather than
74
+ * a bare `fetch` at the call site. `/api/baselines` is gated by `readable(repo)`, so on a PRIVATE repo
75
+ * an unauthenticated read gets 404 — and the caller that did this swallowed the failure in a
76
+ * `try/catch`, leaving "which member islands also changed" empty. An empty answer there does not read
77
+ * as an error; it reads as `no member changed`, which is how a region diff concludes THE ARRANGEMENT
78
+ * MOVED when in truth it could not see the members at all.
79
+ */
80
+ export async function listShots(host, island = null) {
81
+ const q = new URLSearchParams({ repo: host.id.repo });
82
+ if (island) q.set('island', island);
83
+ return api(host, `/api/baselines?${q}`);
84
+ }
85
+
86
+ /** Move the accepted pointer. `island` null accepts everything the repo has pending. */
87
+ export async function acceptShots(host, island) {
88
+ const q = new URLSearchParams({ repo: host.id.repo });
89
+ if (island) q.set('island', island);
90
+ return api(host, `/api/baseline/accept?${q}`, { method: 'POST' });
91
+ }
92
+
93
+ /**
94
+ * Failure artifacts, under `.motu/` rather than beside the evidence.
95
+ *
96
+ * They are outputs of a run, not sources: writing them into the island's folder is what taught people
97
+ * to gitignore the whole directory, and it is why removing baselines from the repo has to move these
98
+ * too. Returns the path to show.
99
+ */
100
+ export function writeRemoteArtifacts(island, file, actual, diff) {
101
+ const dir = resolve(REPO_ROOT, '.motu/snapshots', island);
102
+ mkdirSync(dir, { recursive: true });
103
+ const base = file.replace(/\.png$/, '');
104
+ writeFileSync(resolve(dir, `${base}.actual.png`), actual);
105
+ if (diff) writeFileSync(resolve(dir, `${base}.diff.png`), diff);
106
+ return resolve(dir, `${base}.diff.png`);
107
+ }