@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.
- 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,236 @@
|
|
|
1
|
+
// `motu check — worlds`: do the declared worlds hold together, and does every one earn its place?
|
|
2
|
+
//
|
|
3
|
+
// A WORLD (docs/proposals/worlds.md) is a named overlay on what the lagoon override file already
|
|
4
|
+
// declares — a region's seed, a source's channel args, its wire, page props — plus facts a lagoon
|
|
5
|
+
// stand-in reads. Statically, without a browser:
|
|
6
|
+
//
|
|
7
|
+
// world-declared every name resolves: a flow's `world`, a world's region, its source ids, its slots,
|
|
8
|
+
// its `extends`. A name that resolves to nothing is an error, as in an address.
|
|
9
|
+
// world-used a world no flow names is a warning: nothing without a caller (the roadmap's rule).
|
|
10
|
+
// world-pins a flow in a world, or a world's own seed, must not seed a key one of the region's
|
|
11
|
+
// SOURCES produces. A seeded key is pinned and the source can never move it — the trap
|
|
12
|
+
// that kept Peps' week navigator on the current week while showing a past one.
|
|
13
|
+
//
|
|
14
|
+
// `world-differs` (does the world change anything?) needs a browser, and runs with the region's flows.
|
|
15
|
+
//
|
|
16
|
+
// READ, NOT EVALUATED: the override file imports the application, which plain node cannot load.
|
|
17
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
18
|
+
import { dirname, join, resolve } from 'node:path';
|
|
19
|
+
import { SyntaxKind } from 'ts-morph';
|
|
20
|
+
import { paths, resolveAppImport } from './util.mjs';
|
|
21
|
+
import { sourceFileAt } from './ts-project.mjs';
|
|
22
|
+
import { readRegions } from './eject.mjs';
|
|
23
|
+
import { declaredSourcesOf } from './declared-sources.mjs';
|
|
24
|
+
import { screensFile } from './screens.mjs';
|
|
25
|
+
|
|
26
|
+
const text = (node) =>
|
|
27
|
+
node && (node.getKind() === SyntaxKind.StringLiteral || node.getKind() === SyntaxKind.NoSubstitutionTemplateLiteral)
|
|
28
|
+
? node.getLiteralText()
|
|
29
|
+
: null;
|
|
30
|
+
const nameOf = (prop) => (prop.getNameNode?.()?.getText?.() ?? '').replace(/^['"`]|['"`]$/g, '');
|
|
31
|
+
const objectKeys = (obj) =>
|
|
32
|
+
obj?.getKind?.() === SyntaxKind.ObjectLiteralExpression
|
|
33
|
+
? obj.getProperties().filter((p) => p.getKind() === SyntaxKind.PropertyAssignment || p.getKind() === SyntaxKind.ShorthandPropertyAssignment).map(nameOf)
|
|
34
|
+
: [];
|
|
35
|
+
const propOf = (obj, name) => (obj?.getKind?.() === SyntaxKind.ObjectLiteralExpression ? obj.getProperty?.(name)?.getInitializer?.() : undefined);
|
|
36
|
+
|
|
37
|
+
function walk(dir, out = []) {
|
|
38
|
+
if (!existsSync(dir)) return out;
|
|
39
|
+
for (const e of readdirSync(dir)) {
|
|
40
|
+
if (e === 'node_modules' || e.startsWith('.')) continue;
|
|
41
|
+
const p = join(dir, e);
|
|
42
|
+
if (statSync(p).isDirectory()) walk(p, out);
|
|
43
|
+
else if (/\.(ts|tsx|mts)$/.test(e)) out.push(p);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The project's declared worlds, read from the first `defineWorlds({…})` in the lagoon's sources.
|
|
50
|
+
* Null when none is declared — then no section is printed at all.
|
|
51
|
+
*/
|
|
52
|
+
export function declaredWorldsStatic() {
|
|
53
|
+
for (const file of walk(resolve(paths.lagoonDir, 'src'))) {
|
|
54
|
+
let sf;
|
|
55
|
+
try {
|
|
56
|
+
sf = sourceFileAt(file, { allowJs: true, jsx: 4 });
|
|
57
|
+
} catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const call = sf
|
|
61
|
+
.getDescendantsOfKind(SyntaxKind.CallExpression)
|
|
62
|
+
.find((c) => c.getExpression().getText() === 'defineWorlds');
|
|
63
|
+
if (!call) continue;
|
|
64
|
+
const obj = call.getArguments()[0];
|
|
65
|
+
const worlds = {};
|
|
66
|
+
for (const w of obj?.getKind?.() === SyntaxKind.ObjectLiteralExpression ? obj.getProperties() : []) {
|
|
67
|
+
const id = nameOf(w);
|
|
68
|
+
const spec = w.getInitializer?.();
|
|
69
|
+
const regions = {};
|
|
70
|
+
const regionsObj = propOf(spec, 'regions');
|
|
71
|
+
for (const r of regionsObj?.getKind?.() === SyntaxKind.ObjectLiteralExpression ? regionsObj.getProperties() : []) {
|
|
72
|
+
const ov = r.getInitializer?.();
|
|
73
|
+
regions[nameOf(r)] = {
|
|
74
|
+
seed: objectKeys(propOf(ov, 'seed')),
|
|
75
|
+
channels: objectKeys(propOf(ov, 'channels')),
|
|
76
|
+
props: objectKeys(propOf(ov, 'props')),
|
|
77
|
+
wire: !!propOf(ov, 'wire'),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
worlds[id] = {
|
|
81
|
+
extends: text(propOf(spec, 'extends')),
|
|
82
|
+
regions,
|
|
83
|
+
facts: objectKeys(propOf(spec, 'facts')),
|
|
84
|
+
line: w.getStartLineNumber(),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return { file, worlds };
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** `scenarios` in an evidence file, as written: name, world, seed keys, and (screens) the start screen. */
|
|
93
|
+
export function flowsWorlds(file) {
|
|
94
|
+
if (!existsSync(file)) return [];
|
|
95
|
+
let sf;
|
|
96
|
+
try {
|
|
97
|
+
sf = sourceFileAt(file, { allowJs: true });
|
|
98
|
+
} catch {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
const decl = sf.getVariableDeclaration('scenarios');
|
|
102
|
+
const arr = decl?.getInitializer?.();
|
|
103
|
+
const list = arr?.getKind?.() === SyntaxKind.AsExpression ? arr.getExpression() : arr;
|
|
104
|
+
if (list?.getKind?.() !== SyntaxKind.ArrayLiteralExpression) return [];
|
|
105
|
+
return list
|
|
106
|
+
.getElements()
|
|
107
|
+
.filter((e) => e.getKind() === SyntaxKind.ObjectLiteralExpression)
|
|
108
|
+
.map((o) => ({
|
|
109
|
+
name: text(propOf(o, 'name')) ?? '(unnamed flow)',
|
|
110
|
+
world: text(propOf(o, 'world')),
|
|
111
|
+
screen: text(propOf(o, 'screen')),
|
|
112
|
+
seed: objectKeys(propOf(o, 'seed')),
|
|
113
|
+
line: o.getStartLineNumber(),
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Which keys each of a region's RUNNING sources produces — the ones declared by reference, which a
|
|
119
|
+
* channel installs and which keep moving their keys in the lagoon. Their `produces` lives in their own
|
|
120
|
+
* module, resolved and read here.
|
|
121
|
+
*
|
|
122
|
+
* A source declared by MODULE NAME (`activity: { module, produces }`) is a claim about the page's code;
|
|
123
|
+
* nothing installs it in the lagoon, so its keys only ever hold what was seeded. Seeding them pins
|
|
124
|
+
* nothing, and a world re-answers them through `seed` — so they are left out here, deliberately.
|
|
125
|
+
*/
|
|
126
|
+
export function producedKeys(archipelagoFile) {
|
|
127
|
+
const out = {};
|
|
128
|
+
for (const [id, src] of Object.entries(declaredSourcesOf(archipelagoFile))) {
|
|
129
|
+
if (!src.byReference || !src.module) continue;
|
|
130
|
+
let keys = [];
|
|
131
|
+
const base = resolveAppImport(archipelagoFile, src.module);
|
|
132
|
+
const file = base && ['', '.ts', '.tsx', '.js', '/index.ts', '/index.tsx'].map((x) => base + x).find((f) => existsSync(f) && statSync(f).isFile());
|
|
133
|
+
if (file) {
|
|
134
|
+
try {
|
|
135
|
+
const sf = sourceFileAt(file, { allowJs: true, jsx: 4 });
|
|
136
|
+
const prop = sf
|
|
137
|
+
.getDescendantsOfKind(SyntaxKind.PropertyAssignment)
|
|
138
|
+
.find((p) => nameOf(p) === 'produces' && p.getInitializer()?.getKind() === SyntaxKind.ArrayLiteralExpression);
|
|
139
|
+
keys = prop ? prop.getInitializer().getElements().map(text).filter(Boolean) : [];
|
|
140
|
+
} catch {
|
|
141
|
+
keys = [];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
out[id] = keys;
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The findings, in the `{ level, check, msg }` shape the screens section already prints. */
|
|
150
|
+
export function worldFindings() {
|
|
151
|
+
const declared = declaredWorldsStatic();
|
|
152
|
+
if (!declared) return null;
|
|
153
|
+
const regions = readRegions(paths.archipelagosDir).map((r) => ({
|
|
154
|
+
id: r.id,
|
|
155
|
+
slots: (r.islands ?? []).map((i) => i.slot),
|
|
156
|
+
sources: producedKeys(paths.archipelagoFile(r.id)),
|
|
157
|
+
flows: flowsWorlds(paths.archipelagoEvidence(r.id)),
|
|
158
|
+
}));
|
|
159
|
+
const screenFlows = flowsWorlds(resolve(dirname(screensFile()), 'screens.evidence.ts'));
|
|
160
|
+
const findings = judgeWorlds({ worlds: declared.worlds, regions, screenFlows, where: (line) => `${paths.rel(declared.file)}:${line}` });
|
|
161
|
+
return { file: declared.file, worlds: Object.keys(declared.worlds), findings };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The rules, over what was read: no file system, so each is testable on its own.
|
|
166
|
+
* `regions`: `[{ id, slots, sources: { sourceId: producedKeys[] }, flows }]`.
|
|
167
|
+
*/
|
|
168
|
+
export function judgeWorlds({ worlds, regions: regionList, screenFlows = [], where = (line) => `line ${line}` }) {
|
|
169
|
+
const findings = [];
|
|
170
|
+
const add = (level, check, msg) => findings.push({ level, check, msg });
|
|
171
|
+
const ids = new Set(['default', ...Object.keys(worlds)]);
|
|
172
|
+
const regions = new Map(regionList.map((r) => [r.id, r]));
|
|
173
|
+
const producedBy = (sources) => new Map(Object.entries(sources).flatMap(([s, keys]) => keys.map((k) => [k, s])));
|
|
174
|
+
|
|
175
|
+
// world-declared: what a world names.
|
|
176
|
+
let named = 0;
|
|
177
|
+
for (const [id, w] of Object.entries(worlds)) {
|
|
178
|
+
if (w.extends && !ids.has(w.extends)) add('error', 'world-declared', `world "${id}" extends "${w.extends}", which is not declared (${where(w.line)})`);
|
|
179
|
+
for (const [rid, ov] of Object.entries(w.regions ?? {})) {
|
|
180
|
+
named++;
|
|
181
|
+
const region = regions.get(rid);
|
|
182
|
+
if (!region) {
|
|
183
|
+
add('error', 'world-declared', `world "${id}" re-answers region "${rid}", which does not exist (${where(w.line)})`);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const sources = region.sources;
|
|
187
|
+
for (const ch of ov.channels ?? []) {
|
|
188
|
+
if (!(ch in sources)) add('error', 'world-declared', `world "${id}" re-answers source "${ch}" of ${rid}, which has no source a channel installs under that name — installable: ${Object.keys(sources).join(', ') || 'none'} (a source declared by module name is re-answered through \`seed\`)`);
|
|
189
|
+
}
|
|
190
|
+
const slots = new Set(region.slots);
|
|
191
|
+
for (const slot of ov.props ?? []) if (!slots.has(slot)) add('error', 'world-declared', `world "${id}" passes props to slot "${slot}" of ${rid}, which has no such slot`);
|
|
192
|
+
// world-pins, on the world's own seed.
|
|
193
|
+
const produced = producedBy(sources);
|
|
194
|
+
for (const key of ov.seed ?? []) {
|
|
195
|
+
if (produced.has(key)) add('error', 'world-pins', `world "${id}" seeds ${rid}.${key}, which source "${produced.get(key)}" produces — a seeded key is pinned and the source can never move it. Re-answer the source instead (\`channels: { ${produced.get(key)}: [...] }\`)`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// world-declared / world-pins / world-used: what flows name.
|
|
201
|
+
const users = new Map([...ids].map((id) => [id, 0]));
|
|
202
|
+
let flowsSeen = 0;
|
|
203
|
+
for (const region of regions.values()) {
|
|
204
|
+
const produced = producedBy(region.sources);
|
|
205
|
+
for (const f of region.flows) {
|
|
206
|
+
flowsSeen++;
|
|
207
|
+
if (!f.world) continue;
|
|
208
|
+
if (!ids.has(f.world)) {
|
|
209
|
+
add('error', 'world-declared', `${region.id} flow "${f.name}" names world "${f.world}", which is not declared — declared: ${[...ids].join(', ')}`);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
users.set(f.world, users.get(f.world) + 1);
|
|
213
|
+
for (const key of f.seed) {
|
|
214
|
+
if (produced.has(key)) add('error', 'world-pins', `${region.id} flow "${f.name}" (world "${f.world}") seeds ${key}, which source "${produced.get(key)}" produces — it is pinned, and the world's answer for that source can never show`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
for (const f of screenFlows) {
|
|
219
|
+
flowsSeen++;
|
|
220
|
+
if (!f.world) continue;
|
|
221
|
+
if (!ids.has(f.world)) add('error', 'world-declared', `screen flow "${f.name}" names world "${f.world}", which is not declared`);
|
|
222
|
+
else users.set(f.world, users.get(f.world) + 1);
|
|
223
|
+
}
|
|
224
|
+
// A world another world EXTENDS is used through it.
|
|
225
|
+
for (const w of Object.values(worlds)) if (w.extends && users.has(w.extends) && users.get(w.extends) === 0) users.set(w.extends, -1);
|
|
226
|
+
for (const [id, n] of users) {
|
|
227
|
+
if (id === 'default') continue;
|
|
228
|
+
if (!n) add('warn', 'world-used', `no flow runs in world "${id}" — a world nobody uses is a removal candidate (${where(worlds[id].line)})`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!findings.some((f) => f.level === 'error' && f.check === 'world-declared')) {
|
|
232
|
+
add('ok', 'world-declared', `${ids.size - 1} world(s), every name resolves · ${named} region overlay(s), ${flowsSeen} flow(s) read`);
|
|
233
|
+
}
|
|
234
|
+
if (!findings.some((f) => f.check === 'world-pins')) add('ok', 'world-pins', 'no world or world flow pins a key a source produces');
|
|
235
|
+
return findings;
|
|
236
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// A write that does not touch the file when the bytes are already there.
|
|
2
|
+
//
|
|
3
|
+
// EVERY generated lagoon entry goes through this, and it is not an optimisation. Vite watches
|
|
4
|
+
// `.motu/cache/lagoon/**`, and the entries are re-rendered on EVERY lagoon build — `motu island verify
|
|
5
|
+
// --runtime`, `archipelago verify --runtime`, `island snapshot`, `lagoon states`, each of which boots a
|
|
6
|
+
// lagoon of its own. Rendering is deterministic, so those writes were almost always byte-identical, and
|
|
7
|
+
// an identical write still bumps mtime, which still fires the watcher, which still full-reloads every
|
|
8
|
+
// open lagoon tab.
|
|
9
|
+
//
|
|
10
|
+
// The cost was not performance. It was that a person (or an agent) looking at a state in a browser had
|
|
11
|
+
// the page reload under them every time a check ran in another terminal — measured at roughly one
|
|
12
|
+
// reload per CLI invocation, ~6 `[vite] page reload` lines each. It closed a confirm dialog between
|
|
13
|
+
// opening it and screenshotting it, during the "LOOK at it" step motu itself insists on. A preview you
|
|
14
|
+
// cannot hold still is a preview you cannot inspect.
|
|
15
|
+
//
|
|
16
|
+
// So: compare, then write. `existsSync` is not enough (the content may have genuinely changed) and a
|
|
17
|
+
// hash is not worth it (these files are kilobytes).
|
|
18
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { dirname } from 'node:path';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Write `content` to `file` only if it differs from what is there.
|
|
23
|
+
*
|
|
24
|
+
* @returns true when the file was actually written — the caller can report what moved.
|
|
25
|
+
*/
|
|
26
|
+
export function writeIfChanged(file, content) {
|
|
27
|
+
try {
|
|
28
|
+
if (readFileSync(file, 'utf8') === content) return false;
|
|
29
|
+
} catch {
|
|
30
|
+
// Missing or unreadable — fall through and write it.
|
|
31
|
+
}
|
|
32
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
33
|
+
writeFileSync(file, content);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which declared writes no flow exercises — the decision behind `writes-covered`, kept pure so the
|
|
3
|
+
* cases that decide it can be tested without a project on disk.
|
|
4
|
+
*
|
|
5
|
+
* A write `{ slot, event, key }` counts as exercised when a flow step either:
|
|
6
|
+
* - `emit`s that slot's event (the region applies the declared write), or
|
|
7
|
+
* - `expect`s the key in a scenario where neither the seed nor any `provide` hands it a value.
|
|
8
|
+
* One producer per key is enforced statically, so such an expectation can only hold if the
|
|
9
|
+
* producing component emitted the event ITSELF — a stronger proof than an `emit`, which enters
|
|
10
|
+
* past the component's own handler.
|
|
11
|
+
*
|
|
12
|
+
* Counting only `emit` reported writes as "never exercised" in a region whose flow failed the moment
|
|
13
|
+
* the component stopped emitting them.
|
|
14
|
+
*
|
|
15
|
+
* @param {{ slot: string, event: string, key: string }[]} declared
|
|
16
|
+
* @param {{ seed?: Record<string, unknown>, steps?: { emit?: { slot?: string, event?: string }, provide?: Record<string, unknown>, expect?: Record<string, unknown> }[] }[]} scenarios
|
|
17
|
+
*/
|
|
18
|
+
export function undrivenWrites(declared, scenarios) {
|
|
19
|
+
const driven = new Set();
|
|
20
|
+
const assertedFromProducer = new Set();
|
|
21
|
+
for (const scenario of scenarios) {
|
|
22
|
+
const handed = new Set(Object.keys(scenario.seed ?? {}));
|
|
23
|
+
for (const st of scenario.steps ?? []) {
|
|
24
|
+
for (const k of Object.keys(st.provide ?? {})) handed.add(k);
|
|
25
|
+
}
|
|
26
|
+
for (const st of scenario.steps ?? []) {
|
|
27
|
+
if (st.emit?.slot && st.emit?.event) driven.add(`${st.emit.slot}\u0000${st.emit.event}`);
|
|
28
|
+
for (const k of Object.keys(st.expect ?? {})) if (!handed.has(k)) assertedFromProducer.add(k);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return declared.filter((d) => !driven.has(`${d.slot}\u0000${d.event}`) && !assertedFromProducer.has(d.key));
|
|
32
|
+
}
|