@delurio/cli 0.0.0 → 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 +201 -0
- package/NOTICE +5 -0
- package/README.md +75 -3
- package/SPEC.md +363 -0
- package/dist/changes.js +69 -0
- package/dist/check.js +270 -0
- package/dist/cli.js +163 -0
- package/dist/compare.js +143 -0
- package/dist/diff-flow.js +547 -0
- package/dist/diff-nav.js +391 -0
- package/dist/diff.js +316 -0
- package/dist/edges.js +120 -0
- package/dist/expr.js +157 -0
- package/dist/graph.js +67 -0
- package/dist/html.js +284 -0
- package/dist/index-page.js +50 -0
- package/dist/kit.js +710 -0
- package/dist/mermaid.js +28 -0
- package/dist/model.js +178 -0
- package/dist/nav-page.js +271 -0
- package/dist/nav.js +566 -0
- package/dist/spine.js +149 -0
- package/dist/starts.js +81 -0
- package/dist/story.js +109 -0
- package/dist/wireframe.js +252 -0
- package/package.json +22 -3
package/dist/check.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
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
|
+
export const count = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
43
|
+
// the 1-based line and column of the first occurrence of `needle`, else the file's start
|
|
44
|
+
function placeOf(abs, needle) {
|
|
45
|
+
if (needle === null || !existsSync(abs))
|
|
46
|
+
return { line: 1, column: 1 };
|
|
47
|
+
const lines = readFileSync(abs, 'utf8').split('\n');
|
|
48
|
+
for (let i = 0; i < lines.length; i++) {
|
|
49
|
+
const c = lines[i].indexOf(needle);
|
|
50
|
+
if (c >= 0)
|
|
51
|
+
return { line: i + 1, column: c + 1 };
|
|
52
|
+
}
|
|
53
|
+
return { line: 1, column: 1 };
|
|
54
|
+
}
|
|
55
|
+
// every graph folder at or under root, in path order
|
|
56
|
+
export function graphDirs(root, out = []) {
|
|
57
|
+
if (existsSync(join(root, 'flow.yaml')) || existsSync(join(root, 'nav.yaml')))
|
|
58
|
+
out.push(root);
|
|
59
|
+
for (const name of readdirSync(root).sort()) {
|
|
60
|
+
if (SKIP.has(name) || name.startsWith('.'))
|
|
61
|
+
continue;
|
|
62
|
+
const sub = join(root, name);
|
|
63
|
+
if (statSync(sub).isDirectory())
|
|
64
|
+
graphDirs(sub, out);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
// the format the file says it is written in: null when it is this one
|
|
69
|
+
function newerFormat(abs) {
|
|
70
|
+
const doc = parse(readFileSync(abs, 'utf8'));
|
|
71
|
+
const s = typeof doc?.schema === 'string' ? doc.schema : FORMAT;
|
|
72
|
+
return s === FORMAT ? null : s;
|
|
73
|
+
}
|
|
74
|
+
function rel(abs) {
|
|
75
|
+
return relative(process.cwd(), abs) || '.';
|
|
76
|
+
}
|
|
77
|
+
function result(rule, file, at, message, graph, element, notes = []) {
|
|
78
|
+
return { rule, level: LEVEL.get(rule) ?? 'error', file: rel(file), line: at.line, column: at.column, message, graph, element, notes };
|
|
79
|
+
}
|
|
80
|
+
// a warning raised while checking a flow, sorted into its rule and placed in its file
|
|
81
|
+
function fromWarning(dir, graph, w) {
|
|
82
|
+
const flow = join(dir, 'flow.yaml');
|
|
83
|
+
const ui = join(dir, 'ui.yaml');
|
|
84
|
+
let m;
|
|
85
|
+
if ((m = /^catalog key (\S+) is missing/.exec(w)))
|
|
86
|
+
return result('catalog-key-missing', ui, placeOf(ui, m[1]), w, graph, m[1]);
|
|
87
|
+
if ((m = /^no sample value for \{(\w+)\} in (\S+)/.exec(w)))
|
|
88
|
+
return result('sample-missing', ui, placeOf(ui, m[2]), w, graph, `${m[2]}/${m[1]}`);
|
|
89
|
+
if ((m = /^(\S+): ListItem outside a List/.exec(w)))
|
|
90
|
+
return result('list-item-outside-list', ui, placeOf(ui, `${m[1]}:`), w, graph, m[1]);
|
|
91
|
+
if ((m = /\(node (\S+?)\)/.exec(w)) && /^kit /.test(w))
|
|
92
|
+
return result('kit-uncovered', ui, placeOf(ui, `${m[1]}:`), w, graph, m[1]);
|
|
93
|
+
if ((m = /^(\S+): component .* has no wireframe renderer/.exec(w)))
|
|
94
|
+
return result('no-renderer', ui, placeOf(ui, `${m[1]}:`), w, graph, m[1]);
|
|
95
|
+
if (/declared main story cannot be walked/.test(w))
|
|
96
|
+
return result('main-story-unwalkable', flow, placeOf(flow, 'main:'), w, graph, 'main');
|
|
97
|
+
if ((m = /^(\S+) starts (\S+): (.*)$/.exec(w)))
|
|
98
|
+
return result('hand-over', flow, placeOf(flow, `flow: ${m[2]}`), w, graph, `${m[1]}->${m[2]}`);
|
|
99
|
+
if (/has no changes note/.test(w))
|
|
100
|
+
return result('changes-note-missing', ui, placeOf(ui, 'samples:'), w, graph, 'changes');
|
|
101
|
+
if ((m = /^the changes note (\S+) is not in the (\w+) catalog/.exec(w)))
|
|
102
|
+
return result('changes-note-locale', ui, placeOf(ui, 'changes:'), w, graph, `changes/${m[2]}`);
|
|
103
|
+
return result('graph-warning', existsSync(ui) ? ui : flow, { line: 1, column: 1 }, w, graph, w);
|
|
104
|
+
}
|
|
105
|
+
function checkFlow(dir) {
|
|
106
|
+
const file = join(dir, 'flow.yaml');
|
|
107
|
+
const g = load(dir);
|
|
108
|
+
const kit = resolveKit(dir, g.ui.kit, undefined);
|
|
109
|
+
const model = new Model(g.flow);
|
|
110
|
+
const ex = explore(model);
|
|
111
|
+
const out = [];
|
|
112
|
+
const warnings = [];
|
|
113
|
+
const warn = (m) => {
|
|
114
|
+
if (!warnings.includes(m))
|
|
115
|
+
warnings.push(m);
|
|
116
|
+
};
|
|
117
|
+
for (const a of checkAssertions(model, ex)) {
|
|
118
|
+
if (a.violation === undefined)
|
|
119
|
+
continue;
|
|
120
|
+
const at = placeOf(file, `id: ${a.id}`);
|
|
121
|
+
const start = Object.entries(a.violation.start).map(([k, v]) => `${k} = ${String(v)}`).join(', ');
|
|
122
|
+
const notes = [{ file: rel(file), ...at, message: `start: ${start || 'the initial context'}` }];
|
|
123
|
+
a.violation.events.forEach((e, i) => notes.push({ file: rel(file), ...at, message: `step ${i + 1}: ${e}` }));
|
|
124
|
+
notes.push({ file: rel(file), ...at, message: `reached: ${a.violation.state}` });
|
|
125
|
+
out.push(result('assertion-violated', file, at, `${a.id} is broken: ${a.say ?? a.never}`, g.flow.id, a.id, notes));
|
|
126
|
+
}
|
|
127
|
+
if (g.flow.main !== undefined && stepsByEvents(model, g.flow.main) === null)
|
|
128
|
+
warn('the declared main story cannot be walked: an event is not possible where it is sent');
|
|
129
|
+
renderScreens(g, warn, kit);
|
|
130
|
+
for (const h of checkStarts(g, model, ex))
|
|
131
|
+
for (const p of h.problems)
|
|
132
|
+
warn(`${h.from} starts ${h.flow}: ${p}`);
|
|
133
|
+
for (const p of checkChanges(g)?.problems ?? [])
|
|
134
|
+
warn(p);
|
|
135
|
+
for (const w of warnings)
|
|
136
|
+
out.push(fromWarning(dir, g.flow.id, w));
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
function checkNav(dir) {
|
|
140
|
+
const file = join(dir, 'nav.yaml');
|
|
141
|
+
const { g, warnings } = loadNav(dir, undefined);
|
|
142
|
+
const views = g.personas.map((p) => viewOf(g, p));
|
|
143
|
+
const found = findings(g, views, checkGoals(g, views));
|
|
144
|
+
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 }))));
|
|
145
|
+
for (const w of warnings)
|
|
146
|
+
out.push(result('graph-warning', file, { line: 1, column: 1 }, w, g.id, w));
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
const fingerprint = (r) => createHash('sha256').update(`${r.rule}|${r.graph}|${r.element}`).digest('hex').slice(0, 32);
|
|
150
|
+
function baselinePrints(file) {
|
|
151
|
+
const doc = JSON.parse(readFileSync(file, 'utf8'));
|
|
152
|
+
const out = new Set();
|
|
153
|
+
for (const run of doc.runs ?? [])
|
|
154
|
+
for (const r of run.results ?? [])
|
|
155
|
+
if (r.fingerprints?.['delurio/v1'] !== undefined)
|
|
156
|
+
out.add(r.fingerprints['delurio/v1']);
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
function toolVersion() {
|
|
160
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
161
|
+
return pkg.version ?? '0.0.0';
|
|
162
|
+
}
|
|
163
|
+
function sarif(results, exitCode) {
|
|
164
|
+
const used = RULES.filter((r) => results.some((x) => x.rule === r.id));
|
|
165
|
+
return {
|
|
166
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
167
|
+
version: '2.1.0',
|
|
168
|
+
runs: [
|
|
169
|
+
{
|
|
170
|
+
tool: {
|
|
171
|
+
driver: {
|
|
172
|
+
name: 'delurio',
|
|
173
|
+
version: toolVersion(),
|
|
174
|
+
informationUri: 'https://delur.io',
|
|
175
|
+
rules: used.map((r) => ({ id: r.id, shortDescription: { text: r.text }, defaultConfiguration: { level: r.level } })),
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
automationDetails: { id: 'delurio/check/' },
|
|
179
|
+
invocations: [{ executionSuccessful: exitCode !== 1, exitCode }],
|
|
180
|
+
results: results.map((r) => ({
|
|
181
|
+
ruleId: r.rule,
|
|
182
|
+
ruleIndex: used.findIndex((u) => u.id === r.rule),
|
|
183
|
+
level: r.level,
|
|
184
|
+
message: { text: r.message },
|
|
185
|
+
locations: [
|
|
186
|
+
{
|
|
187
|
+
physicalLocation: { artifactLocation: { uri: r.file.split('\\').join('/') }, region: { startLine: r.line, startColumn: r.column } },
|
|
188
|
+
logicalLocations: [{ fullyQualifiedName: `${r.graph}/${r.element}` }],
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
fingerprints: { 'delurio/v1': fingerprint(r) },
|
|
192
|
+
...(r.notes.length > 0 && r.rule === 'assertion-violated'
|
|
193
|
+
? { codeFlows: [{ threadFlows: [{ locations: r.notes.map((n) => ({ location: { message: { text: n.message } } })) }] }] }
|
|
194
|
+
: {}),
|
|
195
|
+
...(r.baselineState === undefined ? {} : { baselineState: r.baselineState }),
|
|
196
|
+
})),
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
export function checkMain(args) {
|
|
202
|
+
const flag = (name) => {
|
|
203
|
+
const i = args.indexOf(name);
|
|
204
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
205
|
+
};
|
|
206
|
+
const withValue = ['--baseline', '--format', '--output'];
|
|
207
|
+
const dir = args.find((a, i) => !a.startsWith('--') && !withValue.includes(args[i - 1] ?? ''));
|
|
208
|
+
if (dir === undefined) {
|
|
209
|
+
console.error(`usage:\n${CHECK_USAGE}`);
|
|
210
|
+
return 1;
|
|
211
|
+
}
|
|
212
|
+
const root = resolve(dir);
|
|
213
|
+
const strict = args.includes('--strict');
|
|
214
|
+
const format = flag('--format') ?? 'text';
|
|
215
|
+
const output = flag('--output');
|
|
216
|
+
const baseline = flag('--baseline');
|
|
217
|
+
if (format !== 'text' && format !== 'sarif') {
|
|
218
|
+
console.error(`--format is text or sarif, not ${format}`);
|
|
219
|
+
return 1;
|
|
220
|
+
}
|
|
221
|
+
const results = [];
|
|
222
|
+
let broken = false;
|
|
223
|
+
for (const d of graphDirs(root)) {
|
|
224
|
+
const file = existsSync(join(d, 'flow.yaml')) ? join(d, 'flow.yaml') : join(d, 'nav.yaml');
|
|
225
|
+
try {
|
|
226
|
+
const newer = newerFormat(file);
|
|
227
|
+
if (newer !== null) {
|
|
228
|
+
broken = true;
|
|
229
|
+
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'));
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
results.push(...(file.endsWith('flow.yaml') ? checkFlow(d) : checkNav(d)));
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
if (!(err instanceof GraphError || err instanceof ModelError || err instanceof KitError || (err instanceof Error && err.name === 'YAMLParseError')))
|
|
236
|
+
throw err;
|
|
237
|
+
broken = true;
|
|
238
|
+
const linePos = err.linePos?.[0];
|
|
239
|
+
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'));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
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));
|
|
243
|
+
if (baseline !== undefined) {
|
|
244
|
+
const prints = baselinePrints(baseline);
|
|
245
|
+
for (const r of results)
|
|
246
|
+
r.baselineState = prints.has(fingerprint(r)) ? 'unchanged' : 'new';
|
|
247
|
+
}
|
|
248
|
+
const failing = results.filter((r) => r.level === 'error' && r.rule !== 'load' && r.rule !== 'format-newer' && (r.baselineState === undefined || r.baselineState === 'new'));
|
|
249
|
+
const exitCode = broken ? 1 : strict && failing.length > 0 ? 2 : 0;
|
|
250
|
+
if (format === 'sarif') {
|
|
251
|
+
const text = `${JSON.stringify(sarif(results, exitCode), null, 2)}\n`;
|
|
252
|
+
if (output === undefined)
|
|
253
|
+
process.stdout.write(text);
|
|
254
|
+
else
|
|
255
|
+
writeFileSync(output, text);
|
|
256
|
+
}
|
|
257
|
+
if (format === 'text' || output !== undefined) {
|
|
258
|
+
for (const r of results) {
|
|
259
|
+
const level = r.level === 'error' && r.baselineState === 'unchanged' ? 'warning' : r.level;
|
|
260
|
+
console.log(`${r.file}:${r.line}:${r.column}: ${level}: ${r.message} [${r.rule}]`);
|
|
261
|
+
for (const n of r.notes)
|
|
262
|
+
console.log(`${n.file}:${n.line}:${n.column}: note: ${n.message}`);
|
|
263
|
+
}
|
|
264
|
+
const graphs = graphDirs(root).length;
|
|
265
|
+
const errors = results.filter((r) => r.level === 'error').length;
|
|
266
|
+
const warnings = results.filter((r) => r.level === 'warning').length;
|
|
267
|
+
console.error(`delurio check: ${count(graphs, 'graph')}, ${count(errors, 'error')}, ${count(warnings, 'warning')}; exit ${exitCode}`);
|
|
268
|
+
}
|
|
269
|
+
return exitCode;
|
|
270
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
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, statSync } from 'node:fs';
|
|
6
|
+
import { basename, join, relative, resolve, sep } from 'node:path';
|
|
7
|
+
import { GraphError, humanize, 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, count, graphDirs } from './check.js';
|
|
21
|
+
import { renderIndexPage } from './index-page.js';
|
|
22
|
+
const USAGE = [
|
|
23
|
+
'usage:',
|
|
24
|
+
CHECK_USAGE,
|
|
25
|
+
' delurio page <graph dir | graphs folder> [--out <dir>] [--kit <kit>] [--locale <code>] [--strict]',
|
|
26
|
+
' writes the graph\'s page: <out>/index.html and its diagram (default out: <graph dir>/viz); on a folder of graphs,',
|
|
27
|
+
' the page of each at <out>/<its path> and an index of them all at <out>/index.html (default out: <folder>/viz)',
|
|
28
|
+
DIFF_USAGE.replaceAll('node src/cli.ts', 'delurio'),
|
|
29
|
+
].join('\n').replaceAll('node src/cli.ts', 'delurio');
|
|
30
|
+
const flag = (args, name) => {
|
|
31
|
+
const i = args.indexOf(name);
|
|
32
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
33
|
+
};
|
|
34
|
+
function navPage(dir, out, args) {
|
|
35
|
+
const { g, warnings } = loadNav(dir, flag(args, '--locale'));
|
|
36
|
+
const views = g.personas.map((p) => viewOf(g, p));
|
|
37
|
+
const rows = menuRows(g, views);
|
|
38
|
+
const goals = checkGoals(g, views);
|
|
39
|
+
const found = findings(g, views, goals);
|
|
40
|
+
mkdirSync(out, { recursive: true });
|
|
41
|
+
writeFileSync(join(out, 'index.html'), renderNavPage({ g, views, rows, goals, found, repo: null, warnings }));
|
|
42
|
+
writeFileSync(join(out, 'nav.mmd'), renderNavMermaid(g));
|
|
43
|
+
const problems = found.filter((f) => f.level === 'problem');
|
|
44
|
+
console.log(`${g.id}: ${count(Object.keys(g.pages).length, 'page')}, ${count(g.specs.length, 'way')} to move, ${g.personas.length} ${g.personas.length === 1 ? 'kind' : 'kinds'} of user; ${count(problems.length, 'problem')}, ${count(found.length - problems.length, 'note')}`);
|
|
45
|
+
for (const w of warnings)
|
|
46
|
+
console.error(`warning: ${w}`);
|
|
47
|
+
return {
|
|
48
|
+
code: args.includes('--strict') && (warnings.length > 0 || problems.length > 0) ? 2 : 0,
|
|
49
|
+
entry: { path: '', kind: 'nav', id: g.id, title: `${g.title}: where people go`, status: g.status, holds: 0, total: problems.length, warnings, error: null },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
async function flowPage(dir, out, args) {
|
|
53
|
+
const warnings = [];
|
|
54
|
+
const warn = (m) => {
|
|
55
|
+
if (!warnings.includes(m))
|
|
56
|
+
warnings.push(m);
|
|
57
|
+
};
|
|
58
|
+
const g = load(dir);
|
|
59
|
+
const kit = resolveKit(dir, g.ui.kit, flag(args, '--kit'));
|
|
60
|
+
const model = new Model(g.flow);
|
|
61
|
+
const ex = explore(model);
|
|
62
|
+
const assertions = checkAssertions(model, ex);
|
|
63
|
+
const finals = finalPaths(model, ex);
|
|
64
|
+
const mainIndex = mainStoryIndex(g, ex);
|
|
65
|
+
const declared = g.flow.main === undefined ? null : stepsByEvents(model, g.flow.main);
|
|
66
|
+
if (g.flow.main !== undefined && declared === null)
|
|
67
|
+
warn('the declared main story cannot be walked: an event is not possible where it is sent');
|
|
68
|
+
const mainSteps = declared ?? (mainIndex === null ? null : stepsTo(model, ex, mainIndex));
|
|
69
|
+
const main = mainSteps === null ? null : narrate(g, model, mainSteps);
|
|
70
|
+
const spineMain = await renderSpine(g, ex, 'main', mainSteps);
|
|
71
|
+
const spineFull = await renderSpine(g, ex, 'full', mainSteps);
|
|
72
|
+
const screens = renderScreens(g, warn, kit);
|
|
73
|
+
const handOvers = checkStarts(g, model, ex);
|
|
74
|
+
for (const h of handOvers)
|
|
75
|
+
for (const p of h.problems)
|
|
76
|
+
warn(`${h.from} starts ${h.flow}: ${p}`);
|
|
77
|
+
for (const p of checkChanges(g)?.problems ?? [])
|
|
78
|
+
warn(p);
|
|
79
|
+
mkdirSync(out, { recursive: true });
|
|
80
|
+
writeFileSync(join(out, 'index.html'), renderPage({ g, m: model, ex, assertions, finals, main, spineMain, spineFull, screens, kit, handOvers }));
|
|
81
|
+
writeFileSync(join(out, 'flow.mmd'), renderMermaid(g, ex));
|
|
82
|
+
const violated = assertions.filter((a) => a.violation !== undefined);
|
|
83
|
+
console.log(`${g.flow.id}: ${count(Object.keys(g.flow.states).length, 'state')}, ${count(ex.configs.length, 'configuration')} from ${count(ex.starts, 'start')}, ${assertions.length - violated.length}/${assertions.length} assertions hold`);
|
|
84
|
+
for (const a of violated)
|
|
85
|
+
console.log(` violated: ${a.id}`);
|
|
86
|
+
for (const w of warnings)
|
|
87
|
+
console.error(`warning: ${w}`);
|
|
88
|
+
return {
|
|
89
|
+
code: args.includes('--strict') && (violated.length > 0 || warnings.length > 0) ? 2 : 0,
|
|
90
|
+
entry: { path: '', kind: 'flow', id: g.flow.id, title: g.flow.title ?? humanize(g.flow.id), status: g.flow.status ?? null, holds: assertions.length - violated.length, total: assertions.length, warnings, error: null },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const isGraph = (dir) => existsSync(join(dir, 'flow.yaml')) || existsSync(join(dir, 'nav.yaml'));
|
|
94
|
+
// every graph under the folder, each into <out>/<its path>, and the index of them all. A graph that cannot be loaded
|
|
95
|
+
// is listed as such and the rest are still written: exit 1 when one cannot be loaded, else 2 under --strict when one
|
|
96
|
+
// would fail on its own, else 0.
|
|
97
|
+
async function treePages(root, args) {
|
|
98
|
+
const dirs = graphDirs(root);
|
|
99
|
+
if (dirs.length === 0) {
|
|
100
|
+
console.error(`error: ${root} is not a graph and holds none: there is no flow.yaml or nav.yaml in it or under it`);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
const out = resolve(flag(args, '--out') ?? join(root, 'viz'));
|
|
104
|
+
const entries = [];
|
|
105
|
+
let broken = false;
|
|
106
|
+
let failing = false;
|
|
107
|
+
for (const dir of dirs) {
|
|
108
|
+
const path = relative(root, dir).split(sep).join('/');
|
|
109
|
+
const nav = !existsSync(join(dir, 'flow.yaml'));
|
|
110
|
+
try {
|
|
111
|
+
const { code, entry } = nav ? navPage(dir, join(out, path), args) : await flowPage(dir, join(out, path), args);
|
|
112
|
+
entries.push({ ...entry, path });
|
|
113
|
+
failing ||= code !== 0;
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
if (!(err instanceof GraphError || err instanceof ModelError || err instanceof KitError || (err instanceof Error && err.name === 'YAMLParseError')))
|
|
117
|
+
throw err;
|
|
118
|
+
broken = true;
|
|
119
|
+
const message = err.message.split('\n')[0].replace(/:$/, '');
|
|
120
|
+
console.error(`error: ${path}: ${message}`);
|
|
121
|
+
entries.push({ path, kind: nav ? 'nav' : 'flow', id: null, title: path, status: null, holds: 0, total: 0, warnings: [], error: message });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
mkdirSync(out, { recursive: true });
|
|
125
|
+
writeFileSync(join(out, 'index.html'), renderIndexPage(basename(resolve(root)), entries));
|
|
126
|
+
console.log(`wrote ${join(out, 'index.html')} and the pages of ${count(entries.length - entries.filter((e) => e.error !== null).length, 'graph')} under ${out}`);
|
|
127
|
+
return broken ? 1 : failing ? 2 : 0;
|
|
128
|
+
}
|
|
129
|
+
async function main() {
|
|
130
|
+
const args = process.argv.slice(2);
|
|
131
|
+
if (args[0] === 'check')
|
|
132
|
+
return checkMain(args.slice(1));
|
|
133
|
+
if (args[0] === 'diff')
|
|
134
|
+
return diffMain(args.slice(1));
|
|
135
|
+
if (args[0] === 'page') {
|
|
136
|
+
const rest = args.slice(1);
|
|
137
|
+
const withValue = ['--out', '--kit', '--locale'];
|
|
138
|
+
const dir = rest.find((a, i) => !a.startsWith('--') && !withValue.includes(rest[i - 1] ?? ''));
|
|
139
|
+
if (dir === undefined) {
|
|
140
|
+
console.error(USAGE);
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
143
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
|
|
144
|
+
console.error(`error: ${dir} is not a folder`);
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
if (!isGraph(dir))
|
|
148
|
+
return treePages(dir, rest);
|
|
149
|
+
const out = resolve(flag(rest, '--out') ?? join(dir, 'viz'));
|
|
150
|
+
const { code } = existsSync(join(dir, 'flow.yaml')) ? await flowPage(dir, out, rest) : navPage(dir, out, rest);
|
|
151
|
+
console.log(`wrote ${join(out, 'index.html')} and ${join(out, existsSync(join(dir, 'flow.yaml')) ? 'flow.mmd' : 'nav.mmd')}`);
|
|
152
|
+
return code;
|
|
153
|
+
}
|
|
154
|
+
console.error(USAGE);
|
|
155
|
+
return args.length === 0 || args.includes('--help') ? 0 : 1;
|
|
156
|
+
}
|
|
157
|
+
main().then((code) => process.exit(code), (err) => {
|
|
158
|
+
if (err instanceof GraphError || err instanceof ModelError || err instanceof KitError) {
|
|
159
|
+
console.error(`error: ${err.message}`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
throw err;
|
|
163
|
+
});
|
package/dist/compare.js
ADDED
|
@@ -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
|
+
}
|