@delurio/cli 0.0.0 → 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.
@@ -0,0 +1,69 @@
1
+ // `changes:` in ui.yaml (SPEC.md §4): a flow whose steps write something tells its users, in its own note, what it
2
+ // changes and what follows. An action writes when its route is not a GET, when a policy lets someone `write` with it, or
3
+ // when it is a step inside the service (no route) that returns an entity; all three are read from the flow's domain,
4
+ // looked up by id among the graphs beside this one. A flow with no screens, or whose domain is not found, is not checked.
5
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
6
+ import { basename, dirname, join } from 'node:path';
7
+ import { parse } from 'yaml';
8
+ const SKIP = new Set(['viz', 'video', 'node_modules', '.git']);
9
+ // the root to search: the nearest folder called `graphs` above the graph, else the graph's parent
10
+ function rootOf(dir) {
11
+ for (let d = dir, i = 0; i < 5; i++, d = dirname(d))
12
+ if (basename(d) === 'graphs')
13
+ return d;
14
+ return dirname(dir);
15
+ }
16
+ function domainUnder(root, id, depth = 4) {
17
+ const file = join(root, 'domain.yaml');
18
+ if (existsSync(file)) {
19
+ const d = parse(readFileSync(file, 'utf8'));
20
+ if (d?.id === id)
21
+ return d;
22
+ }
23
+ if (depth === 0)
24
+ return null;
25
+ for (const name of readdirSync(root)) {
26
+ if (SKIP.has(name))
27
+ continue;
28
+ const sub = join(root, name);
29
+ if (!statSync(sub).isDirectory())
30
+ continue;
31
+ const found = domainUnder(sub, id, depth - 1);
32
+ if (found !== null)
33
+ return found;
34
+ }
35
+ return null;
36
+ }
37
+ export function checkChanges(g) {
38
+ if (g.ui.absent === true || g.flow.domain === undefined)
39
+ return null;
40
+ const domain = domainUnder(rootOf(g.dir), g.flow.domain);
41
+ if (domain === null)
42
+ return null;
43
+ const writing = new Set();
44
+ for (const [name, a] of Object.entries(domain.actions ?? {})) {
45
+ const method = a?.route?.trim().split(/\s+/)[0]?.toUpperCase();
46
+ if ((method !== undefined && method !== 'GET') || (method === undefined && a?.result?.returns !== undefined))
47
+ writing.add(name);
48
+ }
49
+ for (const p of domain.policies ?? [])
50
+ if ((p.write ?? []).length > 0)
51
+ for (const name of p.do ?? [])
52
+ writing.add(name);
53
+ const invoked = Object.values(g.flow.states).map((s) => s.invoke?.action);
54
+ const writes = [...new Set(invoked.filter((a) => a !== undefined && writing.has(a)))].sort();
55
+ const key = g.ui.changes?.key ?? null;
56
+ const problems = [];
57
+ if (writes.length > 0 && key === null)
58
+ problems.push(`its steps write (${writes.join(', ')}), but ui.yaml has no changes note (SPEC.md §4)`);
59
+ if (key !== null) {
60
+ for (const locale of g.ui.locales ?? [basename(g.ui.catalog, '.json')]) {
61
+ const file = join(g.dir, dirname(g.ui.catalog), `${locale}.json`);
62
+ const catalog = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null;
63
+ const note = catalog?.[key];
64
+ if (typeof note !== 'string' || note.trim() === '')
65
+ problems.push(`the changes note ${key} is not in the ${locale} catalog`);
66
+ }
67
+ }
68
+ return { writes, key, problems };
69
+ }
package/dist/check.js ADDED
@@ -0,0 +1,266 @@
1
+ // `check`: every graph under a folder, as a project's CI runs it (graphs/agents/cli.check, SPEC.md §10). It reads the
2
+ // files and nothing else, writes no page, and gives each finding as a GNU error line, or as SARIF 2.1.0 with
3
+ // --format sarif. Exit 0 when clean, 1 when a graph cannot be loaded or is written for a newer format, 2 when an error
4
+ // is found under --strict (and, with --baseline, new since that run).
5
+ import { createHash } from 'node:crypto';
6
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
7
+ import { join, relative, resolve } from 'node:path';
8
+ import { parse } from 'yaml';
9
+ import { GraphError, load } from './graph.js';
10
+ import { checkAssertions, explore, Model, ModelError } from './model.js';
11
+ import { stepsByEvents } from './story.js';
12
+ import { renderScreens } from './wireframe.js';
13
+ import { checkStarts } from './starts.js';
14
+ import { checkChanges } from './changes.js';
15
+ import { KitError, resolveKit } from './kit.js';
16
+ import { checkGoals, findings, loadNav, viewOf } from './nav.js';
17
+ export const CHECK_USAGE = [
18
+ ' node src/cli.ts check <graphs folder> [--strict] [--baseline <sarif>] [--format sarif --output <file>]',
19
+ ' checks every graph under the folder and writes nothing else: GNU error lines on stdout, or SARIF 2.1.0;',
20
+ ' exit 0 clean, 1 a graph cannot be loaded or needs a newer format, 2 an error under --strict (new since --baseline)',
21
+ ].join('\n');
22
+ const FORMAT = 'delurio-graph/0';
23
+ const SKIP = new Set(['viz', 'video', 'recording', 'node_modules', '.git']);
24
+ const RULES = [
25
+ { id: 'load', text: 'The graph cannot be loaded', level: 'error' },
26
+ { id: 'format-newer', text: 'The graph is written for a newer format than this checker reads', level: 'error' },
27
+ { id: 'assertion-violated', text: 'A guarantee of the flow is broken by a reachable situation', level: 'error' },
28
+ { id: 'main-story-unwalkable', text: 'The declared main story cannot be walked', level: 'error' },
29
+ { id: 'catalog-key-missing', text: 'A catalog key the screens use is missing', level: 'error' },
30
+ { id: 'sample-missing', text: 'A catalog message has a placeholder with no sample value', level: 'error' },
31
+ { id: 'no-renderer', text: 'A component has no renderer', level: 'error' },
32
+ { id: 'kit-uncovered', text: "The kit does not cover a component or an intent", level: 'error' },
33
+ { id: 'list-item-outside-list', text: 'A list item is drawn outside a list', level: 'error' },
34
+ { id: 'hand-over', text: 'A started flow cannot begin with what it is given', level: 'error' },
35
+ { id: 'changes-note-missing', text: 'A flow whose steps write has no changes note (SPEC.md §4)', level: 'error' },
36
+ { id: 'changes-note-locale', text: "The changes note is missing from a locale's catalog", level: 'error' },
37
+ { id: 'graph-warning', text: 'Another warning of the graph', level: 'error' },
38
+ { id: 'nav-problem', text: 'A problem of the navigation graph', level: 'error' },
39
+ { id: 'nav-note', text: 'A note on the navigation graph', level: 'warning' },
40
+ ];
41
+ const LEVEL = new Map(RULES.map((r) => [r.id, r.level]));
42
+ // the 1-based line and column of the first occurrence of `needle`, else the file's start
43
+ function placeOf(abs, needle) {
44
+ if (needle === null || !existsSync(abs))
45
+ return { line: 1, column: 1 };
46
+ const lines = readFileSync(abs, 'utf8').split('\n');
47
+ for (let i = 0; i < lines.length; i++) {
48
+ const c = lines[i].indexOf(needle);
49
+ if (c >= 0)
50
+ return { line: i + 1, column: c + 1 };
51
+ }
52
+ return { line: 1, column: 1 };
53
+ }
54
+ function graphDirs(root, out = []) {
55
+ if (existsSync(join(root, 'flow.yaml')) || existsSync(join(root, 'nav.yaml')))
56
+ out.push(root);
57
+ for (const name of readdirSync(root).sort()) {
58
+ if (SKIP.has(name) || name.startsWith('.'))
59
+ continue;
60
+ const sub = join(root, name);
61
+ if (statSync(sub).isDirectory())
62
+ graphDirs(sub, out);
63
+ }
64
+ return out;
65
+ }
66
+ // the format the file says it is written in: null when it is this one
67
+ function newerFormat(abs) {
68
+ const doc = parse(readFileSync(abs, 'utf8'));
69
+ const s = typeof doc?.schema === 'string' ? doc.schema : FORMAT;
70
+ return s === FORMAT ? null : s;
71
+ }
72
+ function rel(abs) {
73
+ return relative(process.cwd(), abs) || '.';
74
+ }
75
+ function result(rule, file, at, message, graph, element, notes = []) {
76
+ return { rule, level: LEVEL.get(rule) ?? 'error', file: rel(file), line: at.line, column: at.column, message, graph, element, notes };
77
+ }
78
+ // a warning raised while checking a flow, sorted into its rule and placed in its file
79
+ function fromWarning(dir, graph, w) {
80
+ const flow = join(dir, 'flow.yaml');
81
+ const ui = join(dir, 'ui.yaml');
82
+ let m;
83
+ if ((m = /^catalog key (\S+) is missing/.exec(w)))
84
+ return result('catalog-key-missing', ui, placeOf(ui, m[1]), w, graph, m[1]);
85
+ if ((m = /^no sample value for \{(\w+)\} in (\S+)/.exec(w)))
86
+ return result('sample-missing', ui, placeOf(ui, m[2]), w, graph, `${m[2]}/${m[1]}`);
87
+ if ((m = /^(\S+): ListItem outside a List/.exec(w)))
88
+ return result('list-item-outside-list', ui, placeOf(ui, `${m[1]}:`), w, graph, m[1]);
89
+ if ((m = /\(node (\S+?)\)/.exec(w)) && /^kit /.test(w))
90
+ return result('kit-uncovered', ui, placeOf(ui, `${m[1]}:`), w, graph, m[1]);
91
+ if ((m = /^(\S+): component .* has no wireframe renderer/.exec(w)))
92
+ return result('no-renderer', ui, placeOf(ui, `${m[1]}:`), w, graph, m[1]);
93
+ if (/declared main story cannot be walked/.test(w))
94
+ return result('main-story-unwalkable', flow, placeOf(flow, 'main:'), w, graph, 'main');
95
+ if ((m = /^(\S+) starts (\S+): (.*)$/.exec(w)))
96
+ return result('hand-over', flow, placeOf(flow, `flow: ${m[2]}`), w, graph, `${m[1]}->${m[2]}`);
97
+ if (/has no changes note/.test(w))
98
+ return result('changes-note-missing', ui, placeOf(ui, 'samples:'), w, graph, 'changes');
99
+ if ((m = /^the changes note (\S+) is not in the (\w+) catalog/.exec(w)))
100
+ return result('changes-note-locale', ui, placeOf(ui, 'changes:'), w, graph, `changes/${m[2]}`);
101
+ return result('graph-warning', existsSync(ui) ? ui : flow, { line: 1, column: 1 }, w, graph, w);
102
+ }
103
+ function checkFlow(dir) {
104
+ const file = join(dir, 'flow.yaml');
105
+ const g = load(dir);
106
+ const kit = resolveKit(dir, g.ui.kit, undefined);
107
+ const model = new Model(g.flow);
108
+ const ex = explore(model);
109
+ const out = [];
110
+ const warnings = [];
111
+ const warn = (m) => {
112
+ if (!warnings.includes(m))
113
+ warnings.push(m);
114
+ };
115
+ for (const a of checkAssertions(model, ex)) {
116
+ if (a.violation === undefined)
117
+ continue;
118
+ const at = placeOf(file, `id: ${a.id}`);
119
+ const start = Object.entries(a.violation.start).map(([k, v]) => `${k} = ${String(v)}`).join(', ');
120
+ const notes = [{ file: rel(file), ...at, message: `start: ${start || 'the initial context'}` }];
121
+ a.violation.events.forEach((e, i) => notes.push({ file: rel(file), ...at, message: `step ${i + 1}: ${e}` }));
122
+ notes.push({ file: rel(file), ...at, message: `reached: ${a.violation.state}` });
123
+ out.push(result('assertion-violated', file, at, `${a.id} is broken: ${a.say ?? a.never}`, g.flow.id, a.id, notes));
124
+ }
125
+ if (g.flow.main !== undefined && stepsByEvents(model, g.flow.main) === null)
126
+ warn('the declared main story cannot be walked: an event is not possible where it is sent');
127
+ renderScreens(g, warn, kit);
128
+ for (const h of checkStarts(g, model, ex))
129
+ for (const p of h.problems)
130
+ warn(`${h.from} starts ${h.flow}: ${p}`);
131
+ for (const p of checkChanges(g)?.problems ?? [])
132
+ warn(p);
133
+ for (const w of warnings)
134
+ out.push(fromWarning(dir, g.flow.id, w));
135
+ return out;
136
+ }
137
+ function checkNav(dir) {
138
+ const file = join(dir, 'nav.yaml');
139
+ const { g, warnings } = loadNav(dir, undefined);
140
+ const views = g.personas.map((p) => viewOf(g, p));
141
+ const found = findings(g, views, checkGoals(g, views));
142
+ const out = found.map((f) => result(f.level === 'problem' ? 'nav-problem' : 'nav-note', file, { line: 1, column: 1 }, `${f.say} (${f.code})`, g.id, f.code, f.detail.map((d) => ({ file: rel(file), line: 1, column: 1, message: d }))));
143
+ for (const w of warnings)
144
+ out.push(result('graph-warning', file, { line: 1, column: 1 }, w, g.id, w));
145
+ return out;
146
+ }
147
+ const fingerprint = (r) => createHash('sha256').update(`${r.rule}|${r.graph}|${r.element}`).digest('hex').slice(0, 32);
148
+ function baselinePrints(file) {
149
+ const doc = JSON.parse(readFileSync(file, 'utf8'));
150
+ const out = new Set();
151
+ for (const run of doc.runs ?? [])
152
+ for (const r of run.results ?? [])
153
+ if (r.fingerprints?.['delurio/v1'] !== undefined)
154
+ out.add(r.fingerprints['delurio/v1']);
155
+ return out;
156
+ }
157
+ function toolVersion() {
158
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
159
+ return pkg.version ?? '0.0.0';
160
+ }
161
+ function sarif(results, exitCode) {
162
+ const used = RULES.filter((r) => results.some((x) => x.rule === r.id));
163
+ return {
164
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
165
+ version: '2.1.0',
166
+ runs: [
167
+ {
168
+ tool: {
169
+ driver: {
170
+ name: 'delurio',
171
+ version: toolVersion(),
172
+ informationUri: 'https://delur.io',
173
+ rules: used.map((r) => ({ id: r.id, shortDescription: { text: r.text }, defaultConfiguration: { level: r.level } })),
174
+ },
175
+ },
176
+ automationDetails: { id: 'delurio/check/' },
177
+ invocations: [{ executionSuccessful: exitCode !== 1, exitCode }],
178
+ results: results.map((r) => ({
179
+ ruleId: r.rule,
180
+ ruleIndex: used.findIndex((u) => u.id === r.rule),
181
+ level: r.level,
182
+ message: { text: r.message },
183
+ locations: [
184
+ {
185
+ physicalLocation: { artifactLocation: { uri: r.file.split('\\').join('/') }, region: { startLine: r.line, startColumn: r.column } },
186
+ logicalLocations: [{ fullyQualifiedName: `${r.graph}/${r.element}` }],
187
+ },
188
+ ],
189
+ fingerprints: { 'delurio/v1': fingerprint(r) },
190
+ ...(r.notes.length > 0 && r.rule === 'assertion-violated'
191
+ ? { codeFlows: [{ threadFlows: [{ locations: r.notes.map((n) => ({ location: { message: { text: n.message } } })) }] }] }
192
+ : {}),
193
+ ...(r.baselineState === undefined ? {} : { baselineState: r.baselineState }),
194
+ })),
195
+ },
196
+ ],
197
+ };
198
+ }
199
+ export function checkMain(args) {
200
+ const flag = (name) => {
201
+ const i = args.indexOf(name);
202
+ return i >= 0 ? args[i + 1] : undefined;
203
+ };
204
+ const withValue = ['--baseline', '--format', '--output'];
205
+ const dir = args.find((a, i) => !a.startsWith('--') && !withValue.includes(args[i - 1] ?? ''));
206
+ if (dir === undefined) {
207
+ console.error(`usage:\n${CHECK_USAGE}`);
208
+ return 1;
209
+ }
210
+ const root = resolve(dir);
211
+ const strict = args.includes('--strict');
212
+ const format = flag('--format') ?? 'text';
213
+ const output = flag('--output');
214
+ const baseline = flag('--baseline');
215
+ if (format !== 'text' && format !== 'sarif') {
216
+ console.error(`--format is text or sarif, not ${format}`);
217
+ return 1;
218
+ }
219
+ const results = [];
220
+ let broken = false;
221
+ for (const d of graphDirs(root)) {
222
+ const file = existsSync(join(d, 'flow.yaml')) ? join(d, 'flow.yaml') : join(d, 'nav.yaml');
223
+ try {
224
+ const newer = newerFormat(file);
225
+ if (newer !== null) {
226
+ broken = true;
227
+ results.push(result('format-newer', file, placeOf(file, 'schema:'), `written in ${newer}; this checker reads ${FORMAT}: pin a version that reads it`, rel(d), 'schema'));
228
+ continue;
229
+ }
230
+ results.push(...(file.endsWith('flow.yaml') ? checkFlow(d) : checkNav(d)));
231
+ }
232
+ catch (err) {
233
+ if (!(err instanceof GraphError || err instanceof ModelError || err instanceof KitError || (err instanceof Error && err.name === 'YAMLParseError')))
234
+ throw err;
235
+ broken = true;
236
+ const linePos = err.linePos?.[0];
237
+ results.push(result('load', file, linePos === undefined ? { line: 1, column: 1 } : { line: linePos.line, column: linePos.col }, err.message.split('\n')[0], rel(d), 'load'));
238
+ }
239
+ }
240
+ results.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column || a.rule.localeCompare(b.rule) || a.message.localeCompare(b.message));
241
+ if (baseline !== undefined) {
242
+ const prints = baselinePrints(baseline);
243
+ for (const r of results)
244
+ r.baselineState = prints.has(fingerprint(r)) ? 'unchanged' : 'new';
245
+ }
246
+ const failing = results.filter((r) => r.level === 'error' && r.rule !== 'load' && r.rule !== 'format-newer' && (r.baselineState === undefined || r.baselineState === 'new'));
247
+ const exitCode = broken ? 1 : strict && failing.length > 0 ? 2 : 0;
248
+ if (format === 'sarif') {
249
+ const text = `${JSON.stringify(sarif(results, exitCode), null, 2)}\n`;
250
+ if (output === undefined)
251
+ process.stdout.write(text);
252
+ else
253
+ writeFileSync(output, text);
254
+ }
255
+ if (format === 'text' || output !== undefined) {
256
+ for (const r of results) {
257
+ const level = r.level === 'error' && r.baselineState === 'unchanged' ? 'warning' : r.level;
258
+ console.log(`${r.file}:${r.line}:${r.column}: ${level}: ${r.message} [${r.rule}]`);
259
+ for (const n of r.notes)
260
+ console.log(`${n.file}:${n.line}:${n.column}: note: ${n.message}`);
261
+ }
262
+ const graphs = graphDirs(root).length;
263
+ console.error(`delurio check: ${graphs} graphs, ${results.filter((r) => r.level === 'error').length} errors, ${results.filter((r) => r.level === 'warning').length} warnings; exit ${exitCode}`);
264
+ }
265
+ return exitCode;
266
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ // delurio: the open checker and page of delurio-graph/0 (SPEC.md). Reads graph files and nothing else: no network, no
3
+ // account, no model.
4
+ import { mkdirSync, writeFileSync } from 'node:fs';
5
+ import { existsSync } from 'node:fs';
6
+ import { join, resolve } from 'node:path';
7
+ import { GraphError, load } from './graph.js';
8
+ import { checkAssertions, explore, finalPaths, Model, ModelError } from './model.js';
9
+ import { renderMermaid } from './mermaid.js';
10
+ import { renderSpine } from './spine.js';
11
+ import { renderScreens } from './wireframe.js';
12
+ import { renderPage } from './html.js';
13
+ import { checkChanges } from './changes.js';
14
+ import { checkStarts } from './starts.js';
15
+ import { mainStoryIndex, narrate, stepsByEvents, stepsTo } from './story.js';
16
+ import { checkGoals, findings, loadNav, menuRows, viewOf } from './nav.js';
17
+ import { renderNavMermaid, renderNavPage } from './nav-page.js';
18
+ import { DIFF_USAGE, diffMain } from './diff.js';
19
+ import { KitError, resolveKit } from './kit.js';
20
+ import { CHECK_USAGE, checkMain } from './check.js';
21
+ const USAGE = [
22
+ 'usage:',
23
+ CHECK_USAGE,
24
+ ' delurio page <graph dir> [--out <dir>] [--kit <kit>] [--locale <code>] [--strict]',
25
+ ' writes the graph\'s page: <out>/index.html and its diagram (default out: <graph dir>/viz)',
26
+ DIFF_USAGE.replaceAll('node src/cli.ts', 'delurio'),
27
+ ].join('\n').replaceAll('node src/cli.ts', 'delurio');
28
+ const flag = (args, name) => {
29
+ const i = args.indexOf(name);
30
+ return i >= 0 ? args[i + 1] : undefined;
31
+ };
32
+ function navPage(dir, args) {
33
+ const out = resolve(flag(args, '--out') ?? join(dir, 'viz'));
34
+ const { g, warnings } = loadNav(dir, flag(args, '--locale'));
35
+ const views = g.personas.map((p) => viewOf(g, p));
36
+ const rows = menuRows(g, views);
37
+ const goals = checkGoals(g, views);
38
+ const found = findings(g, views, goals);
39
+ mkdirSync(out, { recursive: true });
40
+ writeFileSync(join(out, 'index.html'), renderNavPage({ g, views, rows, goals, found, repo: null, warnings }));
41
+ writeFileSync(join(out, 'nav.mmd'), renderNavMermaid(g));
42
+ const problems = found.filter((f) => f.level === 'problem');
43
+ console.log(`${g.id}: ${Object.keys(g.pages).length} pages, ${g.specs.length} ways to move, ${g.personas.length} kinds of user; ${problems.length} problems, ${found.length - problems.length} notes`);
44
+ for (const w of warnings)
45
+ console.error(`warning: ${w}`);
46
+ console.log(`wrote ${join(out, 'index.html')} and ${join(out, 'nav.mmd')}`);
47
+ return args.includes('--strict') && (warnings.length > 0 || problems.length > 0) ? 2 : 0;
48
+ }
49
+ async function flowPage(dir, args) {
50
+ const out = resolve(flag(args, '--out') ?? join(dir, 'viz'));
51
+ const warnings = [];
52
+ const warn = (m) => {
53
+ if (!warnings.includes(m))
54
+ warnings.push(m);
55
+ };
56
+ const g = load(dir);
57
+ const kit = resolveKit(dir, g.ui.kit, flag(args, '--kit'));
58
+ const model = new Model(g.flow);
59
+ const ex = explore(model);
60
+ const assertions = checkAssertions(model, ex);
61
+ const finals = finalPaths(model, ex);
62
+ const mainIndex = mainStoryIndex(g, ex);
63
+ const declared = g.flow.main === undefined ? null : stepsByEvents(model, g.flow.main);
64
+ if (g.flow.main !== undefined && declared === null)
65
+ warn('the declared main story cannot be walked: an event is not possible where it is sent');
66
+ const mainSteps = declared ?? (mainIndex === null ? null : stepsTo(model, ex, mainIndex));
67
+ const main = mainSteps === null ? null : narrate(g, model, mainSteps);
68
+ const spineMain = await renderSpine(g, ex, 'main', mainSteps);
69
+ const spineFull = await renderSpine(g, ex, 'full', mainSteps);
70
+ const screens = renderScreens(g, warn, kit);
71
+ const handOvers = checkStarts(g, model, ex);
72
+ for (const h of handOvers)
73
+ for (const p of h.problems)
74
+ warn(`${h.from} starts ${h.flow}: ${p}`);
75
+ for (const p of checkChanges(g)?.problems ?? [])
76
+ warn(p);
77
+ mkdirSync(out, { recursive: true });
78
+ writeFileSync(join(out, 'index.html'), renderPage({ g, m: model, ex, assertions, finals, main, spineMain, spineFull, screens, kit, handOvers }));
79
+ writeFileSync(join(out, 'flow.mmd'), renderMermaid(g, ex));
80
+ const violated = assertions.filter((a) => a.violation !== undefined);
81
+ console.log(`${g.flow.id}: ${Object.keys(g.flow.states).length} states, ${ex.configs.length} configurations from ${ex.starts} starts, ${assertions.length - violated.length}/${assertions.length} assertions hold`);
82
+ for (const a of violated)
83
+ console.log(` violated: ${a.id}`);
84
+ for (const w of warnings)
85
+ console.error(`warning: ${w}`);
86
+ console.log(`wrote ${join(out, 'index.html')} and ${join(out, 'flow.mmd')}`);
87
+ return args.includes('--strict') && (violated.length > 0 || warnings.length > 0) ? 2 : 0;
88
+ }
89
+ async function main() {
90
+ const args = process.argv.slice(2);
91
+ if (args[0] === 'check')
92
+ return checkMain(args.slice(1));
93
+ if (args[0] === 'diff')
94
+ return diffMain(args.slice(1));
95
+ if (args[0] === 'page') {
96
+ const rest = args.slice(1);
97
+ const withValue = ['--out', '--kit', '--locale'];
98
+ const dir = rest.find((a, i) => !a.startsWith('--') && !withValue.includes(rest[i - 1] ?? ''));
99
+ if (dir === undefined) {
100
+ console.error(USAGE);
101
+ return 1;
102
+ }
103
+ return existsSync(join(dir, 'flow.yaml')) ? flowPage(dir, rest) : navPage(dir, rest);
104
+ }
105
+ console.error(USAGE);
106
+ return args.length === 0 || args.includes('--help') ? 0 : 1;
107
+ }
108
+ main().then((code) => process.exit(code), (err) => {
109
+ if (err instanceof GraphError || err instanceof ModelError || err instanceof KitError) {
110
+ console.error(`error: ${err.message}`);
111
+ process.exit(1);
112
+ }
113
+ throw err;
114
+ });
@@ -0,0 +1,143 @@
1
+ // What the revision diff is made of, for both kinds of graph: structural changes keyed by id, and changes in behaviour.
2
+ // Nothing here knows what a flow or a page is; diff-flow.ts and diff-nav.ts do.
3
+ const own = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
4
+ const isMap = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
5
+ // a value as text with sorted keys, so two values written in a different key order compare equal
6
+ export function canon(v) {
7
+ if (v === undefined)
8
+ return '~';
9
+ if (Array.isArray(v))
10
+ return `[${v.map(canon).join(',')}]`;
11
+ if (isMap(v)) {
12
+ const keys = Object.keys(v).filter((k) => v[k] !== undefined).sort();
13
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(',')}}`;
14
+ }
15
+ return JSON.stringify(v);
16
+ }
17
+ export const same = (a, b) => canon(a) === canon(b);
18
+ // the keys of both maps: the new order first, then the keys only the old one has, in its order
19
+ export function union(before, after) {
20
+ const out = Object.keys(after ?? {});
21
+ for (const k of Object.keys(before ?? {}))
22
+ if (!own(after ?? {}, k))
23
+ out.push(k);
24
+ return out;
25
+ }
26
+ export const quote = (s) => `“${s}”`;
27
+ export const joinWords = (xs) => (xs.length <= 1 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`);
28
+ // Pairs the items of two ordered lists (the rules of an `always`, the alternatives of one event, the entries of a nav graph).
29
+ // The first key pairs items in the order they occur (the n-th with the n-th); a later key pairs what is left only when it is
30
+ // unique on both sides, so nothing is paired by guesswork. Returns [before index, after index] pairs, sorted by before index.
31
+ export function matchOrdered(before, after, keys) {
32
+ const pairs = [];
33
+ const usedB = new Set();
34
+ const usedA = new Set();
35
+ keys.forEach((key, round) => {
36
+ const group = (xs, used) => {
37
+ const m = new Map();
38
+ xs.forEach((x, i) => {
39
+ if (used.has(i))
40
+ return;
41
+ const k = key(x);
42
+ if (k !== null)
43
+ m.set(k, [...(m.get(k) ?? []), i]);
44
+ });
45
+ return m;
46
+ };
47
+ const gb = group(before, usedB);
48
+ const ga = group(after, usedA);
49
+ for (const [k, bs] of gb) {
50
+ const as = ga.get(k);
51
+ if (as === undefined)
52
+ continue;
53
+ if (round > 0 && (bs.length > 1 || as.length > 1))
54
+ continue;
55
+ for (let j = 0; j < Math.min(bs.length, as.length); j++) {
56
+ pairs.push([bs[j], as[j]]);
57
+ usedB.add(bs[j]);
58
+ usedA.add(as[j]);
59
+ }
60
+ }
61
+ });
62
+ return pairs.sort((x, y) => x[0] - y[0]);
63
+ }
64
+ // The pairs that changed place relative to the others: the pairs outside the longest run that keeps its order.
65
+ // An item that only shifted because something was added or removed before it is not moved.
66
+ export function moved(pairs) {
67
+ const sorted = [...pairs].sort((x, y) => x[0] - y[0]);
68
+ const n = sorted.length;
69
+ const len = new Array(n).fill(1);
70
+ const prev = new Array(n).fill(-1);
71
+ for (let i = 0; i < n; i++) {
72
+ for (let j = 0; j < i; j++) {
73
+ if (sorted[j][1] < sorted[i][1] && len[j] + 1 > len[i]) {
74
+ len[i] = len[j] + 1;
75
+ prev[i] = j;
76
+ }
77
+ }
78
+ }
79
+ let best = -1;
80
+ for (let i = 0; i < n; i++)
81
+ if (best < 0 || len[i] > len[best])
82
+ best = i;
83
+ const keep = new Set();
84
+ for (let i = best; i >= 0; i = prev[i])
85
+ keep.add(i);
86
+ return sorted.filter((_, i) => !keep.has(i));
87
+ }
88
+ // A field-by-field comparison for data graph-viz does not interpret (domain.yaml, unknown keys): maps by key, lists whose
89
+ // items all carry an `id` by id, anything else as one value. A field called `source` or `evidence` is a citation.
90
+ export function deepDiff(area, key, before, after, out, path = []) {
91
+ if (same(before, after))
92
+ return;
93
+ if (isMap(before) && isMap(after)) {
94
+ for (const k of union(before, after))
95
+ deepDiff(area, key, before[k], after[k], out, [...path, k]);
96
+ return;
97
+ }
98
+ const byId = (v) => Array.isArray(v) && v.every((x) => isMap(x) && typeof x.id === 'string');
99
+ if (byId(before) && byId(after) && before.length + after.length > 0) {
100
+ const mb = Object.fromEntries(before.map((x) => [x.id, x]));
101
+ const ma = Object.fromEntries(after.map((x) => [x.id, x]));
102
+ if (Object.keys(mb).length === before.length && Object.keys(ma).length === after.length) {
103
+ for (const id of union(mb, ma))
104
+ deepDiff(area, key, mb[id], ma[id], out, [...path, id]);
105
+ return;
106
+ }
107
+ }
108
+ const last = path[path.length - 1];
109
+ out.push({
110
+ area: last === 'source' || last === 'evidence' ? 'citations' : area,
111
+ key,
112
+ op: before === undefined ? 'added' : after === undefined ? 'removed' : 'changed',
113
+ field: path.length === 0 ? undefined : path.join('.'),
114
+ before,
115
+ after,
116
+ say: null,
117
+ });
118
+ }
119
+ // the plain old → new comparison of named fields of one item; `say` words the change for people, or gives null
120
+ export function fieldChanges(area, key, before, after, fields, say) {
121
+ const out = [];
122
+ for (const f of fields) {
123
+ if (same(before[f], after[f]))
124
+ continue;
125
+ const citation = f === 'source' || f === 'evidence';
126
+ out.push({ area: citation ? 'citations' : area, key, op: 'changed', field: f, before: before[f], after: after[f], say: citation ? null : say(f, before[f], after[f]) });
127
+ }
128
+ return out;
129
+ }
130
+ // the keys of an item that are not in `known`, compared as data
131
+ export function otherKeys(key, before, after, known, out) {
132
+ for (const k of union(before, after)) {
133
+ if (known.includes(k))
134
+ continue;
135
+ deepDiff('other', key, before[k], after[k], out, [k]);
136
+ }
137
+ }
138
+ // added and removed items of two lists of plain words
139
+ export function listChanges(before, after) {
140
+ const b = before ?? [];
141
+ const a = after ?? [];
142
+ return { added: a.filter((x) => !b.includes(x)), removed: b.filter((x) => !a.includes(x)) };
143
+ }