@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 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
- Pin an exact version:
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 graphs/billing/payment --out viz/payment
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 graphs/billing/payment --base origin/main
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
- function graphDirs(root, out = []) {
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
- console.error(`delurio check: ${graphs} graphs, ${results.filter((r) => r.level === 'error').length} errors, ${results.filter((r) => r.level === 'warning').length} warnings; exit ${exitCode}`);
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} pages, ${g.specs.length} ways to move, ${g.personas.length} kinds of user; ${problems.length} problems, ${found.length - problems.length} notes`);
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
- 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;
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} states, ${ex.configs.length} configurations from ${ex.starts} starts, ${assertions.length - violated.length}/${assertions.length} assertions hold`);
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
- 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
+ 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
- return existsSync(join(dir, 'flow.yaml')) ? flowPage(dir, rest) : navPage(dir, rest);
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delurio/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "The open checker and page of delurio-graph/0: check every graph in a project's own CI, offline, with GNU error lines and SARIF.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",