@delurio/cli 0.1.0 → 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ While the version is 0.x, a release that can judge the same graphs differently (a new check, a stricter schema) bumps the minor; a fix that judges nothing differently bumps the patch.
4
+
5
+ ## 0.2.0 (2026-10-25)
6
+
7
+ - JSON Schemas (2020-12) for `flow`, `ui`, `nav` (and `nav.extracted.yaml`), `domain` and `catalog` files, shipped in `schemas/`, for editors: completion, hover help from SPEC.md, and errors such as a typo in a key or a title cut short by a comma inside `{ }`.
8
+ - `page --watch` writes the pages again on every change under the folder; `page --serve [--port <n>]` also serves them on 127.0.0.1 and reloads the open page after each write, or says why a graph could not be loaded.
9
+ - `check` validates every graph file against its kind's schema: each place a file does not follow it is an error of the new rule `schema`, at its line and column, which fails `--strict` (exit 2) and works with `--baseline`.
10
+
11
+ ## 0.1.1 (2026-10-25)
12
+
13
+ - `page` on a folder writes the page of every graph under it and `index.html` listing them, in one process.
14
+ - A folder that holds no graph gets one line instead of a stack trace; counts read in the singular when there is one.
15
+ - The README runs every command as `npx @delurio/cli`.
16
+
17
+ ## 0.1.0 (2026-10-25)
18
+
19
+ - `check`: every graph under a folder, as GNU error lines or SARIF 2.1.0, with `--baseline`; exit 0, 1 or 2.
20
+ - `page`: a graph's page in words, with its guarantees, screens and diagram.
21
+ - `diff`: what a revision changes in a graph, in words, for a pull request.
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,49 @@ 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
+
67
+ ## Write a graph in an editor
68
+
69
+ The package ships a JSON Schema (2020-12) for each kind of graph file in `schemas/`: `flow`, `ui`, `nav` (also `nav.extracted.yaml`), `domain` and `catalog`. With them an editor completes keys, explains each one from SPEC.md on hover, and marks a typo, a value of the wrong kind, or a title cut short by a comma inside `{ }`. In VS Code, with the YAML extension, put this in `.vscode/settings.json`:
70
+
71
+ ```json
72
+ {
73
+ "yaml.schemas": {
74
+ "https://unpkg.com/@delurio/cli@0.2.0/schemas/flow.schema.json": "**/flow.yaml",
75
+ "https://unpkg.com/@delurio/cli@0.2.0/schemas/ui.schema.json": "**/ui.yaml",
76
+ "https://unpkg.com/@delurio/cli@0.2.0/schemas/nav.schema.json": ["**/nav.yaml", "**/nav.extracted.yaml"],
77
+ "https://unpkg.com/@delurio/cli@0.2.0/schemas/domain.schema.json": "**/domain.yaml"
78
+ },
79
+ "json.schemas": [
80
+ { "fileMatch": ["**/catalog/*.json"], "url": "https://unpkg.com/@delurio/cli@0.2.0/schemas/catalog.schema.json" }
81
+ ]
82
+ }
83
+ ```
84
+
85
+ With the package installed, `./node_modules/@delurio/cli/schemas/<kind>.schema.json` works the same, offline. `check` reports the same as the editor, as errors of the rule `schema` that fail `--strict`, and finds what a schema cannot see, such as a state that does not exist or a catalog key that is missing.
86
+
87
+ Next to the editor, keep the pages open and current:
88
+
89
+ ```sh
90
+ npx @delurio/cli page graphs --serve # http://127.0.0.1:4600/, written again and reloaded on every save
91
+ ```
92
+
93
+ `--watch` writes the pages again on every change without serving them. `--serve` listens on 127.0.0.1 only and serves the written pages and nothing else; when a save leaves a graph that cannot be loaded, the open page says why until the next good save.
94
+
54
95
  ## What a revision changes, in words
55
96
 
56
97
  ```sh
57
- npx delurio diff graphs/billing/payment --base origin/main
98
+ npx @delurio/cli diff examples/qauth-authorize --base origin/main
58
99
  ```
59
100
 
60
101
  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/SPEC.md CHANGED
@@ -68,7 +68,7 @@ states: { <state>: { .. } }
68
68
  assertions: [ { id, never: "<expression>", say, source } ]
69
69
  ```
70
70
 
71
- A flow is a statechart, and its meaning is a subset of SCXML's (W3C Recommendation, 2015). A transition in `on` is an SCXML transition with an event, `always` is a list of eventless transitions, a list of transitions is tried in document order, `invoke` and `type: final` mean what they mean in SCXML, a guard is a `cond` and an effect an `<assign>`; `DONE` and `FAIL.<class>` stand for SCXML's `done.invoke` and `error` events. A flow can therefore be written out as SCXML; that export is an open issue.
71
+ A flow is a statechart, and its meaning is a subset of SCXML's (W3C Recommendation, 2015). A transition in `on` is an SCXML transition with an event, `always` is a list of eventless transitions, a list of transitions is tried in document order, `invoke` and `type: final` mean what they mean in SCXML, a guard is a `cond` and an effect an `<assign>`; `DONE` and `FAIL.<class>` stand for SCXML's `done.invoke` and `error` events. A flow can therefore be written out as SCXML; that export is [#9](https://github.com/delurio-labs/delurio/issues/9).
72
72
 
73
73
  ### Context
74
74
 
@@ -117,7 +117,7 @@ A configuration is a state together with a value for every context variable. The
117
117
 
118
118
  Each assertion is `never: <expression>`: it holds when no reachable configuration satisfies the expression. A violation is reported with the shortest event path from a starting situation. Under `--strict` a violated assertion exits 2.
119
119
 
120
- The checker does not yet fail on unreachable states, transitions no situation takes, or states with no way out; the page already shows them. That is an open issue. Paths that cover every transition are an open issue.
120
+ The checker does not yet fail on unreachable states, transitions no situation takes, or states with no way out; the page already shows them. That is [#2](https://github.com/delurio-labs/delurio/issues/2). Paths that cover every transition are [#4](https://github.com/delurio-labs/delurio/issues/4).
121
121
 
122
122
  ### Several flows
123
123
 
@@ -177,7 +177,7 @@ rules: [ { id, rule } ]
177
177
 
178
178
  A `role` is a WAI-ARIA 1.2 role, and `name` is the accessible name that Accessible Name and Description Computation 1.2 gives the node; `kit check` reads both from the browser's accessibility tree.
179
179
 
180
- A node is shown in a state when the state is in `in`, not in `unless`, and `when` holds. `edges` records relations the page and the checks use: a node labelled by another, an action that leads to a state. `rules` are written but no tool reads them yet; checking them, together with dialogs, focus return, confirmation before a destructive action and events handled where shown, is an open issue.
180
+ A node is shown in a state when the state is in `in`, not in `unless`, and `when` holds. `edges` records relations the page and the checks use: a node labelled by another, an action that leads to a state. `rules` are written but no tool reads them yet; checking them, together with dialogs, focus return, confirmation before a destructive action and events handled where shown, is [#3](https://github.com/delurio-labs/delurio/issues/3).
181
181
 
182
182
  ### Catalog
183
183
 
@@ -231,11 +231,13 @@ invariants:
231
231
 
232
232
  A capture may carry more words of its own on a field (`owner`, `note`, `editableBy`, `matching`, `opaque`). They are kept and compared, and no tool reads them.
233
233
 
234
+ An action, a refusal and an invariant may carry `note`: the author's own words, which no tool reads. An invariant the code does not keep is still written, with `holds: false` and the `finding` that reports it: the finding's id in the graph's report, such as `MS-7`. A capture describes the code, so the invariant says what should hold and the finding says where it does not. Decided on 25 Oct 2026.
235
+
234
236
  **The API.** An action with a `route` (`METHOD /path`) is one OpenAPI 3.1 operation: its name is the `operationId`, `needs` its security requirement, `query` (a JSON Schema object) its query parameters, `body` its request body, `result` its response (`returns` an entity, a `redirect`, a `status`, or a JSON Schema), and each refusal one more response. When a refusal has a body, it is RFC 9457 problem details with the refusal code as its `type`. So a domain can be written out as an OpenAPI document, and an app that already publishes one names it at the top (`openapi: <file>`) and each action's operation by `operationId`, instead of copying them. An action with no route is a step inside the service. `needs`, `policies`, `sideEffects`, `refusals.<code>.redirect` and `invariants` are delurio's own: no standard says who may do what to which data, or what must always hold.
235
237
 
236
- This form was decided on 22 Oct 2026. Graphs captured before it may still give fields as `{ kind: text }`; they are converted when a tool first reads fields. The OpenAPI export and import are an open issue.
238
+ This form was decided on 22 Oct 2026. Graphs captured before it may still give fields as `{ kind: text }`; they are converted when a tool first reads fields. The OpenAPI export and import are [#8](https://github.com/delurio-labs/delurio/issues/8).
237
239
 
238
- No tool checks a domain yet: the revision diff compares it and anchors lock its `source:` keys. Loading it with the flow and checking policies, secrets and `invoke` names is an open issue; generating it from Drizzle and Zod is an open issue; a registry that every API operation and route is covered by a graph is an open issue.
240
+ No tool checks a domain yet: the revision diff compares it and anchors lock its `source:` keys. Loading it with the flow and checking policies, secrets and `invoke` names is [#5](https://github.com/delurio-labs/delurio/issues/5); generating it from Drizzle and Zod is [#6](https://github.com/delurio-labs/delurio/issues/6); a registry that every API operation and route is covered by a graph is [#7](https://github.com/delurio-labs/delurio/issues/7).
239
241
 
240
242
  ## 6. Navigation (`kind: nav`)
241
243
 
@@ -324,12 +326,12 @@ graphs/product/flow.yaml:88:7: note: step 2: CODE_CHANGED from developing, with
324
326
 
325
327
  The same input gives the same bytes: no times, no random ids, results in a fixed order. Every file written is valid against the OASIS schema.
326
328
 
327
- There is no other machine format: no JSON of delurio's own, and nothing that only one CI reads. A converter for a CI that reads neither (GitLab's Code Quality report, Bitbucket's Code Insights) is written when a customer needs it, outside the checker. The public, pinned, offline CLI that gives both outputs is an open issue.
329
+ There is no other machine format: no JSON of delurio's own, and nothing that only one CI reads. A converter for a CI that reads neither (GitLab's Code Quality report, Bitbucket's Code Insights) is written when a customer needs it, outside the checker. The public, pinned, offline CLI that gives both outputs is `@delurio/cli`, built from this repository; what its first release left out is [#1](https://github.com/delurio-labs/delurio/issues/1).
328
330
 
329
331
  ## 11. Writing a graph
330
332
 
331
333
  - Model the few cases that change behaviour; a guard over hundreds of permission codes is wrong.
332
- - Quote a YAML list item that contains `: `, or YAML reads it as a map.
334
+ - Quote a YAML list item that contains `: `, or YAML reads it as a map. Quote a value inside `{ }` that contains `, ` too, or YAML cuts it there and reads the rest as a key: `{ type: final, title: "Paid, and sent" }`.
333
335
  - Say every rule that must hold as an assertion; do not leave it implied by the drawing.
334
336
  - In capture mode, describe what the code does today: no new feature, no fix. An invariant the code breaks is modelled as the code has it and reported, not written as an assertion that fails.
335
337
  - One process, one flow. A process that runs beside another starts it (`starts:`) or waits for it (`invoke: { flow }`).
package/dist/check.js CHANGED
@@ -14,6 +14,7 @@ import { checkStarts } from './starts.js';
14
14
  import { checkChanges } from './changes.js';
15
15
  import { KitError, resolveKit } from './kit.js';
16
16
  import { checkGoals, findings, loadNav, viewOf } from './nav.js';
17
+ import { schemaKindOf, validateFile } from './schemas.js';
17
18
  export const CHECK_USAGE = [
18
19
  ' node src/cli.ts check <graphs folder> [--strict] [--baseline <sarif>] [--format sarif --output <file>]',
19
20
  ' checks every graph under the folder and writes nothing else: GNU error lines on stdout, or SARIF 2.1.0;',
@@ -24,6 +25,7 @@ const SKIP = new Set(['viz', 'video', 'recording', 'node_modules', '.git']);
24
25
  const RULES = [
25
26
  { id: 'load', text: 'The graph cannot be loaded', level: 'error' },
26
27
  { id: 'format-newer', text: 'The graph is written for a newer format than this checker reads', level: 'error' },
28
+ { id: 'schema', text: "A graph file does not follow its kind's schema (SPEC.md)", level: 'error' },
27
29
  { id: 'assertion-violated', text: 'A guarantee of the flow is broken by a reachable situation', level: 'error' },
28
30
  { id: 'main-story-unwalkable', text: 'The declared main story cannot be walked', level: 'error' },
29
31
  { id: 'catalog-key-missing', text: 'A catalog key the screens use is missing', level: 'error' },
@@ -39,6 +41,7 @@ const RULES = [
39
41
  { id: 'nav-note', text: 'A note on the navigation graph', level: 'warning' },
40
42
  ];
41
43
  const LEVEL = new Map(RULES.map((r) => [r.id, r.level]));
44
+ export const count = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
42
45
  // the 1-based line and column of the first occurrence of `needle`, else the file's start
43
46
  function placeOf(abs, needle) {
44
47
  if (needle === null || !existsSync(abs))
@@ -51,7 +54,8 @@ function placeOf(abs, needle) {
51
54
  }
52
55
  return { line: 1, column: 1 };
53
56
  }
54
- function graphDirs(root, out = []) {
57
+ // every graph folder at or under root, in path order
58
+ export function graphDirs(root, out = []) {
55
59
  if (existsSync(join(root, 'flow.yaml')) || existsSync(join(root, 'nav.yaml')))
56
60
  out.push(root);
57
61
  for (const name of readdirSync(root).sort()) {
@@ -63,6 +67,19 @@ function graphDirs(root, out = []) {
63
67
  }
64
68
  return out;
65
69
  }
70
+ // every file under root that a schema covers (flow, ui, nav, nav.extracted, domain, catalog), in path order
71
+ function graphFiles(root, out = []) {
72
+ for (const name of readdirSync(root).sort()) {
73
+ if (SKIP.has(name) || name.startsWith('.'))
74
+ continue;
75
+ const path = join(root, name);
76
+ if (statSync(path).isDirectory())
77
+ graphFiles(path, out);
78
+ else if (schemaKindOf(path) !== null)
79
+ out.push(path);
80
+ }
81
+ return out;
82
+ }
66
83
  // the format the file says it is written in: null when it is this one
67
84
  function newerFormat(abs) {
68
85
  const doc = parse(readFileSync(abs, 'utf8'));
@@ -237,6 +254,11 @@ export function checkMain(args) {
237
254
  results.push(result('load', file, linePos === undefined ? { line: 1, column: 1 } : { line: linePos.line, column: linePos.col }, err.message.split('\n')[0], rel(d), 'load'));
238
255
  }
239
256
  }
257
+ // every graph file against its kind's schema: what an editor with the schemas marks, check reports
258
+ for (const file of graphFiles(root)) {
259
+ for (const f of validateFile(file))
260
+ results.push(result('schema', file, f, f.message, f.graph ?? rel(file), f.element));
261
+ }
240
262
  results.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column || a.rule.localeCompare(b.rule) || a.message.localeCompare(b.message));
241
263
  if (baseline !== undefined) {
242
264
  const prints = baselinePrints(baseline);
@@ -260,7 +282,9 @@ export function checkMain(args) {
260
282
  console.log(`${n.file}:${n.line}:${n.column}: note: ${n.message}`);
261
283
  }
262
284
  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}`);
285
+ const errors = results.filter((r) => r.level === 'error').length;
286
+ const warnings = results.filter((r) => r.level === 'warning').length;
287
+ console.error(`delurio check: ${count(graphs, 'graph')}, ${count(errors, 'error')}, ${count(warnings, 'warning')}; exit ${exitCode}`);
264
288
  }
265
289
  return exitCode;
266
290
  }
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,24 @@ 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';
22
+ import { watchPages } from './watch.js';
21
23
  const USAGE = [
22
24
  'usage:',
23
25
  CHECK_USAGE,
24
- ' delurio page <graph dir> [--out <dir>] [--kit <kit>] [--locale <code>] [--strict]',
25
- ' writes the graph\'s page: <out>/index.html and its diagram (default out: <graph dir>/viz)',
26
+ ' delurio page <graph dir | graphs folder> [--out <dir>] [--kit <kit>] [--locale <code>] [--strict] [--watch] [--serve [--port <n>]]',
27
+ ' writes the graph\'s page: <out>/index.html and its diagram (default out: <graph dir>/viz); on a folder of graphs,',
28
+ ' the page of each at <out>/<its path> and an index of them all at <out>/index.html (default out: <folder>/viz);',
29
+ ' --watch writes them again on every change under the folder; --serve also shows them at http://127.0.0.1:<port>/',
30
+ ' (default 4600) and reloads the open page after each write',
26
31
  DIFF_USAGE.replaceAll('node src/cli.ts', 'delurio'),
27
32
  ].join('\n').replaceAll('node src/cli.ts', 'delurio');
28
33
  const flag = (args, name) => {
29
34
  const i = args.indexOf(name);
30
35
  return i >= 0 ? args[i + 1] : undefined;
31
36
  };
32
- function navPage(dir, args) {
33
- const out = resolve(flag(args, '--out') ?? join(dir, 'viz'));
37
+ function navPage(dir, out, args) {
34
38
  const { g, warnings } = loadNav(dir, flag(args, '--locale'));
35
39
  const views = g.personas.map((p) => viewOf(g, p));
36
40
  const rows = menuRows(g, views);
@@ -40,14 +44,15 @@ function navPage(dir, args) {
40
44
  writeFileSync(join(out, 'index.html'), renderNavPage({ g, views, rows, goals, found, repo: null, warnings }));
41
45
  writeFileSync(join(out, 'nav.mmd'), renderNavMermaid(g));
42
46
  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`);
47
+ 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
48
  for (const w of warnings)
45
49
  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;
50
+ return {
51
+ code: args.includes('--strict') && (warnings.length > 0 || problems.length > 0) ? 2 : 0,
52
+ entry: { path: '', kind: 'nav', id: g.id, title: `${g.title}: where people go`, status: g.status, holds: 0, total: problems.length, warnings, error: null },
53
+ };
48
54
  }
49
- async function flowPage(dir, args) {
50
- const out = resolve(flag(args, '--out') ?? join(dir, 'viz'));
55
+ async function flowPage(dir, out, args) {
51
56
  const warnings = [];
52
57
  const warn = (m) => {
53
58
  if (!warnings.includes(m))
@@ -78,13 +83,71 @@ async function flowPage(dir, args) {
78
83
  writeFileSync(join(out, 'index.html'), renderPage({ g, m: model, ex, assertions, finals, main, spineMain, spineFull, screens, kit, handOvers }));
79
84
  writeFileSync(join(out, 'flow.mmd'), renderMermaid(g, ex));
80
85
  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`);
86
+ 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
87
  for (const a of violated)
83
88
  console.log(` violated: ${a.id}`);
84
89
  for (const w of warnings)
85
90
  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;
91
+ return {
92
+ code: args.includes('--strict') && (violated.length > 0 || warnings.length > 0) ? 2 : 0,
93
+ 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 },
94
+ };
95
+ }
96
+ const isGraph = (dir) => existsSync(join(dir, 'flow.yaml')) || existsSync(join(dir, 'nav.yaml'));
97
+ // every graph under the folder, each into <out>/<its path>, and the index of them all. A graph that cannot be loaded
98
+ // is listed as such and the rest are still written: exit 1 when one cannot be loaded, else 2 under --strict when one
99
+ // would fail on its own, else 0.
100
+ async function treePages(root, out, args) {
101
+ const dirs = graphDirs(root);
102
+ if (dirs.length === 0) {
103
+ const message = `${root} is not a graph and holds none: there is no flow.yaml or nav.yaml in it or under it`;
104
+ console.error(`error: ${message}`);
105
+ return { code: 1, message };
106
+ }
107
+ const entries = [];
108
+ let broken = false;
109
+ let failing = false;
110
+ for (const dir of dirs) {
111
+ const path = relative(root, dir).split(sep).join('/');
112
+ const nav = !existsSync(join(dir, 'flow.yaml'));
113
+ try {
114
+ const { code, entry } = nav ? navPage(dir, join(out, path), args) : await flowPage(dir, join(out, path), args);
115
+ entries.push({ ...entry, path });
116
+ failing ||= code !== 0;
117
+ }
118
+ catch (err) {
119
+ if (!loadError(err))
120
+ throw err;
121
+ broken = true;
122
+ const message = err.message.split('\n')[0].replace(/:$/, '');
123
+ console.error(`error: ${path}: ${message}`);
124
+ entries.push({ path, kind: nav ? 'nav' : 'flow', id: null, title: path, status: null, holds: 0, total: 0, warnings: [], error: message });
125
+ }
126
+ }
127
+ mkdirSync(out, { recursive: true });
128
+ writeFileSync(join(out, 'index.html'), renderIndexPage(basename(resolve(root)), entries));
129
+ console.log(`wrote ${join(out, 'index.html')} and the pages of ${count(entries.length - entries.filter((e) => e.error !== null).length, 'graph')} under ${out}`);
130
+ const firstError = entries.find((e) => e.error !== null);
131
+ return { code: broken ? 1 : failing ? 2 : 0, message: firstError === undefined ? '' : `${firstError.path}: ${firstError.error}` };
132
+ }
133
+ const loadError = (err) => err instanceof GraphError || err instanceof ModelError || err instanceof KitError || (err instanceof Error && err.name === 'YAMLParseError');
134
+ // one graph's page, or the pages of every graph under the folder
135
+ async function writePages(dir, out, args) {
136
+ if (!isGraph(dir))
137
+ return treePages(dir, out, args);
138
+ try {
139
+ const flow = existsSync(join(dir, 'flow.yaml'));
140
+ const { code } = flow ? await flowPage(dir, out, args) : navPage(dir, out, args);
141
+ console.log(`wrote ${join(out, 'index.html')} and ${join(out, flow ? 'flow.mmd' : 'nav.mmd')}`);
142
+ return { code, message: '' };
143
+ }
144
+ catch (err) {
145
+ if (!loadError(err))
146
+ throw err;
147
+ const message = err.message.split('\n')[0].replace(/:$/, '');
148
+ console.error(`error: ${message}`);
149
+ return { code: 1, message };
150
+ }
88
151
  }
89
152
  async function main() {
90
153
  const args = process.argv.slice(2);
@@ -94,13 +157,30 @@ async function main() {
94
157
  return diffMain(args.slice(1));
95
158
  if (args[0] === 'page') {
96
159
  const rest = args.slice(1);
97
- const withValue = ['--out', '--kit', '--locale'];
160
+ const withValue = ['--out', '--kit', '--locale', '--port'];
98
161
  const dir = rest.find((a, i) => !a.startsWith('--') && !withValue.includes(rest[i - 1] ?? ''));
99
162
  if (dir === undefined) {
100
163
  console.error(USAGE);
101
164
  return 1;
102
165
  }
103
- return existsSync(join(dir, 'flow.yaml')) ? flowPage(dir, rest) : navPage(dir, rest);
166
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) {
167
+ console.error(`error: ${dir} is not a folder`);
168
+ return 1;
169
+ }
170
+ const out = resolve(flag(rest, '--out') ?? join(dir, 'viz'));
171
+ const first = await writePages(dir, out, rest);
172
+ const serve = rest.includes('--serve');
173
+ if (!serve && !rest.includes('--watch'))
174
+ return first.code;
175
+ const port = Number(flag(rest, '--port') ?? 4600);
176
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
177
+ console.error(`error: --port is a port number, not ${flag(rest, '--port')}`);
178
+ return 1;
179
+ }
180
+ return watchPages(dir, out, async () => {
181
+ const again = await writePages(dir, out, rest);
182
+ return { ok: again.code !== 1, message: again.message };
183
+ }, serve ? port : null);
104
184
  }
105
185
  console.error(USAGE);
106
186
  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
+ }