@delurio/cli 0.1.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/README.md +20 -7
- package/dist/check.js +6 -2
- package/dist/cli.js +66 -17
- package/dist/index-page.js +50 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,10 +6,17 @@ This package is the part a project's own CI runs: it reads graph files and nothi
|
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Run it once, without installing:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npx @delurio/cli@0.1.1 check graphs --strict
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
or pin an exact version in a project, after which the command is `delurio`:
|
|
10
16
|
|
|
11
17
|
```sh
|
|
12
18
|
npm install --save-dev --save-exact @delurio/cli
|
|
19
|
+
npx delurio check graphs --strict
|
|
13
20
|
```
|
|
14
21
|
|
|
15
22
|
or the container image, pinned by its digest:
|
|
@@ -23,15 +30,15 @@ Node 22.18 or later.
|
|
|
23
30
|
## Check every graph
|
|
24
31
|
|
|
25
32
|
```sh
|
|
26
|
-
npx delurio check graphs --strict
|
|
33
|
+
npx @delurio/cli check graphs --strict
|
|
27
34
|
```
|
|
28
35
|
|
|
29
36
|
- **Findings:** one line each, in the GNU error format that compilers, editors and CI logs read:
|
|
30
37
|
|
|
31
38
|
```
|
|
32
|
-
graphs/billing/payment/flow.yaml:176:7: error: restricted-only-when-retries-ran-out is broken: … [assertion-violated]
|
|
33
|
-
graphs/billing/payment/flow.yaml:176:7: note: start: role = manager, paid = false
|
|
34
|
-
graphs/billing/payment/flow.yaml:176:7: note: step 1: RETRIES_RAN_OUT
|
|
39
|
+
graphs/billing/billing.payment/flow.yaml:176:7: error: restricted-only-when-retries-ran-out is broken: … [assertion-violated]
|
|
40
|
+
graphs/billing/billing.payment/flow.yaml:176:7: note: start: role = manager, paid = false
|
|
41
|
+
graphs/billing/billing.payment/flow.yaml:176:7: note: step 1: RETRIES_RAN_OUT
|
|
35
42
|
```
|
|
36
43
|
|
|
37
44
|
- **SARIF:** `--format sarif --output delurio.sarif` writes SARIF 2.1.0 for code scanning. The same input gives the same bytes.
|
|
@@ -46,15 +53,21 @@ npx delurio check graphs --strict
|
|
|
46
53
|
## A graph's page
|
|
47
54
|
|
|
48
55
|
```sh
|
|
49
|
-
npx delurio page
|
|
56
|
+
npx @delurio/cli page examples/qauth-authorize --out viz/qauth-authorize
|
|
50
57
|
```
|
|
51
58
|
|
|
52
59
|
The page shows the flow in words: its main story, every way it ends, its guarantees and whether they hold, its screens as wireframes, and its diagram. `examples/qauth-authorize` is a small graph to try it on.
|
|
53
60
|
|
|
61
|
+
Given a folder of graphs instead, it writes the page of every graph under it, each at its own path, and `index.html` listing them all with what their check found:
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
npx @delurio/cli page graphs --out viz
|
|
65
|
+
```
|
|
66
|
+
|
|
54
67
|
## What a revision changes, in words
|
|
55
68
|
|
|
56
69
|
```sh
|
|
57
|
-
npx delurio diff
|
|
70
|
+
npx @delurio/cli diff examples/qauth-authorize --base origin/main
|
|
58
71
|
```
|
|
59
72
|
|
|
60
73
|
This prints Markdown for a pull request. It covers what the flow does differently (found by running both revisions) and what changed in the graph.
|
package/dist/check.js
CHANGED
|
@@ -39,6 +39,7 @@ const RULES = [
|
|
|
39
39
|
{ id: 'nav-note', text: 'A note on the navigation graph', level: 'warning' },
|
|
40
40
|
];
|
|
41
41
|
const LEVEL = new Map(RULES.map((r) => [r.id, r.level]));
|
|
42
|
+
export const count = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
42
43
|
// the 1-based line and column of the first occurrence of `needle`, else the file's start
|
|
43
44
|
function placeOf(abs, needle) {
|
|
44
45
|
if (needle === null || !existsSync(abs))
|
|
@@ -51,7 +52,8 @@ function placeOf(abs, needle) {
|
|
|
51
52
|
}
|
|
52
53
|
return { line: 1, column: 1 };
|
|
53
54
|
}
|
|
54
|
-
|
|
55
|
+
// every graph folder at or under root, in path order
|
|
56
|
+
export function graphDirs(root, out = []) {
|
|
55
57
|
if (existsSync(join(root, 'flow.yaml')) || existsSync(join(root, 'nav.yaml')))
|
|
56
58
|
out.push(root);
|
|
57
59
|
for (const name of readdirSync(root).sort()) {
|
|
@@ -260,7 +262,9 @@ export function checkMain(args) {
|
|
|
260
262
|
console.log(`${n.file}:${n.line}:${n.column}: note: ${n.message}`);
|
|
261
263
|
}
|
|
262
264
|
const graphs = graphDirs(root).length;
|
|
263
|
-
|
|
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}`);
|
|
264
268
|
}
|
|
265
269
|
return exitCode;
|
|
266
270
|
}
|
package/dist/cli.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// delurio: the open checker and page of delurio-graph/0 (SPEC.md). Reads graph files and nothing else: no network, no
|
|
3
3
|
// account, no model.
|
|
4
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';
|
|
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
8
|
import { checkAssertions, explore, finalPaths, Model, ModelError } from './model.js';
|
|
9
9
|
import { renderMermaid } from './mermaid.js';
|
|
10
10
|
import { renderSpine } from './spine.js';
|
|
@@ -17,20 +17,21 @@ import { checkGoals, findings, loadNav, menuRows, viewOf } from './nav.js';
|
|
|
17
17
|
import { renderNavMermaid, renderNavPage } from './nav-page.js';
|
|
18
18
|
import { DIFF_USAGE, diffMain } from './diff.js';
|
|
19
19
|
import { KitError, resolveKit } from './kit.js';
|
|
20
|
-
import { CHECK_USAGE, checkMain } from './check.js';
|
|
20
|
+
import { CHECK_USAGE, checkMain, count, graphDirs } from './check.js';
|
|
21
|
+
import { renderIndexPage } from './index-page.js';
|
|
21
22
|
const USAGE = [
|
|
22
23
|
'usage:',
|
|
23
24
|
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)',
|
|
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)',
|
|
26
28
|
DIFF_USAGE.replaceAll('node src/cli.ts', 'delurio'),
|
|
27
29
|
].join('\n').replaceAll('node src/cli.ts', 'delurio');
|
|
28
30
|
const flag = (args, name) => {
|
|
29
31
|
const i = args.indexOf(name);
|
|
30
32
|
return i >= 0 ? args[i + 1] : undefined;
|
|
31
33
|
};
|
|
32
|
-
function navPage(dir, args) {
|
|
33
|
-
const out = resolve(flag(args, '--out') ?? join(dir, 'viz'));
|
|
34
|
+
function navPage(dir, out, args) {
|
|
34
35
|
const { g, warnings } = loadNav(dir, flag(args, '--locale'));
|
|
35
36
|
const views = g.personas.map((p) => viewOf(g, p));
|
|
36
37
|
const rows = menuRows(g, views);
|
|
@@ -40,14 +41,15 @@ function navPage(dir, args) {
|
|
|
40
41
|
writeFileSync(join(out, 'index.html'), renderNavPage({ g, views, rows, goals, found, repo: null, warnings }));
|
|
41
42
|
writeFileSync(join(out, 'nav.mmd'), renderNavMermaid(g));
|
|
42
43
|
const problems = found.filter((f) => f.level === 'problem');
|
|
43
|
-
console.log(`${g.id}: ${Object.keys(g.pages).length}
|
|
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')}`);
|
|
44
45
|
for (const w of warnings)
|
|
45
46
|
console.error(`warning: ${w}`);
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
};
|
|
48
51
|
}
|
|
49
|
-
async function flowPage(dir, args) {
|
|
50
|
-
const out = resolve(flag(args, '--out') ?? join(dir, 'viz'));
|
|
52
|
+
async function flowPage(dir, out, args) {
|
|
51
53
|
const warnings = [];
|
|
52
54
|
const warn = (m) => {
|
|
53
55
|
if (!warnings.includes(m))
|
|
@@ -78,13 +80,51 @@ async function flowPage(dir, args) {
|
|
|
78
80
|
writeFileSync(join(out, 'index.html'), renderPage({ g, m: model, ex, assertions, finals, main, spineMain, spineFull, screens, kit, handOvers }));
|
|
79
81
|
writeFileSync(join(out, 'flow.mmd'), renderMermaid(g, ex));
|
|
80
82
|
const violated = assertions.filter((a) => a.violation !== undefined);
|
|
81
|
-
console.log(`${g.flow.id}: ${Object.keys(g.flow.states).length}
|
|
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`);
|
|
82
84
|
for (const a of violated)
|
|
83
85
|
console.log(` violated: ${a.id}`);
|
|
84
86
|
for (const w of warnings)
|
|
85
87
|
console.error(`warning: ${w}`);
|
|
86
|
-
|
|
87
|
-
|
|
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;
|
|
88
128
|
}
|
|
89
129
|
async function main() {
|
|
90
130
|
const args = process.argv.slice(2);
|
|
@@ -100,7 +140,16 @@ async function main() {
|
|
|
100
140
|
console.error(USAGE);
|
|
101
141
|
return 1;
|
|
102
142
|
}
|
|
103
|
-
|
|
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;
|
|
104
153
|
}
|
|
105
154
|
console.error(USAGE);
|
|
106
155
|
return args.length === 0 || args.includes('--help') ? 0 : 1;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The index of `page` on a folder of graphs: one row per graph, grouped by the folder it sits in, with what its check
|
|
2
|
+
// found and a link to its page.
|
|
3
|
+
import { CSS } from './html.js';
|
|
4
|
+
import { count } from './check.js';
|
|
5
|
+
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
6
|
+
const INDEX_CSS = `
|
|
7
|
+
.groups h2 { font-size: 18px; margin: 32px 0 10px; } .groups table { table-layout: fixed; } .groups col.check { width: 9em; }
|
|
8
|
+
.groups a { color: var(--accent); } .groups td { overflow-wrap: anywhere; }
|
|
9
|
+
.meta { margin-top: 2px; } .meta .tag { margin-left: 6px; } .warn { margin-top: 6px; color: var(--muted); font-size: 14px; } .warn ul { margin: 4px 0 0; padding-left: 18px; }
|
|
10
|
+
`;
|
|
11
|
+
function stateCell(e) {
|
|
12
|
+
if (e.error !== null)
|
|
13
|
+
return `<span class="bad">cannot be loaded</span>`;
|
|
14
|
+
if (e.kind === 'nav')
|
|
15
|
+
return `<span class="${e.total === 0 ? 'ok' : 'bad'}">${esc(count(e.total, 'problem'))}</span>`;
|
|
16
|
+
if (e.total === 0)
|
|
17
|
+
return '<span class="muted">no guarantees</span>';
|
|
18
|
+
return e.holds === e.total ? `<span class="ok">${e.holds}/${e.total} hold</span>` : `<span class="bad">${e.total - e.holds} of ${e.total} broken</span>`;
|
|
19
|
+
}
|
|
20
|
+
// root: the indexed folder's name, which also heads the graphs that sit right in it
|
|
21
|
+
export function renderIndexPage(root, entries) {
|
|
22
|
+
const out = [];
|
|
23
|
+
const failing = entries.filter((e) => e.error !== null || (e.kind === 'flow' ? e.holds < e.total : e.total > 0));
|
|
24
|
+
const flows = entries.filter((e) => e.kind === 'flow').length;
|
|
25
|
+
const navs = entries.length - flows;
|
|
26
|
+
out.push('<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">');
|
|
27
|
+
out.push(`<title>All graphs</title><style>${CSS}${INDEX_CSS}</style></head><body><main>`);
|
|
28
|
+
out.push(`<p class="eyebrow"><span class="mono">${esc(root)}/</span> · delurio-graph/0</p><h1>All graphs</h1>`);
|
|
29
|
+
const kinds = [flows > 0 ? count(flows, 'flow') : '', navs > 0 ? count(navs, 'navigation graph') : ''].filter((s) => s !== '').join(' and ');
|
|
30
|
+
out.push(`<p class="lede">${esc(kinds)}. ${failing.length === 0 ? 'Every guarantee holds and nothing blocks anyone.' : `${esc(count(failing.length, 'graph'))} need${failing.length === 1 ? 's' : ''} a look: marked in red below.`}</p>`);
|
|
31
|
+
const groups = new Map();
|
|
32
|
+
for (const e of entries) {
|
|
33
|
+
const slash = e.path.lastIndexOf('/');
|
|
34
|
+
const folder = slash < 0 ? root : e.path.slice(0, slash);
|
|
35
|
+
groups.set(folder, [...(groups.get(folder) ?? []), e]);
|
|
36
|
+
}
|
|
37
|
+
out.push('<div class="groups">');
|
|
38
|
+
for (const [folder, rows] of groups) {
|
|
39
|
+
out.push(`<h2>${esc(folder)}</h2><div class="scroll"><table><colgroup><col><col class="check"></colgroup><thead><tr><th>Graph</th><th>Check</th></tr></thead><tbody>`);
|
|
40
|
+
for (const e of rows) {
|
|
41
|
+
const name = e.error !== null ? esc(e.title) : `<a href="${esc(e.path)}/index.html">${esc(e.title)}</a>`;
|
|
42
|
+
const meta = e.id === null ? '' : `<div class="meta"><span class="mono muted">${esc(e.id)}</span>${e.status === null ? '' : `<span class="tag">${esc(e.status)}</span>`}</div>`;
|
|
43
|
+
const warn = e.error !== null ? `<div class="warn">${esc(e.error)}</div>` : e.warnings.length === 0 ? '' : `<div class="warn">${esc(count(e.warnings.length, 'warning'))}<ul>${e.warnings.map((w) => `<li>${esc(w)}</li>`).join('')}</ul></div>`;
|
|
44
|
+
out.push(`<tr><td>${name}${meta}${warn}</td><td class="state">${stateCell(e)}</td></tr>`);
|
|
45
|
+
}
|
|
46
|
+
out.push('</tbody></table></div>');
|
|
47
|
+
}
|
|
48
|
+
out.push('</div></main></body></html>');
|
|
49
|
+
return out.join('\n');
|
|
50
|
+
}
|
package/package.json
CHANGED