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,465 @@
1
+ /**
2
+ * The lineage graph's shape, checked one node at a time.
3
+ *
4
+ * This is the format contract (technical spec §3) written as plain code
5
+ * rather than a JSON Schema file, so it needs no validator dependency and its
6
+ * messages can talk like a person. It only looks at nodes one by one. Rules
7
+ * that need several nodes (chain shape, cycles) or the HTML (bindings, bare
8
+ * numbers) live with `htmlineage check`.
9
+ *
10
+ * The split in severity follows one idea: an error means the graph cannot be
11
+ * read or composed as written; a warning means it can, but something a
12
+ * reviewer would want is missing.
13
+ */
14
+
15
+ import { exprProblem } from "./expr.js";
16
+ import { expectedPrefix, ID_PATTERN } from "./ids.js";
17
+
18
+ export const FORMAT_MAJOR = 1;
19
+
20
+ export const STEPS = ["source", "view", "figure", "insight"];
21
+
22
+ /** Every rule this module can raise, with its fixed severity. */
23
+ export const RULES = {
24
+ S1: { severity: "error", title: "graph header is malformed" },
25
+ S2: { severity: "error", title: "node id is not a valid identifier" },
26
+ S3: { severity: "error", title: "node step is missing or unknown" },
27
+ S4: { severity: "error", title: "field is missing or has the wrong type" },
28
+ S5: { severity: "warning", title: "unknown field" },
29
+ S6: { severity: "warning", title: "node id prefix does not match its step" },
30
+ S7: { severity: "warning", title: "descriptive field is missing" },
31
+ S8: { severity: "warning", title: "figure expression cannot be read" },
32
+ S9: {
33
+ severity: "warning",
34
+ title: "description is too long or not one line",
35
+ },
36
+ C4: {
37
+ severity: "error",
38
+ title: "node has no parents and is not marked ungrounded",
39
+ },
40
+ C9: {
41
+ severity: "error",
42
+ title: "figure needs exactly one of `sql` or `expr`",
43
+ },
44
+ };
45
+
46
+ const MAX_ROWS = 10;
47
+ const MAX_DESC = 120;
48
+ const DESC_HINT = "what this step computes and how, in words";
49
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
50
+ const ISO_TIMESTAMP =
51
+ /^(\d{4}-\d{2}-\d{2})T(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/;
52
+
53
+ const HEADER_FIELDS = new Set([
54
+ "lineage",
55
+ "report_id",
56
+ "built_at",
57
+ "builder_tool",
58
+ "nodes",
59
+ // Reserved for the review gate (technical spec §14). Accepted, unread.
60
+ "signature",
61
+ ]);
62
+
63
+ const COMMON_FIELDS = ["step", "transform"];
64
+ const DERIVED_FIELDS = [...COMMON_FIELDS, "from", "ungrounded"];
65
+
66
+ const FIELDS = {
67
+ sql: new Set([
68
+ ...COMMON_FIELDS,
69
+ "kind",
70
+ "desc",
71
+ "how",
72
+ "mart",
73
+ "period",
74
+ "sql",
75
+ "columns",
76
+ "rows",
77
+ ]),
78
+ external: new Set([
79
+ ...COMMON_FIELDS,
80
+ "kind",
81
+ "type",
82
+ "ref",
83
+ "given",
84
+ "columns",
85
+ "rows",
86
+ ]),
87
+ view: new Set([
88
+ ...DERIVED_FIELDS,
89
+ "desc",
90
+ "sql",
91
+ "columns",
92
+ "encoding",
93
+ "rows",
94
+ ]),
95
+ figure: new Set([
96
+ ...DERIVED_FIELDS,
97
+ "desc",
98
+ "sql",
99
+ "expr",
100
+ "value",
101
+ "display",
102
+ "label",
103
+ ]),
104
+ insight: new Set([...DERIVED_FIELDS, "text"]),
105
+ };
106
+
107
+ const ENCODING_FIELDS = new Set(["x", "y", "series", "visual"]);
108
+
109
+ const isObject = (value) =>
110
+ value !== null && typeof value === "object" && !Array.isArray(value);
111
+ const isString = (value) => typeof value === "string";
112
+ const isNonEmptyString = (value) => isString(value) && value.trim() !== "";
113
+ const isStringArray = (value) => Array.isArray(value) && value.every(isString);
114
+
115
+ function describe(value) {
116
+ if (value === null) return "null";
117
+ if (Array.isArray(value)) return "a list";
118
+ return `a ${typeof value}`;
119
+ }
120
+
121
+ /**
122
+ * Validate a parsed graph. Never throws on bad input; returns a flat list of
123
+ * issues, each `{ code, severity, node, field, message }`. `node` is null for
124
+ * header problems. An empty list means the shape is sound.
125
+ */
126
+ export function validateGraph(graph) {
127
+ const issues = [];
128
+ const report = (code, node, field, message) =>
129
+ issues.push({
130
+ code,
131
+ severity: RULES[code].severity,
132
+ node,
133
+ field: field ?? null,
134
+ message,
135
+ });
136
+
137
+ if (!isObject(graph)) {
138
+ report("S1", null, null, `graph must be an object, got ${describe(graph)}`);
139
+ return issues;
140
+ }
141
+
142
+ checkHeader(graph, report);
143
+ if (!isObject(graph.nodes)) return issues;
144
+
145
+ for (const [id, node] of Object.entries(graph.nodes)) {
146
+ checkNode(id, node, report);
147
+ }
148
+ return issues;
149
+ }
150
+
151
+ function checkHeader(graph, report) {
152
+ const version = graph.lineage;
153
+ if (version === undefined) {
154
+ report("S1", null, "lineage", "missing `lineage` format version");
155
+ } else if (!isString(version) || !/^\d+\.\d+$/.test(version)) {
156
+ report(
157
+ "S1",
158
+ null,
159
+ "lineage",
160
+ `\`lineage\` must be a version string like "1.0", got ${JSON.stringify(version)}`,
161
+ );
162
+ } else if (Number(version.split(".")[0]) !== FORMAT_MAJOR) {
163
+ report(
164
+ "S1",
165
+ null,
166
+ "lineage",
167
+ `format version ${version} is not supported; this build reads ${FORMAT_MAJOR}.x`,
168
+ );
169
+ }
170
+
171
+ if (!isObject(graph.nodes)) {
172
+ report(
173
+ "S1",
174
+ null,
175
+ "nodes",
176
+ graph.nodes === undefined
177
+ ? "missing `nodes`"
178
+ : `\`nodes\` must be an object keyed by id, got ${describe(graph.nodes)}`,
179
+ );
180
+ }
181
+
182
+ for (const field of ["report_id", "builder_tool"]) {
183
+ if (graph[field] !== undefined && !isNonEmptyString(graph[field])) {
184
+ report("S1", null, field, `\`${field}\` must be a non-empty string`);
185
+ }
186
+ }
187
+ if (graph.built_at !== undefined && !isIsoTimestamp(graph.built_at)) {
188
+ report("S1", null, "built_at", "`built_at` must be an ISO timestamp");
189
+ }
190
+
191
+ for (const field of Object.keys(graph)) {
192
+ if (!HEADER_FIELDS.has(field)) {
193
+ report("S5", null, field, `unknown top-level field \`${field}\``);
194
+ }
195
+ }
196
+ }
197
+
198
+ function checkNode(id, node, report) {
199
+ if (!ID_PATTERN.test(id)) {
200
+ report(
201
+ "S2",
202
+ id,
203
+ null,
204
+ `\`${id}\` is not a valid id: use lowercase letters, digits and _, starting with a letter`,
205
+ );
206
+ }
207
+
208
+ if (!isObject(node)) {
209
+ report("S4", id, null, `node must be an object, got ${describe(node)}`);
210
+ return;
211
+ }
212
+
213
+ if (!STEPS.includes(node.step)) {
214
+ report(
215
+ "S3",
216
+ id,
217
+ "step",
218
+ node.step === undefined
219
+ ? "missing `step`"
220
+ : `unknown step ${JSON.stringify(node.step)}; expected one of ${STEPS.join(", ")}`,
221
+ );
222
+ return;
223
+ }
224
+
225
+ // Everything below knows the step. Missing and mistyped fields get S4;
226
+ // they are what makes a node unreadable or uncomposable.
227
+ const need = (field, test, what) => {
228
+ if (node[field] === undefined) {
229
+ report("S4", id, field, `${node.step} is missing \`${field}\``);
230
+ return false;
231
+ }
232
+ if (!test(node[field])) {
233
+ report("S4", id, field, `\`${field}\` must be ${what}`);
234
+ return false;
235
+ }
236
+ return true;
237
+ };
238
+ const may = (field, test, what) => {
239
+ if (node[field] !== undefined && !test(node[field])) {
240
+ report("S4", id, field, `\`${field}\` must be ${what}`);
241
+ }
242
+ };
243
+ const want = (field, what) => {
244
+ const value = node[field];
245
+ if (value === undefined || (isString(value) && value.trim() === "")) {
246
+ report("S7", id, field, `${node.step} has no \`${field}\` (${what})`);
247
+ }
248
+ };
249
+
250
+ may("transform", isString, "a string");
251
+
252
+ let allowed;
253
+ if (node.step === "source") {
254
+ allowed = checkSource(node, need, may, want);
255
+ } else {
256
+ allowed = FIELDS[node.step];
257
+ checkDerived(id, node, need, may, report);
258
+ if (node.step === "view") checkView(node, need, may, want);
259
+ if (node.step === "figure") checkFigure(id, node, may, want, report);
260
+ if (node.step === "insight")
261
+ need("text", isNonEmptyString, "the claim, as text");
262
+ }
263
+
264
+ if (allowed?.has("desc")) {
265
+ may("desc", isString, "a string");
266
+ if (node.ungrounded !== true) want("desc", DESC_HINT);
267
+ const problem = isString(node.desc) && descProblem(node.desc);
268
+ if (problem) report("S9", id, "desc", problem);
269
+ }
270
+
271
+ if (allowed) {
272
+ for (const field of Object.keys(node)) {
273
+ if (!allowed.has(field)) {
274
+ report("S5", id, field, `unknown field \`${field}\` on a ${node.step}`);
275
+ }
276
+ }
277
+ }
278
+
279
+ const prefix = expectedPrefix(node);
280
+ if (prefix && ID_PATTERN.test(id) && !id.startsWith(prefix)) {
281
+ report(
282
+ "S6",
283
+ id,
284
+ null,
285
+ `${describeKind(node)} ids conventionally start with \`${prefix}\`, e.g. ${prefix}1`,
286
+ );
287
+ }
288
+ }
289
+
290
+ /** Why a `desc` is not one short line, or null. Length wins over a break. */
291
+ function descProblem(desc) {
292
+ const length = [...desc].length;
293
+ const rule = `keep it to one line of ${MAX_DESC} or fewer`;
294
+ if (length > MAX_DESC) return `\`desc\` is ${length} characters; ${rule}`;
295
+ if (/[\r\n]/.test(desc)) return `\`desc\` has a line break; ${rule}`;
296
+ return null;
297
+ }
298
+
299
+ function describeKind(node) {
300
+ if (node.step === "source") {
301
+ return node.kind === "external" ? "external source" : "sql source";
302
+ }
303
+ return node.step;
304
+ }
305
+
306
+ /** Returns the allowed field set, or null when `kind` is unknown. */
307
+ function checkSource(node, need, may, want) {
308
+ if (
309
+ !need("kind", (k) => k === "sql" || k === "external", '"sql" or "external"')
310
+ ) {
311
+ return null;
312
+ }
313
+ may("columns", isStringArray, "a list of column names");
314
+ may(
315
+ "rows",
316
+ isRows,
317
+ `columnar rows: { column: [values] }, at most ${MAX_ROWS} each`,
318
+ );
319
+
320
+ if (node.kind === "sql") {
321
+ // mart, period, sql and columns are all expected on a sql source, but
322
+ // their absence is rule C7, which `check` reports. Here only their types.
323
+ may(
324
+ "how",
325
+ (h) => h === "captured" || h === "reported",
326
+ '"captured" or "reported"',
327
+ );
328
+ may("mart", isNonEmptyString, "a mart name");
329
+ may("sql", isNonEmptyString, "the query as text");
330
+ may(
331
+ "period",
332
+ isPeriod,
333
+ 'an object like { "from": "2026-01-01", "to": "2026-08-31" }',
334
+ );
335
+ return FIELDS.sql;
336
+ }
337
+
338
+ may("type", isString, "a string");
339
+ may("ref", isString, "a string");
340
+ may("given", isString, "a string");
341
+ want("type", "what kind of thing it is, e.g. csv");
342
+ want("ref", "how a reader can find it");
343
+ want("given", "what the model was handed, in words");
344
+ return FIELDS.external;
345
+ }
346
+
347
+ function checkDerived(id, node, need, may, report) {
348
+ may("ungrounded", (u) => typeof u === "boolean", "true or false");
349
+ if (!need("from", isStringArray, "a list of node ids")) return;
350
+ if (node.from.length === 0 && node.ungrounded !== true) {
351
+ report(
352
+ "C4",
353
+ id,
354
+ "from",
355
+ "`from` is empty; list the parents, or set `ungrounded: true` if there are none",
356
+ );
357
+ }
358
+ }
359
+
360
+ function checkView(node, need, may, want) {
361
+ if (node.ungrounded === true)
362
+ may("sql", isNonEmptyString, "the query as text");
363
+ else need("sql", isNonEmptyString, "the query as text");
364
+ may("columns", isStringArray, "a list of column names");
365
+ may(
366
+ "rows",
367
+ isRows,
368
+ `columnar rows: { column: [values] }, at most ${MAX_ROWS} each`,
369
+ );
370
+ may(
371
+ "encoding",
372
+ isEncoding,
373
+ "an object with any of x, y, series, visual as strings",
374
+ );
375
+ want("columns", "the output column names");
376
+ }
377
+
378
+ function checkFigure(id, node, may, want, report) {
379
+ may(
380
+ "value",
381
+ (v) => v === null || typeof v === "number" || isString(v),
382
+ "a number or a string",
383
+ );
384
+ may("display", isString, "the text exactly as shown on the page");
385
+ may("label", isString, "a string");
386
+ may("sql", isNonEmptyString, "a query returning one cell");
387
+ may("expr", isNonEmptyString, "a SQL expression over figure ids");
388
+
389
+ const hasSql = node.sql !== undefined;
390
+ const hasExpr = node.expr !== undefined;
391
+ if (hasSql && hasExpr) {
392
+ report(
393
+ "C9",
394
+ id,
395
+ null,
396
+ "figure has both `sql` and `expr`; keep exactly one",
397
+ );
398
+ } else if (!hasSql && !hasExpr && node.ungrounded !== true) {
399
+ report(
400
+ "C9",
401
+ id,
402
+ null,
403
+ "figure has neither `sql` nor `expr`; add one, or set `ungrounded: true`",
404
+ );
405
+ }
406
+
407
+ if (hasExpr && isNonEmptyString(node.expr)) {
408
+ const problem = exprProblem(node.expr);
409
+ if (problem) {
410
+ report(
411
+ "S8",
412
+ id,
413
+ "expr",
414
+ `cannot read \`${node.expr}\` at character ${problem.position + 1}: ${problem.message}`,
415
+ );
416
+ }
417
+ }
418
+
419
+ want("value", "the number or name the page shows");
420
+ want("display", "the text exactly as shown on the page");
421
+ want("label", "what the number is, in words");
422
+ }
423
+
424
+ function isPeriod(value) {
425
+ return (
426
+ isObject(value) &&
427
+ Object.keys(value).every((k) => k === "from" || k === "to") &&
428
+ isIsoDate(value.from) &&
429
+ isIsoDate(value.to) &&
430
+ value.from <= value.to
431
+ );
432
+ }
433
+
434
+ function isIsoDate(value) {
435
+ if (!isString(value) || !ISO_DATE.test(value)) return false;
436
+ const date = new Date(`${value}T00:00:00Z`);
437
+ return !Number.isNaN(date.getTime()) && date.toISOString().startsWith(value);
438
+ }
439
+
440
+ /** `2026-09-16T14:20:00Z` or with an offset; the date part must exist. */
441
+ function isIsoTimestamp(value) {
442
+ const match = isString(value) && ISO_TIMESTAMP.exec(value);
443
+ return Boolean(match) && isIsoDate(match[1]);
444
+ }
445
+
446
+ function isRows(value) {
447
+ if (value === null) return true;
448
+ if (!isObject(value)) return false;
449
+ const lengths = Object.values(value).map((column) =>
450
+ Array.isArray(column) ? column.length : -1,
451
+ );
452
+ return (
453
+ lengths.every((n) => n >= 0 && n <= MAX_ROWS) &&
454
+ lengths.every((n) => n === lengths[0])
455
+ );
456
+ }
457
+
458
+ function isEncoding(value) {
459
+ return (
460
+ isObject(value) &&
461
+ Object.entries(value).every(
462
+ ([key, field]) => ENCODING_FIELDS.has(key) && isString(field),
463
+ )
464
+ );
465
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * How far each node traces back to a SQL source. Pure: no file I/O.
3
+ */
4
+
5
+ import { STEPS } from "./schema.js";
6
+
7
+ const isObject = (value) =>
8
+ value !== null && typeof value === "object" && !Array.isArray(value);
9
+
10
+ const isNode = (node) => isObject(node) && STEPS.includes(node.step);
11
+
12
+ /**
13
+ * @param {unknown} graph as from `readGraph` (may be malformed)
14
+ * @returns {Map<string, "full" | "partial" | "ungrounded">} one entry per
15
+ * object node with a known step
16
+ */
17
+ export function traceGraph(graph) {
18
+ const states = new Map();
19
+ if (!isObject(graph) || !isObject(graph.nodes)) return states;
20
+ const nodes = graph.nodes;
21
+ const walking = new Set();
22
+ const resolve = (id) => {
23
+ if (!Object.hasOwn(nodes, id) || !isNode(nodes[id])) return "partial";
24
+ if (states.has(id)) return states.get(id);
25
+ if (walking.has(id)) return "partial";
26
+ walking.add(id);
27
+ const node = nodes[id];
28
+ let state = "partial";
29
+ if (node.ungrounded === true) state = "ungrounded";
30
+ else if (node.step === "source") {
31
+ if (node.kind === "sql") state = "full";
32
+ } else if (
33
+ Array.isArray(node.from) &&
34
+ node.from.length > 0 &&
35
+ node.from.every((parent) => resolve(parent) === "full")
36
+ ) {
37
+ state = "full";
38
+ }
39
+ walking.delete(id);
40
+ states.set(id, state);
41
+ return state;
42
+ };
43
+ for (const id of Object.keys(nodes)) resolve(id);
44
+ return states;
45
+ }
package/src/help.js ADDED
@@ -0,0 +1,25 @@
1
+ import { commands } from "./commands/index.js";
2
+ import { packageMeta } from "./meta.js";
3
+
4
+ export function topLevelHelp() {
5
+ const width = Math.max(...commands.map((c) => c.name.length));
6
+ const lines = commands.map((c) => ` ${c.name.padEnd(width)} ${c.summary}`);
7
+ return `htmlineage — a provenance layer for AI-generated reports
8
+
9
+ Usage
10
+ htmlineage <command> [options]
11
+
12
+ Commands
13
+ ${lines.join("\n")}
14
+
15
+ Options
16
+ -h, --help Show this help, or help for a command
17
+ -v, --version Print the version
18
+
19
+ Run \`htmlineage <command> --help\` for the options of one command.`;
20
+ }
21
+
22
+ export function versionLine() {
23
+ const { name, version } = packageMeta();
24
+ return `${name} ${version}`;
25
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Embedding a lineage graph into a report.
3
+ *
4
+ * This is the one place `check` changes a user's file. It edits by string
5
+ * surgery (inserts and in-place replacements), never reserializes the page,
6
+ * and writes via a temp file and a rename.
7
+ */
8
+
9
+ import {
10
+ chmodSync,
11
+ realpathSync,
12
+ renameSync,
13
+ statSync,
14
+ unlinkSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { basename, dirname, join } from "node:path";
18
+
19
+ import { HtmlineageError } from "../errors.js";
20
+ import {
21
+ GRAPH_ELEMENT_ID,
22
+ GRAPH_MIME,
23
+ findEmbeddedGraph,
24
+ serializeForEmbed,
25
+ } from "../graph/parse.js";
26
+
27
+ const META_TAG = '<meta name="lineage" content="1.0">';
28
+ const OPEN_TAG = `<script type="${GRAPH_MIME}" id="${GRAPH_ELEMENT_ID}">`;
29
+
30
+ const BODY_CLOSE = /<\/body\s*>/gi;
31
+ const HEAD_CLOSE = /<\/head\s*>/gi;
32
+ const META_OPEN = /<meta\b[^>]*>/gi;
33
+ const LINEAGE_NAME = /\sname\s*=\s*(["']?)lineage\1(?=[\s/>]|$)/i;
34
+
35
+ /**
36
+ * Put `graph` into `html`: the lineage meta tag in <head> and the graph
37
+ * block before </body>, each replacing an existing one where it stands.
38
+ * Returns the new HTML; returns `html` unchanged when both are already current.
39
+ * Throws HtmlineageError when the page has no </body> or no </head>, or when
40
+ * findEmbeddedGraph() rejects it (two blocks, an unclosed block).
41
+ * @param {string} html
42
+ * @param {object} graph embedded as written; nothing is added to it
43
+ * @returns {string}
44
+ */
45
+ export function embedGraph(html, graph) {
46
+ // Found first so that lookalike text inside an existing (unescaped)
47
+ // block's JSON never gets mistaken for a real </body>, </head> or
48
+ // lineage <meta>: those matches are ignored when they fall inside it.
49
+ const block = findEmbeddedGraph(html);
50
+ const insideBlock = (index) =>
51
+ block !== null && index >= block.start && index < block.end;
52
+
53
+ let bodyEnd = -1;
54
+ for (const match of html.matchAll(BODY_CLOSE)) {
55
+ if (insideBlock(match.index)) continue;
56
+ bodyEnd = match.index;
57
+ }
58
+ if (bodyEnd === -1) {
59
+ throw new HtmlineageError(
60
+ "the report has no </body> tag, so the lineage graph was not embedded",
61
+ );
62
+ }
63
+
64
+ let headEnd = -1;
65
+ for (const match of html.matchAll(HEAD_CLOSE)) {
66
+ if (insideBlock(match.index)) continue;
67
+ headEnd = match.index;
68
+ break;
69
+ }
70
+ if (headEnd === -1) {
71
+ throw new HtmlineageError(
72
+ "the report has no </head> tag, so the lineage graph was not embedded",
73
+ );
74
+ }
75
+
76
+ const blockText = `${OPEN_TAG}${serializeForEmbed(graph)}</script>`;
77
+
78
+ const edits = [];
79
+ if (block) {
80
+ edits.push({ start: block.start, end: block.end, text: blockText });
81
+ } else {
82
+ edits.push({ start: bodyEnd, end: bodyEnd, text: `${blockText}\n` });
83
+ }
84
+
85
+ let metaEdit = null;
86
+ for (const match of html.slice(0, headEnd).matchAll(META_OPEN)) {
87
+ if (insideBlock(match.index)) continue;
88
+ if (LINEAGE_NAME.test(match[0])) {
89
+ metaEdit = { start: match.index, end: match.index + match[0].length };
90
+ break;
91
+ }
92
+ }
93
+ if (metaEdit) {
94
+ edits.push({ start: metaEdit.start, end: metaEdit.end, text: META_TAG });
95
+ } else {
96
+ edits.push({ start: headEnd, end: headEnd, text: `${META_TAG}\n` });
97
+ }
98
+
99
+ edits.sort((a, b) => b.start - a.start);
100
+
101
+ let out = html;
102
+ for (const edit of edits) {
103
+ out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /**
109
+ * Embed `graph` into the report at `reportPath`, whose current text is
110
+ * `html`. Writes only when the text changes, through a temp file and a
111
+ * rename, so a crash cannot leave a half-written report. A symlinked report
112
+ * is resolved first, so the file it points to is the one updated and the
113
+ * link stays a link; the new file keeps the report's permissions.
114
+ * @returns {boolean} true when the file was written
115
+ */
116
+ export function embedFile(reportPath, html, graph) {
117
+ const next = embedGraph(html, graph);
118
+ if (next === html) return false;
119
+
120
+ let temp = null;
121
+ try {
122
+ const target = realpathSync(reportPath);
123
+ const mode = statSync(target).mode & 0o7777;
124
+ temp = join(dirname(target), `.${basename(target)}.${process.pid}.tmp`);
125
+ writeFileSync(temp, next, { encoding: "utf8", mode });
126
+ // The umask may have narrowed `mode` on create; set it exactly.
127
+ chmodSync(temp, mode);
128
+ renameSync(temp, target);
129
+ } catch (error) {
130
+ if (temp !== null) {
131
+ try {
132
+ unlinkSync(temp);
133
+ } catch {
134
+ // best effort
135
+ }
136
+ }
137
+ throw new HtmlineageError(
138
+ `could not write ${reportPath}: ${error.message}`,
139
+ );
140
+ }
141
+ return true;
142
+ }