@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.
package/dist/diff.js ADDED
@@ -0,0 +1,316 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
4
+ import { parse } from 'yaml';
5
+ import { GraphError, humanize, load } from './graph.js';
6
+ import { loadNav } from './nav.js';
7
+ import { joinWords } from './compare.js';
8
+ import { flowBehaviour, flowSameBehaviour, flowStructure, runSide } from './diff-flow.js';
9
+ import { navBehaviour, navSameBehaviour, navStructure, runNav } from './diff-nav.js';
10
+ // `node src/cli.ts diff`: what a revision of a graph changes, for a PR description. Each side is a graph directory on disk
11
+ // or the same directory at a git revision (read with `git show <commit>:<path>`, nothing is checked out). No model, no
12
+ // network: the same two revisions give the same bytes.
13
+ export const DIFF_USAGE = [
14
+ ' node src/cli.ts diff <old dir> <new dir> [--base <rev>] [--head <rev>] [--json] [--fail-on-behaviour] [--fail-on-regression]',
15
+ ' node src/cli.ts diff <graph dir> --base <rev> [--head <rev>] ...',
16
+ ' what the new revision changes, as markdown for a PR description (--json: for machines); --base reads the old side',
17
+ ' from a git revision, --head the new one (default: the directory on disk). Works for flow and navigation graphs.',
18
+ ' --fail-on-behaviour: exit 2 when what the graph does changed; --fail-on-regression: exit 2 when a guarantee newly',
19
+ ' fails, a goal newly fails or a problem is new',
20
+ ].join('\n');
21
+ // a directory inside the repository is named from its root, as a PR reader knows it; one outside as given
22
+ function shown(dir) {
23
+ const top = git(['rev-parse', '--show-toplevel'], process.cwd())?.trim();
24
+ if (top === undefined || top === '')
25
+ return dir;
26
+ const rel = relative(top, resolve(dir)).split(sep).join('/');
27
+ return rel.startsWith('..') || isAbsolute(rel) ? dir : rel === '' ? '.' : rel;
28
+ }
29
+ function dirSource(dir) {
30
+ const texts = new Map();
31
+ if (!existsSync(dir))
32
+ throw new GraphError(`${dir} does not exist`);
33
+ return {
34
+ where: shown(dir),
35
+ rev: null,
36
+ texts,
37
+ exists: (file) => existsSync(join(dir, file)),
38
+ read: (file) => {
39
+ const path = join(dir, file);
40
+ if (!existsSync(path))
41
+ throw new GraphError(`${path} does not exist`);
42
+ const text = readFileSync(path, 'utf8');
43
+ texts.set(posix.normalize(file), text);
44
+ return text;
45
+ },
46
+ };
47
+ }
48
+ function git(args, cwd) {
49
+ try {
50
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1 << 28 });
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ function gitSource(rev, dir) {
57
+ const top = git(['rev-parse', '--show-toplevel'], process.cwd())?.trim();
58
+ if (top === undefined || top === '')
59
+ throw new GraphError(`--base and --head read a git revision, and ${process.cwd()} is not in a git repository`);
60
+ const commit = git(['rev-parse', '--verify', '--quiet', `${rev}^{commit}`], top)?.trim();
61
+ if (commit === undefined || commit === '')
62
+ throw new GraphError(`${rev} is not a revision of ${top}`);
63
+ const rel = relative(top, resolve(dir)).split(sep).join('/');
64
+ if (rel.startsWith('..') || isAbsolute(rel))
65
+ throw new GraphError(`${dir} is outside the repository ${top}`);
66
+ const short = commit.slice(0, 7); // fixed length, so the output does not change as the repository grows
67
+ const path = (file) => {
68
+ const p = posix.normalize(posix.join(rel, file));
69
+ if (p.startsWith('../'))
70
+ throw new GraphError(`${file} is outside the repository`);
71
+ return p;
72
+ };
73
+ const texts = new Map();
74
+ const shownRev = commit.startsWith(rev) ? short : `${rev} (${short})`;
75
+ if (git(['cat-file', '-e', `${commit}:${rel === '' ? '.' : rel}`], top) === null)
76
+ throw new GraphError(`${rel} does not exist at ${shownRev}; a new graph has no diff, render it instead`);
77
+ return {
78
+ where: rel === '' ? '.' : rel,
79
+ rev: shownRev,
80
+ texts,
81
+ exists: (file) => git(['cat-file', '-e', `${commit}:${path(file)}`], top) !== null,
82
+ read: (file) => {
83
+ const text = git(['show', `${commit}:${path(file)}`], top);
84
+ if (text === null)
85
+ throw new GraphError(`${path(file)} does not exist at ${short}`);
86
+ texts.set(posix.normalize(file), text);
87
+ return text;
88
+ },
89
+ };
90
+ }
91
+ // The API description of a flow: domain.yaml beside it, else the shared ../domain/domain.yaml of a capture layout,
92
+ // else ../../domain/domain.yaml of graphs/ (graphs/<part>/<flow>). When the flow names its domain, the file must be that domain.
93
+ function findDomain(src, g) {
94
+ for (const file of ['domain.yaml', '../domain/domain.yaml', '../../domain/domain.yaml']) {
95
+ if (!src.exists(file))
96
+ continue;
97
+ const doc = parse(src.read(file));
98
+ const named = g.flow.domain;
99
+ if (typeof named === 'string' && doc?.id !== named)
100
+ throw new GraphError(`flow.yaml names the domain ${named}, but ${file} is ${String(doc?.id)}`);
101
+ return { file, doc };
102
+ }
103
+ return null;
104
+ }
105
+ function loadSide(src, name, locale) {
106
+ try {
107
+ if (src.exists('flow.yaml')) {
108
+ const g = load(src.where, src.read, src.exists);
109
+ return { kind: 'flow', side: { g, domain: findDomain(src, g) } };
110
+ }
111
+ if (src.exists('nav.yaml'))
112
+ return { kind: 'nav', side: loadNav(src.where, locale, src.read) };
113
+ }
114
+ catch (err) {
115
+ throw new GraphError(`${name} (${label(src)}): ${err.message}`);
116
+ }
117
+ throw new GraphError(`${name} (${label(src)}) has neither flow.yaml nor nav.yaml`);
118
+ }
119
+ const label = (s) => (s.rev === null ? s.where : `${s.where} at ${s.rev}`);
120
+ const HEADINGS = {
121
+ flow: 'The flow',
122
+ graph: 'The graph',
123
+ scope: 'What this view shows',
124
+ main: 'The main story',
125
+ steps: 'Steps',
126
+ ways: 'How it moves on',
127
+ guarantees: 'Guarantees',
128
+ situations: 'What it keeps track of',
129
+ conditions: 'Conditions and effects',
130
+ screens: 'What the user sees',
131
+ people: 'Kinds of user',
132
+ pages: 'Pages',
133
+ entries: 'Menus, links and buttons',
134
+ goals: 'Goals',
135
+ frames: 'Frames',
136
+ facts: 'Facts and conditions',
137
+ notModeled: 'Not modeled',
138
+ wording: 'Wording',
139
+ domain: 'The API it calls (domain.yaml)',
140
+ other: 'Other keys',
141
+ citations: 'Source citations',
142
+ };
143
+ // markdown: escape what would turn plain words into markup
144
+ const md = (s) => s.replace(/([\\`*_[\]<])/g, '\\$1');
145
+ function tick(s) {
146
+ const longest = Math.max(0, ...(s.match(/`+/g) ?? []).map((r) => r.length));
147
+ const fence = '`'.repeat(longest + 1);
148
+ const pad = s.startsWith('`') || s.endsWith('`') ? ' ' : '';
149
+ return `${fence}${pad}${s}${pad}${fence}`;
150
+ }
151
+ const value = (v) => (v === undefined ? '(none)' : tick(JSON.stringify(v)));
152
+ function exactLine(c) {
153
+ const what = c.field === undefined ? c.key : `${c.key} › ${c.field}`;
154
+ const short = (v) => {
155
+ if (v === undefined)
156
+ return '';
157
+ const text = JSON.stringify(v);
158
+ return text.length <= 160 ? `: ${tick(text)}` : '';
159
+ };
160
+ if (c.op === 'added')
161
+ return `- ${tick(what)} added${short(c.after)}`;
162
+ if (c.op === 'removed')
163
+ return `- ${tick(what)} removed${short(c.before)}`;
164
+ if (c.op === 'moved')
165
+ return `- ${tick(what)} moved from ${String(c.before)} to ${String(c.after)}`;
166
+ return `- ${tick(what)}: ${value(c.before)} → ${value(c.after)}`;
167
+ }
168
+ // the line that stands for the changes of an area that have no sentence of their own
169
+ function summaryLine(area, items) {
170
+ const n = items.length;
171
+ const plural = (k, w) => `${k} ${w}${k === 1 ? '' : 's'}`;
172
+ const distinct = (xs) => [...new Set(xs)];
173
+ if (area === 'conditions' || area === 'facts') {
174
+ const guards = items.filter((c) => c.key.startsWith('guard ')).length;
175
+ const other = n - guards;
176
+ const parts = [guards > 0 ? plural(guards, 'named condition') : '', other > 0 ? plural(other, area === 'facts' ? 'fact' : 'effect') : ''].filter((x) => x !== '');
177
+ return `${joinWords(parts)} changed; the exact text is under the exact changes.`;
178
+ }
179
+ if (area === 'domain') {
180
+ const one = { entities: 'entity', actions: 'action', policies: 'policy', invariants: 'invariant', relations: 'relation', helpers: 'helper', permissions: 'permission', jobs: 'job' };
181
+ const where = (c) => {
182
+ const [section, name] = (c.field ?? '').split('.');
183
+ return name === undefined ? section : `${one[section] ?? section} ${name}`;
184
+ };
185
+ return `${plural(n, 'change')} in the API description: ${joinWords(distinct(items.map(where)))}.`;
186
+ }
187
+ if (area === 'ways')
188
+ return `${plural(n, 'way')} on from removed steps, under the exact changes.`;
189
+ if (area === 'citations')
190
+ return `${plural(n, 'source citation')} changed.`;
191
+ if (area === 'other')
192
+ return `${plural(n, 'change')} in keys this tool does not read: ${distinct(items.map((c) => (c.field ?? c.key).split('.')[0])).join(', ')}.`;
193
+ return `${plural(n, 'more change')}, under the exact changes.`;
194
+ }
195
+ export function renderMarkdown(r) {
196
+ const out = [];
197
+ out.push(`## ${md(r.title)}: what this revision changes`, '');
198
+ const kind = r.kind === 'flow' ? 'Flow graph' : 'Navigation graph';
199
+ const at = (s) => (s.rev === null ? 'the directory on disk' : tick(s.rev));
200
+ out.push(r.before.where === r.after.where ? `${kind} ${tick(r.after.where)}: ${at(r.before)} → ${at(r.after)}.` : `${kind}: ${tick(label(r.before))} → ${tick(label(r.after))}.`);
201
+ const files = [...new Set([...r.after.texts.keys(), ...r.before.texts.keys()])];
202
+ const changed = files.filter((f) => r.before.texts.get(f) !== r.after.texts.get(f));
203
+ out.push(`Compared ${files.join(', ')}; ${changed.length === 0 ? 'no file differs' : `changed: ${changed.join(', ')}`}.`, '');
204
+ out.push('### What it does differently', '');
205
+ if (r.behaviour.length === 0)
206
+ out.push(md(r.same));
207
+ else if (r.kind === 'flow')
208
+ for (const s of r.behaviour)
209
+ out.push(...item(s));
210
+ else {
211
+ const people = [...new Set(r.behaviour.filter((s) => s.persona !== undefined).map((s) => s.persona))];
212
+ for (const who of people) {
213
+ const items = r.behaviour.filter((s) => s.persona === who);
214
+ if (items.length > 8)
215
+ out.push(`<details><summary><b>${md(who)}</b>: ${items.length} changes</summary>`, '', ...items.flatMap(item), '', '</details>', '');
216
+ else
217
+ out.push(`**${md(who)}**`, '', ...items.flatMap(item), '');
218
+ }
219
+ const rest = r.behaviour.filter((s) => s.persona === undefined);
220
+ if (rest.length > 0)
221
+ out.push('**Findings**', '', ...rest.flatMap(item));
222
+ }
223
+ out.push('');
224
+ out.push('### What changed in the graph', '');
225
+ if (r.structure.length === 0)
226
+ out.push(changed.length === 0 ? 'Nothing.' : 'Nothing: the files differ only in comments, layout or key order.');
227
+ for (const area of Object.keys(HEADINGS)) {
228
+ const items = r.structure.filter((c) => c.area === area);
229
+ if (items.length === 0)
230
+ continue;
231
+ const said = items.filter((c) => c.say !== null);
232
+ const silent = items.filter((c) => c.say === null);
233
+ out.push(`**${HEADINGS[area]}**`, '');
234
+ for (const c of said)
235
+ out.push(`- ${md(c.say)}`);
236
+ if (silent.length > 0)
237
+ out.push(`- ${md(summaryLine(area, silent))}`);
238
+ out.push('');
239
+ }
240
+ if (r.structure.length > 0) {
241
+ out.push(`<details><summary>Exact changes, by id (${r.structure.length})</summary>`, '');
242
+ for (const c of r.structure)
243
+ out.push(exactLine(c));
244
+ out.push('', '</details>');
245
+ }
246
+ return `${out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
247
+ }
248
+ function item(s) {
249
+ return [`- ${s.worse ? `**${md(s.say)}**` : md(s.say)}`, ...s.detail.map((d) => ` ${md(d)}`)];
250
+ }
251
+ export function renderJson(r) {
252
+ const side = (s) => ({ path: s.where, revision: s.rev, files: [...s.texts.keys()] });
253
+ const files = [...new Set([...r.after.texts.keys(), ...r.before.texts.keys()])];
254
+ return `${JSON.stringify({
255
+ schema: 'delurio-graph-diff/0',
256
+ kind: r.kind,
257
+ title: r.title,
258
+ before: side(r.before),
259
+ after: side(r.after),
260
+ changedFiles: files.filter((f) => r.before.texts.get(f) !== r.after.texts.get(f)),
261
+ behaviourChanged: r.behaviour.length > 0,
262
+ regressions: r.behaviour.filter((s) => s.worse).length,
263
+ behaviour: r.behaviour,
264
+ structure: r.structure,
265
+ }, null, 2)}\n`;
266
+ }
267
+ export function compare(before, after, locale) {
268
+ const b = loadSide(before, 'before', locale);
269
+ const a = loadSide(after, 'after', locale);
270
+ if (b.kind === 'flow' && a.kind === 'flow') {
271
+ const s = flowStructure(b.side, a.side);
272
+ const rb = runSide(b.side.g, `before (${label(before)})`);
273
+ const ra = runSide(a.side.g, `after (${label(after)})`);
274
+ return { kind: 'flow', title: a.side.g.flow.title ?? humanize(a.side.g.flow.id), before, after, structure: s.changes, behaviour: flowBehaviour(rb, ra, s), same: flowSameBehaviour(ra) };
275
+ }
276
+ if (b.kind === 'nav' && a.kind === 'nav') {
277
+ for (const w of b.side.warnings)
278
+ console.error(`warning: before: ${w}`);
279
+ for (const w of a.side.warnings)
280
+ console.error(`warning: after: ${w}`);
281
+ const s = navStructure(b.side, a.side);
282
+ const rb = runNav(b.side.g, `before (${label(before)})`);
283
+ const ra = runNav(a.side.g, `after (${label(after)})`);
284
+ return { kind: 'nav', title: a.side.g.title, before, after, structure: s.changes, behaviour: navBehaviour(rb, ra, s), same: navSameBehaviour(ra) };
285
+ }
286
+ throw new GraphError(`before (${label(before)}) is a ${b.kind} graph and after (${label(after)}) a ${a.kind} graph`);
287
+ }
288
+ export async function diffMain(args) {
289
+ const withValue = ['--base', '--head', '--locale'];
290
+ const dirs = args.filter((x, i) => !x.startsWith('--') && !withValue.includes(args[i - 1] ?? ''));
291
+ const flag = (name) => {
292
+ const i = args.indexOf(name);
293
+ return i >= 0 ? args[i + 1] : undefined;
294
+ };
295
+ const base = flag('--base');
296
+ const head = flag('--head');
297
+ for (const name of withValue)
298
+ if (args.includes(name) && (flag(name) === undefined || flag(name)?.startsWith('--') === true))
299
+ throw new GraphError(`${name} needs a value`);
300
+ if (dirs.length === 0 || dirs.length > 2 || (dirs.length === 1 && base === undefined && head === undefined)) {
301
+ console.error(`usage:\n${DIFF_USAGE}`);
302
+ return 1;
303
+ }
304
+ const [oldDir, newDir] = dirs.length === 2 ? dirs : [dirs[0], dirs[0]];
305
+ const before = base === undefined ? dirSource(oldDir) : gitSource(base, oldDir);
306
+ const after = head === undefined ? dirSource(newDir) : gitSource(head, newDir);
307
+ const report = compare(before, after, flag('--locale'));
308
+ const text = args.includes('--json') ? renderJson(report) : renderMarkdown(report);
309
+ await new Promise((done) => process.stdout.write(text, () => done())); // all of it, before the process exits
310
+ const regressions = report.behaviour.filter((s) => s.worse).length;
311
+ if (args.includes('--fail-on-regression') && regressions > 0)
312
+ return 2;
313
+ if (args.includes('--fail-on-behaviour') && report.behaviour.length > 0)
314
+ return 2;
315
+ return 0;
316
+ }
package/dist/edges.js ADDED
@@ -0,0 +1,120 @@
1
+ import { alternatives, humanize } from './graph.js';
2
+ export const BY = { human: 'person', ai: 'AI', agent: 'agent', ci: 'CI' };
3
+ export function kindOf(g, name) {
4
+ const st = g.flow.states[name];
5
+ if (st.type === 'final')
6
+ return 'exit';
7
+ if (st.invoke?.flow !== undefined)
8
+ return 'subflow';
9
+ const shown = Object.values(g.ui.nodes).some((n) => (n.in ?? []).includes(name) && !(n.unless ?? []).includes(name));
10
+ return shown ? 'screen' : 'server';
11
+ }
12
+ export function exitLine(st) {
13
+ if (st.leavesPage === true)
14
+ return 'user leaves the page';
15
+ if (st.redirect === 'never')
16
+ return 'no redirect, page shown';
17
+ if (st.redirect === 'success')
18
+ return `redirect with ${(st.carries ?? []).join(' + ')}`;
19
+ if (st.redirect === undefined && st.code === undefined)
20
+ return 'ends here';
21
+ const codes = (st.code ?? '').split('|').map((c) => c.trim()).filter((c) => c !== '');
22
+ return codes.length === 1 ? `redirect, error=${codes[0]}` : `redirect, error (${codes.length} codes)`;
23
+ }
24
+ // a step's `response` in one line: the status, the code and the redirect first, then the other fields
25
+ export function responseLine(r) {
26
+ const first = ['status', 'code', 'redirect'].filter((k) => r[k] !== undefined).map((k) => (k === 'redirect' ? `redirect ${String(r[k])}` : String(r[k])));
27
+ const rest = Object.entries(r)
28
+ .filter(([k]) => !['status', 'code', 'redirect'].includes(k))
29
+ .map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`);
30
+ return [...first, ...rest].join(' · ');
31
+ }
32
+ export function nodeLine(g, name) {
33
+ const st = g.flow.states[name];
34
+ const kind = kindOf(g, name);
35
+ if (kind === 'exit')
36
+ return exitLine(st);
37
+ if (kind === 'subflow')
38
+ return `sign-in flow · ${st.invoke?.flow}`;
39
+ const by = st.by === undefined ? null : BY[st.by];
40
+ if (st.goal === true)
41
+ return `${name} · goal`;
42
+ return `${name} · ${by ?? (kind === 'screen' ? 'screen' : 'server step')}`;
43
+ }
44
+ // visible text for a catalog key, with {placeholders} filled from the samples
45
+ export function plain(g, key) {
46
+ const raw = g.catalog[key] ?? key;
47
+ return raw.replace(/\{(\w+)\}/g, (_m, p) => g.ui.samples[p] ?? p);
48
+ }
49
+ // the button or link text a person presses to send `event` while in `state`, if there is one
50
+ export function pressLabel(g, state, event) {
51
+ for (const n of Object.values(g.ui.nodes)) {
52
+ if (n.emits !== event || !(n.in ?? []).includes(state))
53
+ continue;
54
+ if (n.text?.key !== undefined)
55
+ return plain(g, n.text.key);
56
+ }
57
+ return null;
58
+ }
59
+ export function wrap(text, max) {
60
+ const lines = [];
61
+ let line = '';
62
+ for (const word of text.split(' ')) {
63
+ if (line !== '' && line.length + 1 + word.length > max) {
64
+ lines.push(line);
65
+ line = word;
66
+ }
67
+ else {
68
+ line = line === '' ? word : `${line} ${word}`;
69
+ }
70
+ }
71
+ if (line !== '')
72
+ lines.push(line);
73
+ return lines;
74
+ }
75
+ export function collectEdges(g, ex) {
76
+ const groups = new Map();
77
+ const add = (from, to, label, kind, used) => {
78
+ const key = `${from}>${to}`;
79
+ const grp = groups.get(key) ?? { from, to, labels: [], kinds: new Set(), used: false };
80
+ if (label !== null && !grp.labels.includes(label))
81
+ grp.labels.push(label);
82
+ grp.kinds.add(kind);
83
+ grp.used = grp.used || used;
84
+ groups.set(key, grp);
85
+ };
86
+ for (const [name, st] of Object.entries(g.flow.states)) {
87
+ (st.always ?? []).forEach((a, i) => add(name, a.target, a.say ?? a.guard ?? 'otherwise', 'route', ex.taken.has(`${name}|always|${i}`)));
88
+ for (const [event, spec] of Object.entries(st.on ?? {})) {
89
+ alternatives(spec).forEach((a, i) => {
90
+ const press = pressLabel(g, name, event);
91
+ let label;
92
+ let kind;
93
+ if (press !== null) {
94
+ label = `“${press}”`;
95
+ kind = 'user';
96
+ }
97
+ else if (event.startsWith('FAIL')) {
98
+ label = a.say !== undefined ? `fails: ${a.say}` : humanize(event);
99
+ kind = 'fail';
100
+ }
101
+ else {
102
+ label = a.say ?? null;
103
+ kind = 'system';
104
+ }
105
+ add(name, a.target, label, kind, ex.taken.has(`${name}|${event}|${i}`));
106
+ if (a.starts !== undefined)
107
+ add(name, a.target, `starts ${a.starts.flow}`, kind, ex.taken.has(`${name}|${event}|${i}`));
108
+ });
109
+ }
110
+ }
111
+ const order = ['user', 'system', 'route', 'fail'];
112
+ return [...groups.values()].map((grp) => ({
113
+ from: grp.from,
114
+ to: grp.to,
115
+ labels: grp.labels,
116
+ kind: order.find((k) => grp.kinds.has(k)) ?? 'system',
117
+ leave: g.flow.states[grp.to].leavesPage === true,
118
+ unused: !grp.used,
119
+ }));
120
+ }
package/dist/expr.js ADDED
@@ -0,0 +1,157 @@
1
+ const cache = new Map();
2
+ function tokenize(src) {
3
+ const out = [];
4
+ const re = /\s*(?:==|!=|[()[\],]|'[^']*'|"[^"]*"|-?\d+(?:\.\d+)?|[A-Za-z_][A-Za-z0-9_.]*)/y;
5
+ let i = 0;
6
+ while (i < src.length) {
7
+ re.lastIndex = i;
8
+ const m = re.exec(src);
9
+ if (m === null) {
10
+ if (src.slice(i).trim() === '')
11
+ break;
12
+ throw new Error(`cannot read "${src.slice(i)}" in: ${src}`);
13
+ }
14
+ out.push(m[0].trim());
15
+ i = re.lastIndex;
16
+ }
17
+ return out;
18
+ }
19
+ export function parseExpr(src) {
20
+ const hit = cache.get(src);
21
+ if (hit !== undefined)
22
+ return hit;
23
+ const toks = tokenize(src);
24
+ let i = 0;
25
+ const peek = () => toks[i];
26
+ const next = () => toks[i++];
27
+ const expect = (t) => {
28
+ if (next() !== t)
29
+ throw new Error(`expected "${t}" in: ${src}`);
30
+ };
31
+ const atom = () => {
32
+ const t = next();
33
+ if (t === undefined)
34
+ throw new Error(`unexpected end in: ${src}`);
35
+ if (t === '(') {
36
+ const e = orExpr();
37
+ expect(')');
38
+ return e;
39
+ }
40
+ if (t === '[') {
41
+ const items = [];
42
+ while (peek() !== ']') {
43
+ items.push(atom());
44
+ if (peek() === ',')
45
+ next();
46
+ }
47
+ expect(']');
48
+ return { k: 'list', items };
49
+ }
50
+ if (t.startsWith("'") || t.startsWith('"'))
51
+ return { k: 'lit', v: t.slice(1, -1) };
52
+ if (/^-?\d/.test(t))
53
+ return { k: 'lit', v: Number(t) };
54
+ if (t === 'true')
55
+ return { k: 'lit', v: true };
56
+ if (t === 'false')
57
+ return { k: 'lit', v: false };
58
+ if (t === 'null')
59
+ return { k: 'lit', v: null };
60
+ if (/^[A-Za-z_]/.test(t) && !['and', 'or', 'not', 'in'].includes(t))
61
+ return { k: 'id', name: t };
62
+ throw new Error(`unexpected "${t}" in: ${src}`);
63
+ };
64
+ const cmpExpr = () => {
65
+ const l = atom();
66
+ const p = peek();
67
+ if (p === '==' || p === '!=') {
68
+ next();
69
+ return { k: 'cmp', op: p, l, r: atom() };
70
+ }
71
+ if (p === 'in') {
72
+ next();
73
+ return { k: 'cmp', op: 'in', l, r: atom() };
74
+ }
75
+ if (p === 'not' && toks[i + 1] === 'in') {
76
+ i += 2;
77
+ return { k: 'cmp', op: 'not in', l, r: atom() };
78
+ }
79
+ return l;
80
+ };
81
+ const notExpr = () => {
82
+ if (peek() === 'not') {
83
+ next();
84
+ return { k: 'not', e: notExpr() };
85
+ }
86
+ return cmpExpr();
87
+ };
88
+ const andExpr = () => {
89
+ let l = notExpr();
90
+ while (peek() === 'and') {
91
+ next();
92
+ l = { k: 'and', l, r: notExpr() };
93
+ }
94
+ return l;
95
+ };
96
+ const orExpr = () => {
97
+ let l = andExpr();
98
+ while (peek() === 'or') {
99
+ next();
100
+ l = { k: 'or', l, r: andExpr() };
101
+ }
102
+ return l;
103
+ };
104
+ const e = orExpr();
105
+ if (i !== toks.length)
106
+ throw new Error(`unexpected "${toks[i]}" in: ${src}`);
107
+ cache.set(src, e);
108
+ return e;
109
+ }
110
+ export function evaluate(e, lookup) {
111
+ switch (e.k) {
112
+ case 'lit':
113
+ return e.v;
114
+ case 'id':
115
+ return lookup(e.name);
116
+ case 'list':
117
+ return e.items.map((x) => evaluate(x, lookup));
118
+ case 'not':
119
+ return !evaluate(e.e, lookup);
120
+ case 'and':
121
+ return Boolean(evaluate(e.l, lookup)) && Boolean(evaluate(e.r, lookup));
122
+ case 'or':
123
+ return Boolean(evaluate(e.l, lookup)) || Boolean(evaluate(e.r, lookup));
124
+ case 'cmp': {
125
+ const l = evaluate(e.l, lookup);
126
+ const r = evaluate(e.r, lookup);
127
+ if (e.op === '==')
128
+ return l === r;
129
+ if (e.op === '!=')
130
+ return l !== r;
131
+ const inList = Array.isArray(r) && r.some((x) => x === l);
132
+ return e.op === 'in' ? inList : !inList;
133
+ }
134
+ }
135
+ }
136
+ export function identifiers(e, out = new Set()) {
137
+ switch (e.k) {
138
+ case 'id':
139
+ out.add(e.name);
140
+ break;
141
+ case 'list':
142
+ e.items.forEach((x) => identifiers(x, out));
143
+ break;
144
+ case 'not':
145
+ identifiers(e.e, out);
146
+ break;
147
+ case 'and':
148
+ case 'or':
149
+ case 'cmp':
150
+ identifiers(e.l, out);
151
+ identifiers(e.r, out);
152
+ break;
153
+ case 'lit':
154
+ break;
155
+ }
156
+ return out;
157
+ }
package/dist/graph.js ADDED
@@ -0,0 +1,67 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parse } from 'yaml';
4
+ import { identifiers, parseExpr } from './expr.js';
5
+ export class GraphError extends Error {
6
+ }
7
+ // 'silentRedirect' -> 'silent redirect'; used only when the graph gives no title
8
+ export function humanize(id) {
9
+ const words = id.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_.-]+/g, ' ').toLowerCase();
10
+ return words.charAt(0).toUpperCase() + words.slice(1);
11
+ }
12
+ export function titleOf(flow, state) {
13
+ return flow.states[state].title ?? humanize(state);
14
+ }
15
+ export function alternatives(spec) {
16
+ const list = Array.isArray(spec) ? spec : [spec];
17
+ return list.map((a) => (typeof a === 'string' ? { target: a } : a));
18
+ }
19
+ // `read` and `exists` take a path relative to `dir`; the revision diff passes ones that read a git revision instead of the disk
20
+ export function load(dir, read = (file) => readFileSync(join(dir, file), 'utf8'), exists = (file) => existsSync(join(dir, file))) {
21
+ const flow = parse(read('flow.yaml'));
22
+ // a flow with no context, guards or effects leaves the key out; every reader expects an object
23
+ flow.context ??= {};
24
+ flow.guards ??= {};
25
+ flow.effects ??= {};
26
+ // a flow with no screens (an endpoint, a webhook, a CI job) has no ui.yaml and no catalog
27
+ const ui = exists('ui.yaml') ? parse(read('ui.yaml')) : { absent: true, id: flow.id, catalog: '', samples: {}, nodes: {} };
28
+ const catalog = ui.absent === true ? {} : JSON.parse(read(ui.catalog));
29
+ if (flow.states[flow.initial] === undefined)
30
+ throw new GraphError(`initial state ${flow.initial} does not exist`);
31
+ for (const [name, st] of Object.entries(flow.states)) {
32
+ if (st.states !== undefined || st.type === 'parallel' || st.type === 'history') {
33
+ throw new GraphError(`state ${name}: nested, parallel and history states are not supported by graph-viz yet`);
34
+ }
35
+ const targets = [
36
+ ...(st.always ?? []).map((a) => a.target),
37
+ ...Object.values(st.on ?? {}).flatMap((spec) => alternatives(spec).map((a) => a.target)),
38
+ ];
39
+ for (const t of targets) {
40
+ if (flow.states[t] === undefined)
41
+ throw new GraphError(`state ${name} goes to ${t}, which does not exist`);
42
+ }
43
+ const ways = [...(st.always ?? []), ...Object.values(st.on ?? {}).flatMap((spec) => alternatives(spec))];
44
+ for (const w of ways) {
45
+ if (w.starts === undefined)
46
+ continue;
47
+ if (typeof w.starts.flow !== 'string')
48
+ throw new GraphError(`state ${name}: \`starts\` names no flow`);
49
+ for (const [k, v] of Object.entries(w.starts.given ?? {})) {
50
+ if (typeof v !== 'string')
51
+ continue;
52
+ for (const id of identifiers(parseExpr(v))) {
53
+ if (id !== 'state' && flow.context[id] === undefined && flow.guards[id] === undefined) {
54
+ throw new GraphError(`state ${name} starts ${w.starts.flow} with ${k} = ${v}: ${id} is not a variable or a guard of this flow (a value is quoted: '${id}')`);
55
+ }
56
+ }
57
+ }
58
+ }
59
+ }
60
+ for (const [id, node] of Object.entries(ui.nodes)) {
61
+ for (const s of [...(node.in ?? []), ...(node.unless ?? []), ...(node.disabledIn ?? [])]) {
62
+ if (flow.states[s] === undefined)
63
+ throw new GraphError(`ui node ${id} names the unknown state ${s}`);
64
+ }
65
+ }
66
+ return { dir, flow, ui, catalog };
67
+ }