htmlineage 0.0.0 → 0.1.0-alpha.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.
Files changed (42) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +64 -2
  3. package/index.js +4 -0
  4. package/package.json +52 -3
  5. package/src/check/rules.js +379 -0
  6. package/src/check/summary.js +154 -0
  7. package/src/cli.js +99 -0
  8. package/src/commands/check.js +71 -0
  9. package/src/commands/index.js +7 -0
  10. package/src/commands/sql.js +65 -0
  11. package/src/commands/view.js +80 -0
  12. package/src/errors.js +39 -0
  13. package/src/graph/duplicates.js +78 -0
  14. package/src/graph/expr.js +54 -0
  15. package/src/graph/ids.js +24 -0
  16. package/src/graph/numbers.js +61 -0
  17. package/src/graph/parse.js +196 -0
  18. package/src/graph/schema.js +465 -0
  19. package/src/graph/trace.js +45 -0
  20. package/src/help.js +25 -0
  21. package/src/html/embed.js +142 -0
  22. package/src/html/page.js +101 -0
  23. package/src/meta.js +13 -0
  24. package/src/sql/compose.js +215 -0
  25. package/src/sql/style.js +193 -0
  26. package/src/viewer/client/bridge.js +141 -0
  27. package/src/viewer/client/fonts/JetBrainsMono-Bold.woff2 +0 -0
  28. package/src/viewer/client/fonts/JetBrainsMono-Medium.woff2 +0 -0
  29. package/src/viewer/client/fonts/JetBrainsMono-Regular.woff2 +0 -0
  30. package/src/viewer/client/fonts/JetBrainsMono-SemiBold.woff2 +0 -0
  31. package/src/viewer/client/fonts/OFL-JetBrainsMono.txt +93 -0
  32. package/src/viewer/client/fonts/OFL-SourceSerif4.txt +93 -0
  33. package/src/viewer/client/fonts/SourceSerif4-Medium.woff2 +0 -0
  34. package/src/viewer/client/fonts/SourceSerif4-Regular.woff2 +0 -0
  35. package/src/viewer/client/fonts/SourceSerif4-SemiBold.woff2 +0 -0
  36. package/src/viewer/client/index.html +95 -0
  37. package/src/viewer/client/lineage.js +650 -0
  38. package/src/viewer/client/themes.css +76 -0
  39. package/src/viewer/client/viewer.css +1418 -0
  40. package/src/viewer/client/viewer.js +761 -0
  41. package/src/viewer/payload.js +45 -0
  42. package/src/viewer/server.js +188 -0
@@ -0,0 +1,379 @@
1
+ /**
2
+ * The check rules that need more than one node or the page.
3
+ * `validateGraph()` (`../graph/schema.js`) owns the per-node ones (S1–S9, C4,
4
+ * C9). Pure: no file I/O.
5
+ */
6
+
7
+ import { bareNumbers } from "../graph/numbers.js";
8
+ import { exprProblem, exprRefs } from "../graph/expr.js";
9
+ import { STEPS } from "../graph/schema.js";
10
+ import { checkSqlStyle, dateFunctions, maskSql } from "../sql/style.js";
11
+
12
+ /** Every rule this module can raise, with its fixed severity. */
13
+ export const RULES = {
14
+ C1: { severity: "error", title: "binding and node do not match up" },
15
+ C2: {
16
+ severity: "warning",
17
+ title: "number in prose is outside a figure's span",
18
+ },
19
+ C3: {
20
+ severity: "warning",
21
+ title: "figure text does not match its display or value",
22
+ },
23
+ C5: {
24
+ severity: "error",
25
+ title: "`from` breaks the source → view → figure → insight chain",
26
+ },
27
+ C6: { severity: "error", title: "nodes depend on each other in a cycle" },
28
+ C7: {
29
+ severity: "warning",
30
+ title: "sql source is incomplete or not pinned to its period",
31
+ },
32
+ C8: { severity: "warning", title: "SQL breaks the style rules" },
33
+ };
34
+
35
+ const isObject = (value) =>
36
+ value !== null && typeof value === "object" && !Array.isArray(value);
37
+ const isStringArray = (value) =>
38
+ Array.isArray(value) && value.every((v) => typeof v === "string");
39
+
40
+ const NUMBER_PATTERN =
41
+ /^([+\-−])?[$€£¥]?#?(\d{1,3}(?:,\d{3})+|\d+)(?:\.(\d+))?( ?%)?$/;
42
+
43
+ /**
44
+ * Read a figure's page text as a number, for C3's fallback.
45
+ * @param {string} text
46
+ * @returns {{ number: number, decimals: number, percent: boolean } | null}
47
+ */
48
+ export function parseDisplayNumber(text) {
49
+ const match = NUMBER_PATTERN.exec(text);
50
+ if (!match) return null;
51
+ const [, sign, digits, decimals, percent] = match;
52
+ const magnitude = Number(`${digits.replace(/,/g, "")}.${decimals ?? "0"}`);
53
+ const number = sign === "-" || sign === "−" ? -magnitude : magnitude;
54
+ return {
55
+ number,
56
+ decimals: decimals ? decimals.length : 0,
57
+ percent: Boolean(percent),
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Run the check rules over a graph and its page.
63
+ * @param {unknown} graph as returned by readGraph; may be malformed
64
+ * @param {{ bindings: {id,text,offset}[], prose: {text,node,offset}[] }} page
65
+ * @returns {{ code, severity, node: string|null, field: string|null, message: string }[]}
66
+ */
67
+ export function checkRules(graph, page) {
68
+ const nodes = graph?.nodes;
69
+ if (!isObject(nodes)) return [];
70
+
71
+ const has = (id) => Object.hasOwn(nodes, id);
72
+ const node = (id) => (has(id) && isObject(nodes[id]) ? nodes[id] : null);
73
+
74
+ const issues = [];
75
+ const report = (code, n, field, message) =>
76
+ issues.push({
77
+ code,
78
+ severity: RULES[code].severity,
79
+ node: n,
80
+ field: field ?? null,
81
+ message,
82
+ });
83
+
84
+ checkC1(nodes, has, node, page, report);
85
+ checkC2(page, report);
86
+ checkC3(node, page, report);
87
+ checkC5(nodes, has, node, report);
88
+ checkC6(nodes, has, report);
89
+ checkC7(nodes, report);
90
+ checkC8(nodes, report);
91
+
92
+ return issues;
93
+ }
94
+
95
+ function checkC1(nodes, has, node, page, report) {
96
+ const seen = new Set();
97
+ for (const binding of page.bindings) {
98
+ const id = binding.id;
99
+ if (seen.has(id)) continue;
100
+ if (!has(id)) {
101
+ seen.add(id);
102
+ report("C1", id, null, `data-lineage="${id}" resolves to no node`);
103
+ continue;
104
+ }
105
+ const n = node(id);
106
+ if (n?.step === "source") {
107
+ seen.add(id);
108
+ report(
109
+ "C1",
110
+ id,
111
+ null,
112
+ `\`${id}\` is a source; bind a figure over it instead`,
113
+ );
114
+ }
115
+ }
116
+
117
+ for (const [id, n] of Object.entries(nodes)) {
118
+ if (!isObject(n)) continue;
119
+ if (n.step !== "view" && n.step !== "figure" && n.step !== "insight")
120
+ continue;
121
+ const bound = page.bindings.some((b) => b.id === id);
122
+ if (!bound) {
123
+ report(
124
+ "C1",
125
+ id,
126
+ null,
127
+ `${n.step} is not bound to any element on the page`,
128
+ );
129
+ }
130
+ }
131
+ }
132
+
133
+ function checkC2(page, report) {
134
+ for (const entry of page.prose) {
135
+ for (const token of bareNumbers(entry.text)) {
136
+ report(
137
+ "C2",
138
+ entry.node,
139
+ null,
140
+ `bare number "${token.text}" is not inside a figure's span`,
141
+ );
142
+ }
143
+ }
144
+ }
145
+
146
+ function checkC3(node, page, report) {
147
+ const byId = new Map();
148
+ for (const binding of page.bindings) {
149
+ if (!byId.has(binding.id)) byId.set(binding.id, []);
150
+ byId.get(binding.id).push(binding.text);
151
+ }
152
+
153
+ for (const [id, texts] of byId) {
154
+ const n = node(id);
155
+ if (n?.step !== "figure") continue;
156
+ const display = typeof n.display === "string" ? n.display : null;
157
+ const value =
158
+ typeof n.value === "number" && Number.isFinite(n.value) ? n.value : null;
159
+ if (display === null && value === null) continue;
160
+
161
+ const bad = [];
162
+ const seen = new Set();
163
+ for (const text of texts) {
164
+ if (text === display) continue;
165
+ let ok = false;
166
+ if (value !== null) {
167
+ const parsed = parseDisplayNumber(text);
168
+ if (parsed) {
169
+ const s = parsed.percent ? 100 : 1;
170
+ const slack = ((0.5 * 10 ** -parsed.decimals) / s) * (1 + 1e-9);
171
+ if (Math.abs(parsed.number / s - value) <= slack) ok = true;
172
+ }
173
+ }
174
+ if (!ok && !seen.has(text)) {
175
+ seen.add(text);
176
+ bad.push(text);
177
+ }
178
+ }
179
+ if (bad.length > 0) {
180
+ const quoted = bad.map((t) => `"${t}"`).join(", ");
181
+ const against =
182
+ display !== null ? `display is "${display}"` : `value is ${value}`;
183
+ report("C3", id, "display", `page shows ${quoted} but ${against}`);
184
+ }
185
+ }
186
+ }
187
+
188
+ /** Read a node's step, or null when it is not a plain object or has an unknown step. */
189
+ function stepOf(n) {
190
+ return isObject(n) && STEPS.includes(n.step) ? n.step : null;
191
+ }
192
+
193
+ function checkC5(nodes, has, node, report) {
194
+ for (const [id, n] of Object.entries(nodes)) {
195
+ if (!isObject(n)) continue;
196
+ const step = n.step;
197
+ if (step !== "view" && step !== "figure" && step !== "insight") continue;
198
+ if (!isStringArray(n.from) || n.from.length === 0) continue;
199
+
200
+ let exprIds = null;
201
+ if (step === "figure") {
202
+ const hasSql = n.sql !== undefined;
203
+ const hasExpr = n.expr !== undefined;
204
+ if (hasSql === hasExpr) continue;
205
+ if (hasExpr) {
206
+ if (typeof n.expr !== "string") continue;
207
+ if (exprProblem(n.expr)) continue;
208
+ exprIds = exprRefs(n.expr, Object.keys(nodes));
209
+ }
210
+ }
211
+
212
+ const parts = [];
213
+
214
+ const unknown = n.from.filter((p) => !has(p));
215
+ if (unknown.length > 0) {
216
+ parts.push(
217
+ `\`from\` names ${unknown.join(", ")}, which ${unknown.length > 1 ? "are not nodes" : "is not a node"}`,
218
+ );
219
+ }
220
+
221
+ const knownParents = n.from
222
+ .filter((p) => has(p))
223
+ .map((p) => ({ id: p, step: stepOf(node(p)) }))
224
+ .filter((p) => p.step !== null);
225
+
226
+ if (knownParents.length > 0) {
227
+ let ok;
228
+ let message;
229
+ if (step === "view") {
230
+ ok = knownParents.every(
231
+ (p) => p.step === "source" || p.step === "view",
232
+ );
233
+ message = "a view rests on sources and views only";
234
+ } else if (step === "figure" && exprIds === null) {
235
+ ok = knownParents.every(
236
+ (p) => p.step === "source" || p.step === "view",
237
+ );
238
+ message = "a figure with `sql` rests on sources and views only";
239
+ } else if (step === "figure") {
240
+ ok = knownParents.every((p) => p.step === "figure");
241
+ message = "a figure with `expr` rests on figures only";
242
+ } else {
243
+ ok = knownParents.every((p) => p.step !== "source");
244
+ message = "an insight rests on views, figures and insights only";
245
+ }
246
+ if (!ok) parts.push(message);
247
+ }
248
+
249
+ if (step === "figure" && exprIds !== null) {
250
+ const missing = exprIds.filter((r) => !n.from.includes(r));
251
+ if (missing.length > 0) {
252
+ parts.push(
253
+ `\`expr\` uses ${missing.join(", ")}, which ${missing.length > 1 ? "are not in `from`" : "is not in `from`"}`,
254
+ );
255
+ }
256
+ }
257
+
258
+ if (parts.length > 0) {
259
+ report("C5", id, "from", parts.join("; "));
260
+ }
261
+ }
262
+ }
263
+
264
+ function checkC6(nodes, has, report) {
265
+ const edges = (id) => {
266
+ const n = nodes[id];
267
+ if (!isObject(n) || !isStringArray(n.from)) return [];
268
+ return n.from.filter((p) => has(p));
269
+ };
270
+
271
+ const keyOrder = Object.keys(nodes);
272
+ const indexOf = new Map(keyOrder.map((id, i) => [id, i]));
273
+ const state = new Map(); // 0 = visiting, 1 = done
274
+ const stack = [];
275
+ const stackSet = new Set();
276
+ const reportedSets = new Set();
277
+
278
+ const visit = (id) => {
279
+ state.set(id, 0);
280
+ stack.push(id);
281
+ stackSet.add(id);
282
+ for (const next of edges(id)) {
283
+ if (stackSet.has(next)) {
284
+ const cycleStart = stack.indexOf(next);
285
+ const cycle = stack.slice(cycleStart);
286
+ const memberSet = [...cycle].sort().join(",");
287
+ if (!reportedSets.has(memberSet)) {
288
+ reportedSets.add(memberSet);
289
+ let firstIndex = 0;
290
+ for (let i = 1; i < cycle.length; i++) {
291
+ if (indexOf.get(cycle[i]) < indexOf.get(cycle[firstIndex]))
292
+ firstIndex = i;
293
+ }
294
+ const rotated = [
295
+ ...cycle.slice(firstIndex),
296
+ ...cycle.slice(0, firstIndex),
297
+ ];
298
+ report(
299
+ "C6",
300
+ rotated[0],
301
+ "from",
302
+ `cycle ${[...rotated, rotated[0]].join(" -> ")}`,
303
+ );
304
+ }
305
+ } else if (!state.has(next)) {
306
+ visit(next);
307
+ }
308
+ }
309
+ stack.pop();
310
+ stackSet.delete(id);
311
+ state.set(id, 1);
312
+ };
313
+
314
+ for (const id of keyOrder) {
315
+ if (!state.has(id)) visit(id);
316
+ }
317
+ }
318
+
319
+ function checkC7(nodes, report) {
320
+ for (const [id, n] of Object.entries(nodes)) {
321
+ if (!isObject(n) || n.step !== "source" || n.kind !== "sql") continue;
322
+
323
+ const parts = [];
324
+ const missing = ["mart", "period", "sql", "columns"].filter(
325
+ (field) => n[field] === undefined,
326
+ );
327
+ if (missing.length > 0) {
328
+ parts.push(`sql source is missing \`${missing.join("`, `")}\``);
329
+ }
330
+
331
+ if (typeof n.sql === "string") {
332
+ if (
333
+ isObject(n.period) &&
334
+ typeof n.period.from === "string" &&
335
+ typeof n.period.to === "string"
336
+ ) {
337
+ const masked = maskSql(n.sql, { keepStrings: true });
338
+ const dates = [...new Set([n.period.from, n.period.to])];
339
+ const missingDates = dates.filter(
340
+ (date) => !masked.includes(`'${date}'`),
341
+ );
342
+ if (missingDates.length > 0) {
343
+ parts.push(
344
+ `SQL does not contain the period as literal dates: ${missingDates.map((d) => `'${d}'`).join(", ")}`,
345
+ );
346
+ }
347
+ }
348
+ const dates = dateFunctions(n.sql);
349
+ if (dates.length > 0) {
350
+ parts.push(`SQL uses date functions: ${dates.join(", ")}`);
351
+ }
352
+ }
353
+
354
+ if (parts.length > 0) report("C7", id, null, parts.join("; "));
355
+ }
356
+ }
357
+
358
+ function checkC8(nodes, report) {
359
+ for (const [id, n] of Object.entries(nodes)) {
360
+ if (!isObject(n)) continue;
361
+ const isSource = n.step === "source" && n.kind === "sql";
362
+ const isViewOrFigure = n.step === "view" || n.step === "figure";
363
+ if (!isSource && !isViewOrFigure) continue;
364
+ if (typeof n.sql !== "string" || n.sql.trim() === "") continue;
365
+
366
+ const parents = isViewOrFigure && isStringArray(n.from) ? n.from : [];
367
+ let findings = checkSqlStyle(n.sql, { parents });
368
+ if (isSource) findings = findings.filter((f) => f.rule !== "date-function");
369
+
370
+ if (findings.length > 0) {
371
+ report(
372
+ "C8",
373
+ id,
374
+ "sql",
375
+ `SQL style: ${findings.map((f) => f.message).join("; ")}`,
376
+ );
377
+ }
378
+ }
379
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The check summary: counts, what needs attention, and its printed form.
3
+ * Pure: no file I/O, no printing.
4
+ */
5
+
6
+ import { traceGraph } from "../graph/trace.js";
7
+
8
+ /** `[id, node]` pairs in graph order; none when the graph has no readable `nodes`. */
9
+ const nodeEntries = (graph) =>
10
+ graph !== null &&
11
+ typeof graph === "object" &&
12
+ graph.nodes !== null &&
13
+ typeof graph.nodes === "object" &&
14
+ !Array.isArray(graph.nodes)
15
+ ? Object.entries(graph.nodes)
16
+ : [];
17
+
18
+ /** The node's `text`, else its `label`; only strings count. */
19
+ const describe = (node) =>
20
+ [node.text, node.label].find((value) => typeof value === "string") ?? null;
21
+
22
+ /**
23
+ * @param {unknown} graph
24
+ * @param {{ bindings: { id: string }[] }} page
25
+ * @param {object[]} issues
26
+ * @param {{ report: string }} options
27
+ */
28
+ export function summarize(graph, page, issues, { report }) {
29
+ const counts = {
30
+ sources: { total: 0, external: 0 },
31
+ views: { total: 0, shown: 0 },
32
+ figures: { total: 0, full: 0, partial: 0, ungrounded: 0 },
33
+ insights: { total: 0, full: 0, partial: 0, ungrounded: 0 },
34
+ };
35
+ const ungrounded = [];
36
+ const external = [];
37
+ const states = traceGraph(graph);
38
+ const bound = new Set(page.bindings.map((binding) => binding.id));
39
+ for (const [id, node] of nodeEntries(graph)) {
40
+ if (!states.has(id)) continue;
41
+ if (node.ungrounded === true) {
42
+ ungrounded.push({ node: id, text: describe(node) });
43
+ }
44
+ if (node.step === "view") {
45
+ counts.views.total += 1;
46
+ if (bound.has(id)) counts.views.shown += 1;
47
+ }
48
+ if (node.step === "figure" || node.step === "insight") {
49
+ const tally = counts[`${node.step}s`];
50
+ tally.total += 1;
51
+ tally[states.get(id)] += 1;
52
+ }
53
+ if (node.step === "source") {
54
+ counts.sources.total += 1;
55
+ if (node.kind === "external") {
56
+ counts.sources.external += 1;
57
+ external.push({
58
+ node: id,
59
+ ref: typeof node.ref === "string" ? node.ref : null,
60
+ });
61
+ }
62
+ }
63
+ }
64
+ return { report, counts, ungrounded, external, issues };
65
+ }
66
+
67
+ /** One count line: label padded to 11, count padded to 4, then the detail. */
68
+ const countLine = (label, total, detail) =>
69
+ ` ${label.padEnd(11)}${String(total).padEnd(4)}${detail}`.trimEnd();
70
+
71
+ const tracedDetail = ({ total, full, partial, ungrounded }) => {
72
+ if (total > 0 && full === total) return "all fully traced";
73
+ return [
74
+ [full, "fully traced"],
75
+ [partial, "partially traced"],
76
+ [ungrounded, "ungrounded"],
77
+ ]
78
+ .filter(([n]) => n > 0)
79
+ .map(([n, what]) => `${n} ${what}`)
80
+ .join(", ");
81
+ };
82
+
83
+ const shownDetail = ({ total, shown }) => {
84
+ if (total === 0) return "";
85
+ if (shown === total) return "all shown on the page";
86
+ return `${shown} shown, ${total - shown} not on the page`;
87
+ };
88
+
89
+ const RED = "\x1b[31m";
90
+ const YELLOW = "\x1b[33m";
91
+ const RESET = "\x1b[0m";
92
+
93
+ const shorten = (text) => {
94
+ const flat = text.replace(/\s+/g, " ").trim();
95
+ return flat.length <= 40 ? flat : `${flat.slice(0, 40).trimEnd()}…`;
96
+ };
97
+
98
+ /**
99
+ * @param {object} summary
100
+ * @param {{ color?: boolean }} [options]
101
+ * @returns {string}
102
+ */
103
+ export function formatSummary(summary, { color = false } = {}) {
104
+ const { sources, views, figures, insights } = summary.counts;
105
+ const colour = (text, paint) => (color ? `${paint}${text}${RESET}` : text);
106
+ const lines = [
107
+ summary.report,
108
+ "",
109
+ countLine(
110
+ "sources",
111
+ sources.total,
112
+ sources.external > 0 ? `(${sources.external} external)` : "",
113
+ ),
114
+ countLine("views", views.total, shownDetail(views)),
115
+ countLine("figures", figures.total, tracedDetail(figures)),
116
+ countLine("insights", insights.total, tracedDetail(insights)),
117
+ ];
118
+ const rows = summary.ungrounded.map(({ node, text }) => ({
119
+ node,
120
+ tag: "ungrounded",
121
+ paint: YELLOW,
122
+ detail: text === null ? "" : `"${shorten(text)}"`,
123
+ }));
124
+ for (const { node, ref } of summary.external) {
125
+ const ask = "a reader will be asked to confirm this with you";
126
+ rows.push({
127
+ node,
128
+ tag: "external",
129
+ paint: YELLOW,
130
+ detail: ref === null ? ask : `${ref} — ${ask}`,
131
+ });
132
+ }
133
+ for (const severity of ["warning", "error"]) {
134
+ for (const issue of summary.issues) {
135
+ if (issue.severity !== severity) continue;
136
+ rows.push({
137
+ node: issue.node ?? "graph",
138
+ tag: `${issue.code} ${severity}`,
139
+ paint: severity === "error" ? RED : YELLOW,
140
+ detail: issue.message,
141
+ });
142
+ }
143
+ }
144
+ if (rows.length > 0) {
145
+ const width = Math.max(5, ...rows.map((row) => row.node.length + 3));
146
+ lines.push("", " needs attention");
147
+ for (const { node, tag, paint, detail } of rows) {
148
+ lines.push(
149
+ ` ${node.padEnd(width)}${colour(tag, paint)}${" ".repeat(13 - tag.length)}${detail}`.trimEnd(),
150
+ );
151
+ }
152
+ }
153
+ return lines.join("\n");
154
+ }
package/src/cli.js ADDED
@@ -0,0 +1,99 @@
1
+ import { parseArgs } from "node:util";
2
+
3
+ import { byName, commands } from "./commands/index.js";
4
+ import { EXIT_OK, EXIT_USAGE, UsageError } from "./errors.js";
5
+ import { topLevelHelp, versionLine } from "./help.js";
6
+
7
+ const defaultIo = {
8
+ out: (text) => console.log(text),
9
+ err: (text) => console.error(text),
10
+ color: process.stdout.isTTY === true,
11
+ };
12
+
13
+ const HELP_FLAGS = new Set(["-h", "--help"]);
14
+ const VERSION_FLAGS = new Set(["-v", "--version"]);
15
+
16
+ /**
17
+ * Parse one command's arguments. parseArgs throws a handful of ERR_PARSE_ARGS_*
18
+ * codes with readable messages; they are all invocation mistakes, so they all
19
+ * become a UsageError and exit 2.
20
+ */
21
+ function parseCommand(command, argv) {
22
+ try {
23
+ return parseArgs({
24
+ args: argv,
25
+ options: { ...command.options, help: { type: "boolean", short: "h" } },
26
+ allowPositionals: true,
27
+ strict: true,
28
+ });
29
+ } catch (error) {
30
+ throw new UsageError(error.message, { command: command.name });
31
+ }
32
+ }
33
+
34
+ async function dispatch(argv, io) {
35
+ if (argv.length === 0) {
36
+ io.out(topLevelHelp());
37
+ return EXIT_OK;
38
+ }
39
+
40
+ const [first, ...rest] = argv;
41
+
42
+ if (HELP_FLAGS.has(first)) {
43
+ // `htmlineage --help check` is the same as `htmlineage check --help`.
44
+ const named = byName.get(rest[0]);
45
+ io.out(named ? named.usage : topLevelHelp());
46
+ return EXIT_OK;
47
+ }
48
+
49
+ if (VERSION_FLAGS.has(first)) {
50
+ io.out(versionLine());
51
+ return EXIT_OK;
52
+ }
53
+
54
+ if (first.startsWith("-")) {
55
+ throw new UsageError(`unknown option \`${first}\``);
56
+ }
57
+
58
+ const command = byName.get(first);
59
+ if (!command) {
60
+ const known = commands.map((c) => c.name).join(", ");
61
+ throw new UsageError(
62
+ `unknown command \`${first}\`. Expected one of: ${known}`,
63
+ );
64
+ }
65
+
66
+ const parsed = parseCommand(command, rest);
67
+ if (parsed.values.help) {
68
+ io.out(command.usage);
69
+ return EXIT_OK;
70
+ }
71
+
72
+ const code = await command.run(parsed, io);
73
+ return code ?? EXIT_OK;
74
+ }
75
+
76
+ /**
77
+ * The whole CLI as one function, so tests can drive it in-process and assert
78
+ * on both the exit code and the output without spawning Node.
79
+ */
80
+ export async function main(argv = process.argv.slice(2), io = defaultIo) {
81
+ try {
82
+ return await dispatch(argv, io);
83
+ } catch (error) {
84
+ if (error instanceof UsageError) {
85
+ io.err(`htmlineage: ${error.message}`);
86
+ io.err(
87
+ error.command
88
+ ? `Try \`htmlineage ${error.command} --help\`.`
89
+ : "Try `htmlineage --help`.",
90
+ );
91
+ return EXIT_USAGE;
92
+ }
93
+ if (typeof error?.exitCode === "number") {
94
+ io.err(`htmlineage: ${error.message}`);
95
+ return error.exitCode;
96
+ }
97
+ throw error;
98
+ }
99
+ }
@@ -0,0 +1,71 @@
1
+ import { basename } from "node:path";
2
+
3
+ import { checkRules } from "../check/rules.js";
4
+ import { formatSummary, summarize } from "../check/summary.js";
5
+ import { UsageError } from "../errors.js";
6
+ import { readGraph, readText } from "../graph/parse.js";
7
+ import { validateGraph } from "../graph/schema.js";
8
+ import { embedFile } from "../html/embed.js";
9
+ import { proseSkip, readPage } from "../html/page.js";
10
+
11
+ export const name = "check";
12
+ export const summary = "Validate a report's lineage and embed the graph";
13
+
14
+ export const usage = `htmlineage check <report.html> [options]
15
+
16
+ Reads the lineage graph beside the report, checks it against the report's
17
+ data-lineage bindings, embeds it into the HTML, and prints a summary.
18
+
19
+ Exit code is 0 when the report passes (warnings still pass) and 1 when it
20
+ does not.
21
+
22
+ Options
23
+ --graph <path> Graph JSON to read (default: .lineage/<report>.json
24
+ beside the report, or else the block already embedded
25
+ in it)
26
+ --no-embed Check only; leave the report file untouched
27
+ --json Print the summary as JSON instead of prose
28
+ --quiet Print nothing; rely on the exit code
29
+ -h, --help Show this help`;
30
+
31
+ export const options = {
32
+ graph: { type: "string" },
33
+ "no-embed": { type: "boolean", default: false },
34
+ json: { type: "boolean", default: false },
35
+ quiet: { type: "boolean", default: false },
36
+ };
37
+
38
+ export async function run(parsed, io) {
39
+ const { positionals } = parsed;
40
+ if (positionals.length === 0) {
41
+ throw new UsageError("missing the report to check", { command: name });
42
+ }
43
+ if (positionals.length > 1) {
44
+ throw new UsageError(`expected one report, got ${positionals.length}`, {
45
+ command: name,
46
+ });
47
+ }
48
+ const reportPath = positionals[0];
49
+ const { graph } = readGraph({
50
+ reportPath,
51
+ graphPath: parsed.values.graph,
52
+ });
53
+ const html = readText(reportPath, "report");
54
+ const page = readPage(html, { skip: proseSkip(graph) });
55
+ const issues = [...validateGraph(graph), ...checkRules(graph, page)];
56
+ const summary = summarize(graph, page, issues, {
57
+ report: basename(reportPath),
58
+ });
59
+ const failed = issues.some((issue) => issue.severity === "error");
60
+ // Embed before printing: if the report cannot take the graph, check fails
61
+ // with that error alone rather than after a summary that reads as passing.
62
+ if (!failed && !parsed.values["no-embed"]) embedFile(reportPath, html, graph);
63
+ if (!parsed.values.quiet) {
64
+ io.out(
65
+ parsed.values.json
66
+ ? JSON.stringify(summary, null, 2)
67
+ : formatSummary(summary, { color: io.color === true }),
68
+ );
69
+ }
70
+ return failed ? 1 : 0;
71
+ }