@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,156 @@
|
|
|
1
|
+
// `motu skills install [dir]` — install the motu agent skills into a target repo.
|
|
2
|
+
//
|
|
3
|
+
// The skills are the JUDGEMENT half of motu (island-extract / island-create); the CLI is the
|
|
4
|
+
// deterministic half. A repo that uses motu needs both, in whatever format its coding agent reads.
|
|
5
|
+
// One source, two emitted formats — so they can never drift:
|
|
6
|
+
//
|
|
7
|
+
// .github/agents/<name>.agent.md (source of truth: frontmatter name/description + body)
|
|
8
|
+
// ├─ .github/agents/<name>.agent.md GitHub Copilot custom agent (verbatim copy)
|
|
9
|
+
// ├─ .github/prompts/<name>.prompt.md Copilot slash-prompt (verbatim copy, if present)
|
|
10
|
+
// └─ .claude/skills/<name>/SKILL.md Claude Code skill (generated from the agent)
|
|
11
|
+
//
|
|
12
|
+
// Deliberately config-free: this verb installs INTO a repo that may not be a motu project yet, so it
|
|
13
|
+
// never reads motu.config.json and never touches the island layout.
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
16
|
+
import { color, HOST_ROOT } from '../lib/util.mjs';
|
|
17
|
+
import { applyHostRules } from '../lib/host-rules.mjs';
|
|
18
|
+
import { frameworkFile } from '../lib/framework.mjs';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where the skills are read from: the checkout's `.github/`, or — installed from npm — the copy
|
|
22
|
+
* shipped inside @motujs/cli. They used to be read from `<cli>/../../.github` unconditionally, which
|
|
23
|
+
* from `node_modules/@motujs/cli` is `node_modules/.github`: nothing there, `discover()` returned an
|
|
24
|
+
* empty list, and `motu init` installed ZERO skills while printing a rules block that tells the
|
|
25
|
+
* agent to load them.
|
|
26
|
+
*/
|
|
27
|
+
const SRC_AGENTS = frameworkFile('.github/agents');
|
|
28
|
+
const SRC_PROMPTS = frameworkFile('.github/prompts');
|
|
29
|
+
const MOTU_ROOT = dirname(dirname(SRC_AGENTS));
|
|
30
|
+
|
|
31
|
+
/** Parse a leading `---` YAML-ish frontmatter block into { data, body, raw }. */
|
|
32
|
+
function frontmatter(raw) {
|
|
33
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw);
|
|
34
|
+
if (!m) return { data: {}, body: raw, raw };
|
|
35
|
+
const data = {};
|
|
36
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
37
|
+
const kv = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line);
|
|
38
|
+
if (kv) data[kv[1]] = kv[2].trim();
|
|
39
|
+
}
|
|
40
|
+
return { data, body: raw.slice(m[0].length), raw };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The skills shipped by this motu checkout, newest source first. */
|
|
44
|
+
function discover() {
|
|
45
|
+
if (!existsSync(SRC_AGENTS)) return [];
|
|
46
|
+
return readdirSync(SRC_AGENTS)
|
|
47
|
+
.filter((f) => f.endsWith('.agent.md'))
|
|
48
|
+
.sort()
|
|
49
|
+
.map((file) => {
|
|
50
|
+
const agentPath = resolve(SRC_AGENTS, file);
|
|
51
|
+
const raw = readFileSync(agentPath, 'utf8');
|
|
52
|
+
const { data, body } = frontmatter(raw);
|
|
53
|
+
const name = data.name ?? file.replace(/\.agent\.md$/, '');
|
|
54
|
+
const promptPath = resolve(SRC_PROMPTS, `${name}.prompt.md`);
|
|
55
|
+
return {
|
|
56
|
+
name,
|
|
57
|
+
description: data.description ?? '',
|
|
58
|
+
agent: { path: agentPath, raw },
|
|
59
|
+
prompt: existsSync(promptPath) ? { path: promptPath, raw: readFileSync(promptPath, 'utf8') } : null,
|
|
60
|
+
body,
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A Claude Code SKILL.md: the same body, under the frontmatter keys Claude reads. */
|
|
66
|
+
function skillMd(skill) {
|
|
67
|
+
const description = skill.description.replace(/\r?\n/g, ' ').trim();
|
|
68
|
+
return `---\nname: ${skill.name}\ndescription: ${description}\n---\n${skill.body.replace(/^\n*/, '\n')}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function put(path, contents, out) {
|
|
72
|
+
const rel = relative(out.root, path);
|
|
73
|
+
if (existsSync(path)) {
|
|
74
|
+
if (readFileSync(path, 'utf8') === contents) return out.same.push(rel);
|
|
75
|
+
if (!out.force) return out.skipped.push(rel);
|
|
76
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
77
|
+
writeFileSync(path, contents);
|
|
78
|
+
return out.updated.push(rel);
|
|
79
|
+
}
|
|
80
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
81
|
+
writeFileSync(path, contents);
|
|
82
|
+
out.created.push(rel);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Write the shipped skills into `root` — never over a file that differs unless `force` (it is reported
|
|
87
|
+
* as skipped). Shared by `motu skills install` and `motu init`: the short motu rules POINT AT these
|
|
88
|
+
* skills, so a project that has the rules must have them too.
|
|
89
|
+
*/
|
|
90
|
+
export function installSkills(root, { force = false, only = 'both' } = {}) {
|
|
91
|
+
const skills = discover();
|
|
92
|
+
const out = { root, force, created: [], updated: [], same: [], skipped: [] };
|
|
93
|
+
for (const skill of skills) {
|
|
94
|
+
if (only !== 'claude') {
|
|
95
|
+
put(resolve(root, '.github/agents', `${skill.name}.agent.md`), skill.agent.raw, out);
|
|
96
|
+
if (skill.prompt) put(resolve(root, '.github/prompts', `${skill.name}.prompt.md`), skill.prompt.raw, out);
|
|
97
|
+
}
|
|
98
|
+
if (only !== 'copilot') {
|
|
99
|
+
put(resolve(root, '.claude/skills', skill.name, 'SKILL.md'), skillMd(skill), out);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { skills, out };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function skillsInstallCommand(argv) {
|
|
106
|
+
const root = resolve(process.cwd(), argv._[0] ?? '.');
|
|
107
|
+
const only = argv.only ?? 'both'; // both | claude | copilot
|
|
108
|
+
if (!['both', 'claude', 'copilot'].includes(only)) {
|
|
109
|
+
console.error(color.red(`✗ --only must be one of: both, claude, copilot (got "${only}")`));
|
|
110
|
+
process.exit(2);
|
|
111
|
+
}
|
|
112
|
+
if (discover().length === 0) {
|
|
113
|
+
console.error(color.red(`✗ no skills found in ${SRC_AGENTS} — is this a complete motu checkout?`));
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
const { skills, out } = installSkills(root, { force: Boolean(argv.force), only });
|
|
117
|
+
|
|
118
|
+
if (argv.json) {
|
|
119
|
+
console.log(JSON.stringify({ root, source: MOTU_ROOT, skills: skills.map((s) => s.name), ...out }, null, 2));
|
|
120
|
+
return process.exit(out.skipped.length > 0 ? 1 : 0);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log(color.bold(`motu skills → ${root}`) + color.dim(` (source: ${MOTU_ROOT})`));
|
|
124
|
+
for (const p of out.created) console.log(' ' + color.green('+ ') + p);
|
|
125
|
+
for (const p of out.updated) console.log(' ' + color.yellow('~ ') + p);
|
|
126
|
+
for (const p of out.same) console.log(' ' + color.dim('= ' + p));
|
|
127
|
+
for (const p of out.skipped) console.log(' ' + color.red('! ') + p + color.dim(' (differs — use --force to overwrite)'));
|
|
128
|
+
console.log('');
|
|
129
|
+
// The skills are the judgement half; the rules are the standing instructions that go with them, so
|
|
130
|
+
// installing one installs the other. `motu init` does the same for a fresh project.
|
|
131
|
+
// The rules belong to the HOST application — that is where the coding agent's instruction file
|
|
132
|
+
// lives. Writing them beside the motu project (which is often a subfolder) created a second
|
|
133
|
+
// CLAUDE.md nothing reads.
|
|
134
|
+
const rules = applyHostRules(HOST_ROOT);
|
|
135
|
+
|
|
136
|
+
console.log(color.green(`✓ ${skills.length} skill(s): ${skills.map((s) => s.name).join(', ')}`));
|
|
137
|
+
for (const p of rules) console.log(' ' + color.dim(p) + color.dim(' (motu rules block)'));
|
|
138
|
+
if (out.skipped.length) {
|
|
139
|
+
console.log(color.yellow(` ${out.skipped.length} file(s) left untouched — rerun with --force to overwrite`));
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
// Name what was actually installed — a hardcoded pair goes stale the day a skill is added.
|
|
143
|
+
console.log('Use them: ' + skills.map((s) => color.bold(`/${s.name}`)).join(', ') + color.dim(' (Claude Code skill or Copilot agent/prompt)'));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function skillsListCommand(argv) {
|
|
147
|
+
const skills = discover();
|
|
148
|
+
if (argv.json) {
|
|
149
|
+
console.log(JSON.stringify(skills.map(({ name, description }) => ({ name, description })), null, 2));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
for (const s of skills) {
|
|
153
|
+
console.log(color.bold(s.name));
|
|
154
|
+
console.log(' ' + color.dim(s.description));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
// `motu island snapshot <name|--all> [--update]` — record or check an island's visual baselines.
|
|
2
|
+
//
|
|
3
|
+
// Without `--update` it CHECKS: capture every declared scenario × viewport and compare to what is
|
|
4
|
+
// committed. With `--update` it records, which is a deliberate, separate act — a baseline should change
|
|
5
|
+
// because someone decided the UI should, never as a side effect of running the checks.
|
|
6
|
+
//
|
|
7
|
+
// The failure artifacts matter as much as the verdict: on a difference it writes `.actual.png` and
|
|
8
|
+
// `.diff.png` beside the baseline, so the answer to "what changed?" is an image, not a percentage.
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { relative, resolve } from 'node:path';
|
|
11
|
+
import { color, paths, names, lagoonViewports } from '../lib/util.mjs';
|
|
12
|
+
import { listIslands } from '../lib/islands.mjs';
|
|
13
|
+
import { readRegions } from '../lib/eject.mjs';
|
|
14
|
+
import { readScenarios } from './verify.mjs';
|
|
15
|
+
import { changedScope } from '../lib/changed.mjs';
|
|
16
|
+
import { captureLagoon, captureRegionLagoon } from '../playwright-lagoon.mjs';
|
|
17
|
+
import { resolveBaselineHost, putShot, fetchShot, acceptShots, listShots, writeRemoteArtifacts } from '../lib/baselines.mjs';
|
|
18
|
+
import {
|
|
19
|
+
compareBuffers,
|
|
20
|
+
compareSnapshot,
|
|
21
|
+
orphanBaselines,
|
|
22
|
+
snapshotDir,
|
|
23
|
+
snapshotName,
|
|
24
|
+
writeBaseline,
|
|
25
|
+
writeFailureArtifacts,
|
|
26
|
+
} from '../lib/snapshots.mjs';
|
|
27
|
+
|
|
28
|
+
/** The island's declared scenarios, or a single default state. */
|
|
29
|
+
async function scenariosFor(kebab) {
|
|
30
|
+
const file = paths.fixturesFile(kebab);
|
|
31
|
+
if (!existsSync(file)) return [];
|
|
32
|
+
let viaNode = [];
|
|
33
|
+
try {
|
|
34
|
+
const mod = await import(`file://${file}?t=${Date.now()}`);
|
|
35
|
+
viaNode = Array.isArray(mod.scenarios) ? mod.scenarios : [];
|
|
36
|
+
} catch {
|
|
37
|
+
/* handled by the cross-check below */
|
|
38
|
+
}
|
|
39
|
+
// TWO LOADERS FOR ONE FILE, and they disagree in silence — the exact failure `islandVerify` already
|
|
40
|
+
// had to fix. A plain node import cannot resolve a `.js` specifier pointing at a `.ts` sibling,
|
|
41
|
+
// which is the convention every evidence file here uses the moment it shares a fixture module. This
|
|
42
|
+
// returned [] and the island was pictured ONCE, in its default state, while `data-flow` (which
|
|
43
|
+
// loads through tsx) saw every scenario. Four new islands were baselined at half their coverage
|
|
44
|
+
// before the shot count gave it away. Cross-check, and take the fuller answer.
|
|
45
|
+
const viaTsx = readScenarios(file);
|
|
46
|
+
return viaTsx.length > viaNode.length ? viaTsx : viaNode;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function snapshotIsland(argv, kebab, host) {
|
|
50
|
+
const { tag } = names(kebab);
|
|
51
|
+
const viewports = lagoonViewports();
|
|
52
|
+
const scenarios = await scenariosFor(kebab);
|
|
53
|
+
const dir = snapshotDir(paths.islandsDir, kebab);
|
|
54
|
+
const port = 5300 + Math.floor(Math.random() * 400);
|
|
55
|
+
|
|
56
|
+
let shots;
|
|
57
|
+
try {
|
|
58
|
+
shots = await captureLagoon({ tag, port, scenarios, viewports });
|
|
59
|
+
} catch (err) {
|
|
60
|
+
return { kebab, error: err.message, results: [] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const results = [];
|
|
64
|
+
for (const shot of shots) {
|
|
65
|
+
const file = snapshotName(shot.scenario, shot.viewport);
|
|
66
|
+
const baseline = resolve(dir, file);
|
|
67
|
+
|
|
68
|
+
// THE HOST DECIDES. It knows what was accepted; this run only knows what it rendered. `new` is not
|
|
69
|
+
// a failure — it is the first sight of a shot, waiting for someone to accept it.
|
|
70
|
+
if (host) {
|
|
71
|
+
let sent;
|
|
72
|
+
try {
|
|
73
|
+
sent = await putShot(host, kebab, file, shot.png);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return { kebab, error: err.message, results: [] };
|
|
76
|
+
}
|
|
77
|
+
if (sent.status !== 'changed') {
|
|
78
|
+
results.push({ file, status: sent.status === 'match' ? 'match' : 'new', hash: sent.hash });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
// Differed: fetch the accepted bytes and show it, rather than report a percentage.
|
|
82
|
+
const acceptedPng = await fetchShot(host, sent.accepted);
|
|
83
|
+
const cmp = acceptedPng ? await compareBuffers(acceptedPng, shot.png) : { status: 'changed', diffPixels: 0, ratio: 1 };
|
|
84
|
+
const artifact = writeRemoteArtifacts(kebab, file, shot.png, cmp.diff);
|
|
85
|
+
results.push({ file, ...cmp, status: cmp.status === 'new' ? 'changed' : cmp.status, artifact });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (argv.update) {
|
|
90
|
+
writeBaseline(dir, file, shot.png);
|
|
91
|
+
results.push({ file, status: existsSync(baseline) ? 'updated' : 'recorded' });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const cmp = await compareSnapshot(baseline, shot.png);
|
|
95
|
+
if (cmp.status === 'changed' || cmp.status === 'resized') {
|
|
96
|
+
writeFailureArtifacts(dir, file, shot.png, cmp.diff);
|
|
97
|
+
} else if (cmp.status === 'new') {
|
|
98
|
+
writeBaseline(dir, file, shot.png);
|
|
99
|
+
}
|
|
100
|
+
results.push({ file, ...cmp });
|
|
101
|
+
}
|
|
102
|
+
// Orphans are a question about FILES: with the host owning baselines there is nothing to be orphaned.
|
|
103
|
+
const orphans = host ? [] : orphanBaselines(dir, shots.map((s) => snapshotName(s.scenario, s.viewport)));
|
|
104
|
+
return { kebab, dir, results, orphans };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Narrow a sweep to what this branch touched — the same flag, and the same discipline, as `motu check`.
|
|
110
|
+
*
|
|
111
|
+
* A full island sweep is 89s and a full region sweep 18s on a 20-island project, which is the cost of
|
|
112
|
+
* a punctual gate, not of a loop. Scoped to one island and its region it is seconds, which is the
|
|
113
|
+
* difference between a check an agent runs on the way past and one it learns to skip.
|
|
114
|
+
*
|
|
115
|
+
* NARROWING IS NEVER SILENT: `changedScope` widens back to everything the moment a file cannot be
|
|
116
|
+
* attributed, and says why. And nothing examined is NOT a pass — a sweep that pictured no island
|
|
117
|
+
* proves nothing about the project, so it exits 2 rather than printing green, the same rule this
|
|
118
|
+
* repository keeps having to reinstate in whichever flag exists to examine less.
|
|
119
|
+
*/
|
|
120
|
+
function scopeTargets(argv, all, pick) {
|
|
121
|
+
if (argv.changed === undefined) return { targets: all, scope: null };
|
|
122
|
+
const scope = changedScope(typeof argv.changed === 'string' ? argv.changed : undefined);
|
|
123
|
+
if (!scope.scoped) {
|
|
124
|
+
console.log(color.dim(` --changed: running everything — ${scope.reason}`));
|
|
125
|
+
return { targets: all, scope };
|
|
126
|
+
}
|
|
127
|
+
return { targets: all.filter((id) => pick(scope).includes(id)), scope };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The shared verdict for a scoped run that found nothing to do. */
|
|
131
|
+
function nothingToPicture(kind) {
|
|
132
|
+
console.log('');
|
|
133
|
+
console.log(
|
|
134
|
+
color.yellow(color.bold('NOTHING TO PICTURE')) +
|
|
135
|
+
color.dim(` no ${kind} changed — this run proves nothing about the project`),
|
|
136
|
+
);
|
|
137
|
+
process.exit(2);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function snapshotCommand(argv) {
|
|
141
|
+
const name = argv._[0];
|
|
142
|
+
const wantsHost = argv.remote !== undefined || argv.accept !== undefined;
|
|
143
|
+
const host = wantsHost ? resolveBaselineHost(argv) : null;
|
|
144
|
+
if (wantsHost && !host) {
|
|
145
|
+
console.error(color.red('✗ no lagoon host — pass --remote <url>, set MOTU_HOST_URL, or write ~/.config/motu/host.json'));
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ACCEPT IS ITS OWN ACT. Not a flag on a checking run: "the UI should look like this now" is a
|
|
150
|
+
// decision, and running it as a side effect of a check is exactly what `--update` got wrong.
|
|
151
|
+
if (argv.accept !== undefined) {
|
|
152
|
+
const island = typeof argv.accept === 'string' ? names(argv.accept).kebab : name ? names(name).kebab : null;
|
|
153
|
+
let out;
|
|
154
|
+
try {
|
|
155
|
+
out = await acceptShots(host, island);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
console.error(color.red(`✗ ${err.message}`));
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
if (argv.json) {
|
|
161
|
+
console.log(JSON.stringify({ ok: true, ...out }, null, 2));
|
|
162
|
+
process.exit(0);
|
|
163
|
+
}
|
|
164
|
+
console.log('');
|
|
165
|
+
console.log(
|
|
166
|
+
out.count
|
|
167
|
+
? `${color.green('✓')} accepted ${out.count} shot(s)\n${out.accepted.map((a) => color.dim(` ${a}`)).join('\n')}`
|
|
168
|
+
: color.dim('nothing to accept — every shot already matches what was accepted'),
|
|
169
|
+
);
|
|
170
|
+
process.exit(0);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!name && !argv.all) {
|
|
174
|
+
console.error('usage: motu island snapshot <name|--all> [--update|--remote] [--accept] [--json]');
|
|
175
|
+
process.exit(2);
|
|
176
|
+
}
|
|
177
|
+
if (host && argv.update) {
|
|
178
|
+
console.error(color.red('✗ --update writes files; --remote stores on the host. Use --remote then --accept.'));
|
|
179
|
+
process.exit(2);
|
|
180
|
+
}
|
|
181
|
+
const allIslands = listIslands(paths.islandsDir).map((i) => i.kebab);
|
|
182
|
+
const { targets, scope } = argv.all
|
|
183
|
+
? scopeTargets(argv, allIslands, (sc) => sc.islands)
|
|
184
|
+
: { targets: [names(name).kebab], scope: null };
|
|
185
|
+
if (scope?.scoped) {
|
|
186
|
+
console.log(color.dim(` --changed: ${targets.length}/${allIslands.length} island(s) — from ${scope.files} changed file(s)`));
|
|
187
|
+
if (!targets.length) nothingToPicture('island');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const all = [];
|
|
191
|
+
for (const kebab of targets) all.push(await snapshotIsland(argv, kebab, host));
|
|
192
|
+
|
|
193
|
+
const failed = all.filter((r) => r.error || r.results.some((x) => x.status === 'changed' || x.status === 'resized'));
|
|
194
|
+
|
|
195
|
+
if (argv.json) {
|
|
196
|
+
console.log(JSON.stringify({ pass: failed.length === 0, islands: all }, null, 2));
|
|
197
|
+
process.exit(failed.length === 0 ? 0 : 1);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
console.log(
|
|
201
|
+
color.bold(
|
|
202
|
+
`\nmotu island snapshot — ${argv.update ? 'recording' : 'checking'} ${targets.length} island(s)` +
|
|
203
|
+
(host ? color.dim(` against ${host.base} · ${host.id.repo}`) : '') +
|
|
204
|
+
'\n',
|
|
205
|
+
),
|
|
206
|
+
);
|
|
207
|
+
for (const island of all) {
|
|
208
|
+
if (island.error) {
|
|
209
|
+
console.log(` ${color.red('✗')} ${color.dim(island.kebab.padEnd(20))} ${color.red(island.error)}`);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const changed = island.results.filter((r) => r.status === 'changed' || r.status === 'resized');
|
|
213
|
+
const fresh = island.results.filter((r) => r.status === 'new' || r.status === 'recorded');
|
|
214
|
+
const mark = changed.length ? color.red('✗') : fresh.length ? color.yellow('+') : color.green('✓');
|
|
215
|
+
const tally = changed.length
|
|
216
|
+
? color.red(`${changed.length} changed`)
|
|
217
|
+
: color.dim(`${island.results.length} shot(s)${fresh.length ? `, ${fresh.length} new` : ''}`);
|
|
218
|
+
console.log(` ${mark} ${color.dim(island.kebab.padEnd(20))} ${tally}`);
|
|
219
|
+
for (const r of changed) {
|
|
220
|
+
const detail =
|
|
221
|
+
r.status === 'resized'
|
|
222
|
+
? `size ${r.from?.join('×')} → ${r.to?.join('×')}`
|
|
223
|
+
: `${r.diffPixels} px (${(r.ratio * 100).toFixed(2)}%)`;
|
|
224
|
+
console.log(` ${color.red('✗')} ${color.dim(r.file.padEnd(28))} ${detail}`);
|
|
225
|
+
// `--remote` writes its artifacts under `.motu/`, not beside the evidence — print where they
|
|
226
|
+
// ACTUALLY are, or the one instruction this output gives points at a file that does not exist.
|
|
227
|
+
const artifact = r.artifact ?? resolve(island.dir, r.file.replace(/\.png$/, '.diff.png'));
|
|
228
|
+
console.log(` ${color.dim(relative(process.cwd(), artifact))}`);
|
|
229
|
+
}
|
|
230
|
+
for (const f of island.orphans ?? []) {
|
|
231
|
+
console.log(` ${color.yellow('!')} ${color.dim(f.padEnd(28))} baseline with no scenario behind it`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
console.log('');
|
|
235
|
+
// NEW SHOTS ARE NOT A PASS — they are a screen nobody has looked at.
|
|
236
|
+
//
|
|
237
|
+
// Every "LOOK at it" in the rules is conditioned on a DIFFERENCE, and a first baseline has none by
|
|
238
|
+
// construction: the run goes green and says nothing. That is the one moment looking matters most,
|
|
239
|
+
// and it is the moment nothing asked for it. Twice in two days a brand-new region shipped a defect
|
|
240
|
+
// no check could see — a fixture inventing a vocabulary the app does not use, and an island
|
|
241
|
+
// stylesheet that was bundled and never applied — and both times a human had to prompt the look.
|
|
242
|
+
//
|
|
243
|
+
// `accepted` is already the record that somebody looked. So the pending count is the number of
|
|
244
|
+
// states nobody has, and this says so rather than printing "every baseline matches" over 83 of them.
|
|
245
|
+
const fresh = all.reduce((n, i) => n + i.results.filter((r) => r.status === 'new').length, 0);
|
|
246
|
+
console.log(
|
|
247
|
+
failed.length === 0
|
|
248
|
+
? (fresh
|
|
249
|
+
? color.yellow(color.bold('LOOK')) +
|
|
250
|
+
color.dim(
|
|
251
|
+
` ${fresh} shot(s) nobody has looked at${host ? ` — open the lagoon, then accept: motu island snapshot --accept <name>` : ''}`,
|
|
252
|
+
)
|
|
253
|
+
: color.green(color.bold('PASS')) + color.dim(argv.update ? ' baselines written' : ' every baseline matches'))
|
|
254
|
+
: color.red(color.bold('FAIL')) +
|
|
255
|
+
color.dim(
|
|
256
|
+
` ${failed.length} island(s) changed — look at the .diff.png, then ` +
|
|
257
|
+
(host ? '`motu island snapshot --accept <island>` if intended' : '`--update` if intended'),
|
|
258
|
+
),
|
|
259
|
+
);
|
|
260
|
+
process.exit(failed.length === 0 ? 0 : 1);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The states a region can be pictured in — its flows' seeds.
|
|
266
|
+
*
|
|
267
|
+
* A region declares no `scenarios`; it declares FLOWS, and each one opens by seeding the state it
|
|
268
|
+
* needs. That seed list is the honest answer to "which shapes does this page take", and it is the
|
|
269
|
+
* answer to the case a single picture misses: an actions page with an empty week is a different PAGE,
|
|
270
|
+
* not a different value. A shape no flow seeds is an evidence gap — the same finding `render-coverage`
|
|
271
|
+
* makes — and closing it with a flow is worth doing for its own sake.
|
|
272
|
+
*/
|
|
273
|
+
async function regionStates(id) {
|
|
274
|
+
const file = paths.archipelagoEvidence(id);
|
|
275
|
+
if (!existsSync(file)) return [];
|
|
276
|
+
try {
|
|
277
|
+
const mod = await import(`file://${file}?t=${Date.now()}`);
|
|
278
|
+
const flows = Array.isArray(mod.scenarios) ? mod.scenarios : [];
|
|
279
|
+
const seen = new Set();
|
|
280
|
+
const out = [];
|
|
281
|
+
for (const f of flows) {
|
|
282
|
+
const name = f.name ?? 'flow';
|
|
283
|
+
// Two flows that start from the same state are one picture, not two.
|
|
284
|
+
const key = JSON.stringify(f.seed ?? {});
|
|
285
|
+
if (seen.has(key)) continue;
|
|
286
|
+
seen.add(key);
|
|
287
|
+
out.push({ name, seed: f.seed ?? {} });
|
|
288
|
+
}
|
|
289
|
+
return out;
|
|
290
|
+
} catch {
|
|
291
|
+
// Evidence is TypeScript in most projects; node cannot load it. One picture of the default state
|
|
292
|
+
// is still worth having — it just cannot be named after the flow that would have seeded it.
|
|
293
|
+
return [];
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function snapshotRegion(argv, id, host) {
|
|
298
|
+
const viewports = lagoonViewports();
|
|
299
|
+
const states = await regionStates(id);
|
|
300
|
+
const port = 5300 + Math.floor(Math.random() * 400);
|
|
301
|
+
const dir = snapshotDir(paths.archipelagosDir, id);
|
|
302
|
+
|
|
303
|
+
let shots;
|
|
304
|
+
try {
|
|
305
|
+
shots = await captureRegionLagoon({ id, port, states, viewports });
|
|
306
|
+
} catch (err) {
|
|
307
|
+
return { kebab: id, error: err.message, results: [] };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const results = [];
|
|
311
|
+
for (const shot of shots) {
|
|
312
|
+
const file = snapshotName(shot.scenario, shot.viewport);
|
|
313
|
+
if (host) {
|
|
314
|
+
let sent;
|
|
315
|
+
try {
|
|
316
|
+
sent = await putShot(host, `region-${id}`, file, shot.png);
|
|
317
|
+
} catch (err) {
|
|
318
|
+
return { kebab: id, error: err.message, results: [] };
|
|
319
|
+
}
|
|
320
|
+
if (sent.status !== 'changed') {
|
|
321
|
+
results.push({ file, status: sent.status === 'match' ? 'match' : 'new' });
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const acceptedPng = await fetchShot(host, sent.accepted);
|
|
325
|
+
const cmp = acceptedPng ? await compareBuffers(acceptedPng, shot.png) : { status: 'changed', diffPixels: 0, ratio: 1 };
|
|
326
|
+
const artifact = writeRemoteArtifacts(`region-${id}`, file, shot.png, cmp.diff);
|
|
327
|
+
results.push({ file, ...cmp, status: cmp.status === 'new' ? 'changed' : cmp.status, artifact });
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const baseline = resolve(dir, file);
|
|
331
|
+
if (argv.update) {
|
|
332
|
+
writeBaseline(dir, file, shot.png);
|
|
333
|
+
results.push({ file, status: existsSync(baseline) ? 'updated' : 'recorded' });
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const cmp = await compareSnapshot(baseline, shot.png);
|
|
337
|
+
if (cmp.status === 'changed' || cmp.status === 'resized') writeFailureArtifacts(dir, file, shot.png, cmp.diff);
|
|
338
|
+
else if (cmp.status === 'new') writeBaseline(dir, file, shot.png);
|
|
339
|
+
results.push({ file, ...cmp });
|
|
340
|
+
}
|
|
341
|
+
return { kebab: id, dir, results, orphans: [], members: readRegions(paths.archipelagosDir).find((r) => r.id === id)?.islands ?? [] };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* `motu archipelago snapshot <id|--all>` — picture the composed page.
|
|
347
|
+
*
|
|
348
|
+
* ATTRIBUTION IS THE POINT, and it is what makes a page-level diff usable rather than noise. Any island
|
|
349
|
+
* edit changes the region picture, so on its own this signal churns and people stop accepting it. But
|
|
350
|
+
* the islands are separately baselined here, so a region diff can be explained: when a member island
|
|
351
|
+
* changed too, the region changed BECAUSE of it. A region diff with NO member changed is an
|
|
352
|
+
* ARRANGEMENT regression — the class no island shot can see, and the one that shipped once already.
|
|
353
|
+
*/
|
|
354
|
+
export async function archipelagoSnapshotCommand(argv) {
|
|
355
|
+
const name = argv._[0];
|
|
356
|
+
const wantsHost = argv.remote !== undefined || argv.accept !== undefined;
|
|
357
|
+
const host = wantsHost ? resolveBaselineHost(argv) : null;
|
|
358
|
+
if (wantsHost && !host) {
|
|
359
|
+
console.error(color.red('✗ no lagoon host — pass --remote <url>, set MOTU_HOST_URL, or write ~/.config/motu/host.json'));
|
|
360
|
+
process.exit(1);
|
|
361
|
+
}
|
|
362
|
+
if (argv.accept !== undefined) {
|
|
363
|
+
const island = typeof argv.accept === 'string' ? `region-${argv.accept}` : name ? `region-${name}` : null;
|
|
364
|
+
const out = await acceptShots(host, island).catch((err) => {
|
|
365
|
+
console.error(color.red(`✗ ${err.message}`));
|
|
366
|
+
process.exit(1);
|
|
367
|
+
});
|
|
368
|
+
console.log(out.count ? `${color.green('✓')} accepted ${out.count} shot(s)` : color.dim('nothing to accept'));
|
|
369
|
+
process.exit(0);
|
|
370
|
+
}
|
|
371
|
+
if (!name && !argv.all) {
|
|
372
|
+
console.error('usage: motu archipelago snapshot <id|--all> [--update|--remote] [--accept] [--json]');
|
|
373
|
+
process.exit(2);
|
|
374
|
+
}
|
|
375
|
+
const allRegions = readRegions(paths.archipelagosDir).map((r) => r.id);
|
|
376
|
+
// A changed ISLAND is a changed region here: the region's picture is its members composed, so an
|
|
377
|
+
// island edit moves it even when no file under archipelagos/ was touched. `changedScope` already
|
|
378
|
+
// maps islands to the regions that declare them.
|
|
379
|
+
const { targets, scope } = argv.all ? scopeTargets(argv, allRegions, (sc) => sc.regions) : { targets: [name], scope: null };
|
|
380
|
+
if (scope?.scoped) {
|
|
381
|
+
console.log(color.dim(` --changed: ${targets.length}/${allRegions.length} region(s) — from ${scope.files} changed file(s)`));
|
|
382
|
+
if (!targets.length) nothingToPicture('region');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const all = [];
|
|
386
|
+
for (const id of targets) all.push(await snapshotRegion(argv, id, host));
|
|
387
|
+
|
|
388
|
+
// Which member islands ALSO differ, so a region diff can be explained instead of just reported.
|
|
389
|
+
let changedIslands = new Set();
|
|
390
|
+
let comparedIslands = new Set();
|
|
391
|
+
if (host) {
|
|
392
|
+
try {
|
|
393
|
+
const body = await listShots(host);
|
|
394
|
+
changedIslands = new Set(body.shots.filter((x) => x.status === 'changed').map((x) => x.island));
|
|
395
|
+
// An island with NO accepted baseline can never report `changed`, so "no member changed" would
|
|
396
|
+
// be true of an island nobody has ever compared. Claiming ARRANGEMENT on that is the check
|
|
397
|
+
// reporting a conclusion it did not examine.
|
|
398
|
+
comparedIslands = new Set(body.shots.filter((x) => x.accepted).map((x) => x.island));
|
|
399
|
+
} catch {
|
|
400
|
+
/* attribution is a nicety; its absence must not fail the run */
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const failed = all.filter((r) => r.error || r.results.some((x) => x.status === 'changed' || x.status === 'resized'));
|
|
405
|
+
if (argv.json) {
|
|
406
|
+
console.log(JSON.stringify({ pass: failed.length === 0, regions: all }, null, 2));
|
|
407
|
+
process.exit(failed.length === 0 ? 0 : 1);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
console.log(color.bold(`\nmotu archipelago snapshot — ${targets.length} region(s)${host ? color.dim(` against ${host.base}`) : ''}\n`));
|
|
411
|
+
for (const region of all) {
|
|
412
|
+
if (region.error) {
|
|
413
|
+
console.log(` ${color.red('✗')} ${color.dim(region.kebab.padEnd(20))} ${color.red(region.error)}`);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const changed = region.results.filter((r) => r.status === 'changed' || r.status === 'resized');
|
|
417
|
+
const fresh = region.results.filter((r) => r.status === 'new' || r.status === 'recorded');
|
|
418
|
+
const mark = changed.length ? color.red('✗') : fresh.length ? color.yellow('+') : color.green('✓');
|
|
419
|
+
console.log(
|
|
420
|
+
` ${mark} ${color.dim(region.kebab.padEnd(20))} ` +
|
|
421
|
+
(changed.length ? color.red(`${changed.length} changed`) : color.dim(`${region.results.length} shot(s)${fresh.length ? `, ${fresh.length} new` : ''}`)),
|
|
422
|
+
);
|
|
423
|
+
for (const r of changed) {
|
|
424
|
+
const detail = r.status === 'resized' ? `size ${r.from?.join('×')} → ${r.to?.join('×')}` : `${r.diffPixels} px (${(r.ratio * 100).toFixed(2)}%)`;
|
|
425
|
+
console.log(` ${color.red('✗')} ${color.dim(r.file.padEnd(28))} ${detail}`);
|
|
426
|
+
if (r.artifact) console.log(` ${color.dim(relative(process.cwd(), r.artifact))}`);
|
|
427
|
+
}
|
|
428
|
+
if (changed.length) {
|
|
429
|
+
const members = (region.members ?? []).map((m) => m.element?.replace(/^x-/, '') ?? '').filter(Boolean);
|
|
430
|
+
const guilty = members.filter((k) => changedIslands.has(k));
|
|
431
|
+
const uncompared = members.filter((k) => !comparedIslands.has(k));
|
|
432
|
+
console.log(
|
|
433
|
+
guilty.length
|
|
434
|
+
? color.dim(` members that also changed: ${guilty.join(', ')} — the region changed because they did`)
|
|
435
|
+
: uncompared.length
|
|
436
|
+
? color.dim(
|
|
437
|
+
` cannot attribute: ${uncompared.length}/${members.length} member(s) have no accepted baseline ` +
|
|
438
|
+
`(${uncompared.slice(0, 4).join(', ')}${uncompared.length > 4 ? '…' : ''}) — accept them and re-run to tell ` +
|
|
439
|
+
`an arrangement change from an island one`,
|
|
440
|
+
)
|
|
441
|
+
: color.yellow(' no member island changed — this is the ARRANGEMENT, which nothing else checks'),
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
console.log('');
|
|
446
|
+
const freshRegions = all.reduce((n, r) => n + r.results.filter((x) => x.status === 'new').length, 0);
|
|
447
|
+
console.log(
|
|
448
|
+
failed.length === 0
|
|
449
|
+
? (freshRegions
|
|
450
|
+
? color.yellow(color.bold('LOOK')) +
|
|
451
|
+
color.dim(` ${freshRegions} shot(s) nobody has looked at — open the region view before accepting`)
|
|
452
|
+
: color.green(color.bold('PASS')))
|
|
453
|
+
: color.red(color.bold('FAIL')) + color.dim(` ${failed.length} region(s) changed`),
|
|
454
|
+
);
|
|
455
|
+
process.exit(failed.length === 0 ? 0 : 1);
|
|
456
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// `motu sync` — regenerate everything motu derives from the files on disk and the config.
|
|
2
|
+
//
|
|
3
|
+
// ONE VERB, because the derived files are one kind of thing: never edited by hand, and stale is a
|
|
4
|
+
// build error. There used to be two — `island sync` (the element registry and the contracts) and
|
|
5
|
+
// `archipelago sync` (the region-side module coverage's deployment config lives in) — split by noun
|
|
6
|
+
// so each command owned what it named. The split was right about ownership and wrong about use:
|
|
7
|
+
// nobody runs half a regeneration on purpose, and running the half that was not stale left `motu
|
|
8
|
+
// check` red with a message naming the other verb. `island sync` / `archipelago sync` still answer,
|
|
9
|
+
// and run this.
|
|
10
|
+
import { paths, color, names, islandComponentPath, islandComponentExport } from '../lib/util.mjs';
|
|
11
|
+
import { syncRegistry } from '../lib/islands.mjs';
|
|
12
|
+
import { syncContracts } from '../lib/contracts.mjs';
|
|
13
|
+
import { syncArchipelagos } from '../lib/archipelagos.mjs';
|
|
14
|
+
|
|
15
|
+
export function syncCommand() {
|
|
16
|
+
// The ISLAND side: the element registry and the contracts read from each component.
|
|
17
|
+
//
|
|
18
|
+
// Static imports in the registry rather than a glob, because the barrel that exports it is consumed
|
|
19
|
+
// by the host application's bundler, which has no `import.meta.glob`.
|
|
20
|
+
const { path, count } = syncRegistry(paths.islandsDir, paths.isolation);
|
|
21
|
+
console.log(`${paths.rel(path)} — ${count} island(s)`);
|
|
22
|
+
const contracts = syncContracts(paths.islandsDir, { islandComponentPath, islandComponentExport, names });
|
|
23
|
+
console.log(`${paths.rel(contracts.path)} — ${contracts.count} contract(s)${contracts.changed ? '' : ' (unchanged)'}`);
|
|
24
|
+
|
|
25
|
+
// The REGION side: coverage's deployment config. It names regions (`regions: ["actions"]`), so it
|
|
26
|
+
// is generated beside the archipelagos, not baked into the island registry it once rode in.
|
|
27
|
+
const regions = syncArchipelagos(paths.archipelagosDir, paths.coverage);
|
|
28
|
+
const wanted = paths.coverage?.regions;
|
|
29
|
+
const state = regions.enabled
|
|
30
|
+
? `coverage on for ${!wanted ? 'every region (no `regions` declared)' : wanted.includes('*') ? 'every region (*)' : wanted.join(', ')}`
|
|
31
|
+
: 'coverage off — an empty module, so nothing names @motujs/coverage';
|
|
32
|
+
console.log(`${paths.rel(regions.path)} — ${state}`);
|
|
33
|
+
if (regions.registry === 'import added') {
|
|
34
|
+
console.log(color.dim(` added the import to ${paths.rel(paths.archipelagosDir)}/registry.ts`));
|
|
35
|
+
} else if (regions.registry === 'no registry') {
|
|
36
|
+
console.log(color.yellow(` ! no registry.ts in ${paths.rel(paths.archipelagosDir)} — nothing imports this module, so it will not run`));
|
|
37
|
+
}
|
|
38
|
+
}
|