@kenjura/ursa 0.85.0 → 0.87.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.
@@ -0,0 +1,529 @@
1
+ import { join } from "path";
2
+ import { mkdtemp, writeFile, rm, unlink, readFile } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import {
5
+ BuildGraph,
6
+ GraphComputeError,
7
+ fileNodeId,
8
+ lookupNodeId,
9
+ loadGraph,
10
+ saveGraph,
11
+ getGraphPath,
12
+ } from "../graph.js";
13
+
14
+ let tempDir;
15
+ beforeEach(async () => {
16
+ tempDir = await mkdtemp(join(tmpdir(), "ursa-graph-"));
17
+ });
18
+ afterEach(async () => {
19
+ await rm(tempDir, { recursive: true, force: true });
20
+ });
21
+
22
+ const p = (name) => join(tempDir, name);
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Basics
26
+ // ---------------------------------------------------------------------------
27
+ describe("basic compute and caching", () => {
28
+ it("computes a node from a file leaf and caches it", async () => {
29
+ await writeFile(p("a.txt"), "hello");
30
+ const graph = new BuildGraph();
31
+ let computeCount = 0;
32
+ graph.node("upper:a", async (ctx) => {
33
+ computeCount++;
34
+ return (await ctx.read(p("a.txt"))).toUpperCase();
35
+ });
36
+
37
+ const r1 = await graph.build(["upper:a"]);
38
+ expect(r1.ok).toBe(true);
39
+ expect(r1.results.get("upper:a")).toBe("HELLO");
40
+ expect(computeCount).toBe(1);
41
+
42
+ // No change, no invalidation → verified without recompute
43
+ const r2 = await graph.build(["upper:a"]);
44
+ expect(r2.computed.size).toBe(0);
45
+ expect(computeCount).toBe(1);
46
+ });
47
+
48
+ it("recomputes when a watched file changes (invalidatePath)", async () => {
49
+ await writeFile(p("a.txt"), "one");
50
+ const graph = new BuildGraph();
51
+ graph.node("val:a", (ctx) => ctx.read(p("a.txt")));
52
+
53
+ await graph.build(["val:a"]);
54
+ await writeFile(p("a.txt"), "two!");
55
+ graph.invalidatePath(p("a.txt"));
56
+
57
+ const r = await graph.build(["val:a"]);
58
+ expect(r.computed.has("val:a")).toBe(true);
59
+ expect(r.results.get("val:a")).toBe("two!");
60
+ });
61
+
62
+ it("does not recompute when mtime changes but content is identical", async () => {
63
+ await writeFile(p("a.txt"), "same");
64
+ const graph = new BuildGraph();
65
+ graph.node("val:a", (ctx) => ctx.read(p("a.txt")));
66
+ await graph.build(["val:a"]);
67
+
68
+ // Touch: rewrite identical content (new mtime, same hash)
69
+ await new Promise((resolve) => setTimeout(resolve, 10));
70
+ await writeFile(p("a.txt"), "same");
71
+ graph.invalidatePath(p("a.txt"));
72
+
73
+ const r = await graph.build(["val:a"]);
74
+ expect(r.computed.size).toBe(0);
75
+ });
76
+ });
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Diamond dependencies recompute once
80
+ // ---------------------------------------------------------------------------
81
+ describe("diamond dependencies", () => {
82
+ it("recomputes each node in a diamond exactly once", async () => {
83
+ await writeFile(p("a.txt"), "base");
84
+ const graph = new BuildGraph();
85
+ const counts = { b: 0, c: 0, d: 0 };
86
+ graph.node("b", async (ctx) => {
87
+ counts.b++;
88
+ return "b:" + (await ctx.read(p("a.txt")));
89
+ });
90
+ graph.node("c", async (ctx) => {
91
+ counts.c++;
92
+ return "c:" + (await ctx.read(p("a.txt")));
93
+ });
94
+ graph.node("d", async (ctx) => {
95
+ counts.d++;
96
+ return (await ctx.get("b")) + "|" + (await ctx.get("c"));
97
+ });
98
+
99
+ await graph.build(["d"]);
100
+ expect(counts).toEqual({ b: 1, c: 1, d: 1 });
101
+
102
+ await writeFile(p("a.txt"), "changed");
103
+ graph.invalidatePath(p("a.txt"));
104
+ const r = await graph.build(["d"]);
105
+ expect(counts).toEqual({ b: 2, c: 2, d: 2 });
106
+ expect(r.results.get("d")).toBe("b:changed|c:changed");
107
+ });
108
+ });
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Early cutoff
112
+ // ---------------------------------------------------------------------------
113
+ describe("early cutoff", () => {
114
+ it("stops propagation when a recomputed fingerprint is unchanged", async () => {
115
+ await writeFile(p("doc.md"), "title-line\nbody one");
116
+ const graph = new BuildGraph();
117
+ let projCount = 0;
118
+ let downstreamCount = 0;
119
+
120
+ // Projection node: only the first line (like docMeta extracting the title)
121
+ graph.node("firstLine:doc", async (ctx) => {
122
+ projCount++;
123
+ return (await ctx.read(p("doc.md"))).split("\n")[0];
124
+ });
125
+ graph.node("menuEntry:doc", async (ctx) => {
126
+ downstreamCount++;
127
+ return "MENU[" + (await ctx.get("firstLine:doc")) + "]";
128
+ });
129
+
130
+ await graph.build(["menuEntry:doc"]);
131
+ expect(projCount).toBe(1);
132
+ expect(downstreamCount).toBe(1);
133
+
134
+ // Edit only the body — projection recomputes, but its fingerprint is
135
+ // unchanged, so the menu entry must NOT recompute.
136
+ await writeFile(p("doc.md"), "title-line\nbody two (edited)");
137
+ graph.invalidatePath(p("doc.md"));
138
+ const r = await graph.build(["menuEntry:doc"]);
139
+ expect(projCount).toBe(2);
140
+ expect(downstreamCount).toBe(1);
141
+ expect(r.computed.has("menuEntry:doc")).toBe(false);
142
+
143
+ // Edit the title line — now propagation continues.
144
+ await writeFile(p("doc.md"), "new-title\nbody two (edited)");
145
+ graph.invalidatePath(p("doc.md"));
146
+ await graph.build(["menuEntry:doc"]);
147
+ expect(downstreamCount).toBe(2);
148
+ });
149
+ });
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Dynamic dependency change
153
+ // ---------------------------------------------------------------------------
154
+ describe("dynamic dependencies", () => {
155
+ it("drops old edges and records new ones when a dep choice changes", async () => {
156
+ await writeFile(p("config.txt"), "x");
157
+ await writeFile(p("x.txt"), "X content");
158
+ await writeFile(p("y.txt"), "Y content");
159
+ const graph = new BuildGraph();
160
+ let count = 0;
161
+ graph.node("page", async (ctx) => {
162
+ count++;
163
+ const which = (await ctx.read(p("config.txt"))).trim();
164
+ return await ctx.read(p(which + ".txt"));
165
+ });
166
+
167
+ const r1 = await graph.build(["page"]);
168
+ expect(r1.results.get("page")).toBe("X content");
169
+ expect(count).toBe(1);
170
+
171
+ // Change the file we currently depend on → recompute
172
+ await writeFile(p("x.txt"), "X content v2");
173
+ graph.invalidatePath(p("x.txt"));
174
+ await graph.build(["page"]);
175
+ expect(count).toBe(2);
176
+
177
+ // Switch the config to y → recompute; edge to x dropped, edge to y live
178
+ await writeFile(p("config.txt"), "y");
179
+ graph.invalidatePath(p("config.txt"));
180
+ const r2 = await graph.build(["page"]);
181
+ expect(r2.results.get("page")).toBe("Y content");
182
+ expect(count).toBe(3);
183
+
184
+ // x.txt is no longer a dependency → changing it must not recompute
185
+ await writeFile(p("x.txt"), "X content v3");
186
+ graph.invalidatePath(p("x.txt"));
187
+ await graph.build(["page"]);
188
+ expect(count).toBe(3);
189
+
190
+ // y.txt is now the live dependency
191
+ await writeFile(p("y.txt"), "Y content v2");
192
+ graph.invalidatePath(p("y.txt"));
193
+ const r3 = await graph.build(["page"]);
194
+ expect(r3.results.get("page")).toBe("Y content v2");
195
+ expect(count).toBe(4);
196
+ });
197
+ });
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Lookup nodes (existence probes)
201
+ // ---------------------------------------------------------------------------
202
+ describe("lookup nodes", () => {
203
+ it("creating a previously-absent file dirties the subtree", async () => {
204
+ const stylePath = p("style.css");
205
+ const graph = new BuildGraph();
206
+ let count = 0;
207
+ graph.node("css:dir", async (ctx) => {
208
+ count++;
209
+ if (ctx.exists(stylePath)) {
210
+ return await ctx.read(stylePath);
211
+ }
212
+ return "/* default */";
213
+ });
214
+
215
+ const r1 = await graph.build(["css:dir"]);
216
+ expect(r1.results.get("css:dir")).toBe("/* default */");
217
+ expect(count).toBe(1);
218
+
219
+ // Probe is recorded even though the file did not exist — creation fires it
220
+ await writeFile(stylePath, "body { color: red }");
221
+ graph.invalidatePath(stylePath);
222
+ const r2 = await graph.build(["css:dir"]);
223
+ expect(r2.results.get("css:dir")).toBe("body { color: red }");
224
+ expect(count).toBe(2);
225
+
226
+ // Deletion fires it too
227
+ await unlink(stylePath);
228
+ graph.invalidatePath(stylePath);
229
+ const r3 = await graph.build(["css:dir"]);
230
+ expect(r3.results.get("css:dir")).toBe("/* default */");
231
+ expect(count).toBe(3);
232
+ });
233
+
234
+ it("records a missing-file read so a later creation triggers recompute", async () => {
235
+ const path = p("maybe.txt");
236
+ const graph = new BuildGraph();
237
+ graph.node("reader", async (ctx) => {
238
+ try {
239
+ return await ctx.read(path);
240
+ } catch {
241
+ return "fallback";
242
+ }
243
+ });
244
+
245
+ const r1 = await graph.build(["reader"]);
246
+ expect(r1.results.get("reader")).toBe("fallback");
247
+
248
+ await writeFile(path, "now it exists");
249
+ graph.invalidatePath(path);
250
+ const r2 = await graph.build(["reader"]);
251
+ expect(r2.results.get("reader")).toBe("now it exists");
252
+ });
253
+ });
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Persistence round-trip
257
+ // ---------------------------------------------------------------------------
258
+ describe("persistence", () => {
259
+ function defineNodes(graph, counters) {
260
+ graph.node("b", async (ctx) => {
261
+ counters.b++;
262
+ return "b:" + (await ctx.read(p("a.txt")));
263
+ });
264
+ graph.node("c", async (ctx) => {
265
+ counters.c++;
266
+ return (await ctx.get("b")).toUpperCase();
267
+ });
268
+ }
269
+
270
+ it("round-trips through serialize/load with zero recomputes when nothing changed", async () => {
271
+ await writeFile(p("a.txt"), "persisted");
272
+ const g1 = new BuildGraph();
273
+ const counters1 = { b: 0, c: 0 };
274
+ defineNodes(g1, counters1);
275
+ await g1.build(["c"]);
276
+ expect(counters1).toEqual({ b: 1, c: 1 });
277
+
278
+ // Serialize → JSON → new process (fresh graph, fresh fn definitions)
279
+ const json = JSON.stringify(g1.serialize());
280
+ const g2 = new BuildGraph();
281
+ const counters2 = { b: 0, c: 0 };
282
+ defineNodes(g2, counters2);
283
+ expect(g2.load(JSON.parse(json))).toBe(true);
284
+
285
+ // Warm start: stat-scan leaves; nothing changed → no-op pass
286
+ const changed = await g2.scanLeaves();
287
+ expect(changed).toEqual([]);
288
+ const r = await g2.build(["c"]);
289
+ expect(r.ok).toBe(true);
290
+ expect(r.computed.size).toBe(0);
291
+ expect(counters2).toEqual({ b: 0, c: 0 });
292
+ });
293
+
294
+ it("after a warm start, a changed leaf recomputes only the affected chain", async () => {
295
+ await writeFile(p("a.txt"), "v1");
296
+ await writeFile(p("other.txt"), "other");
297
+ const g1 = new BuildGraph();
298
+ const counters1 = { b: 0, c: 0 };
299
+ defineNodes(g1, counters1);
300
+ g1.node("standalone", (ctx) => ctx.read(p("other.txt")));
301
+ await g1.build(["c", "standalone"]);
302
+
303
+ const json = JSON.stringify(g1.serialize());
304
+
305
+ // File changes while the process is down
306
+ await writeFile(p("a.txt"), "v2 longer");
307
+
308
+ const g2 = new BuildGraph();
309
+ const counters2 = { b: 0, c: 0 };
310
+ defineNodes(g2, counters2);
311
+ g2.node("standalone", (ctx) => ctx.read(p("other.txt")));
312
+ g2.load(JSON.parse(json));
313
+ const changed = await g2.scanLeaves();
314
+ expect(changed).toEqual([fileNodeId(p("a.txt"))]);
315
+
316
+ const r = await g2.build(["c", "standalone"]);
317
+ expect(r.computed.has("b")).toBe(true);
318
+ expect(r.computed.has("c")).toBe(true);
319
+ expect(r.computed.has("standalone")).toBe(false);
320
+ expect(r.results.get("c")).toBe("B:V2 LONGER");
321
+ });
322
+
323
+ it("is correct on warm start even without an explicit scanLeaves call", async () => {
324
+ await writeFile(p("a.txt"), "v1");
325
+ const g1 = new BuildGraph();
326
+ defineNodes(g1, { b: 0, c: 0 });
327
+ await g1.build(["c"]);
328
+ const json = JSON.stringify(g1.serialize());
329
+
330
+ await writeFile(p("a.txt"), "v2 changed offline");
331
+
332
+ const g2 = new BuildGraph();
333
+ const counters2 = { b: 0, c: 0 };
334
+ defineNodes(g2, counters2);
335
+ g2.load(JSON.parse(json));
336
+ // load() marks leaves stale, so verification re-stats them lazily
337
+ const r = await g2.build(["c"]);
338
+ expect(r.results.get("c")).toBe("B:V2 CHANGED OFFLINE");
339
+ });
340
+
341
+ it("rejects a stale schema version (clean pass instead)", () => {
342
+ const graph = new BuildGraph();
343
+ expect(graph.load({ version: 999, fingerprints: {}, edges: {} })).toBe(false);
344
+ expect(graph.load(null)).toBe(false);
345
+ expect(graph.load(undefined)).toBe(false);
346
+ expect(graph.edges.size).toBe(0);
347
+ });
348
+
349
+ it("saves to and loads from .ursa/graph.json", async () => {
350
+ await writeFile(p("a.txt"), "disk");
351
+ const g1 = new BuildGraph();
352
+ defineNodes(g1, { b: 0, c: 0 });
353
+ await g1.build(["c"]);
354
+ expect(await saveGraph(tempDir, g1)).toBe(true);
355
+ const onDisk = JSON.parse(await readFile(getGraphPath(tempDir), "utf8"));
356
+ expect(onDisk.version).toBe(1);
357
+
358
+ const g2 = new BuildGraph();
359
+ const counters2 = { b: 0, c: 0 };
360
+ defineNodes(g2, counters2);
361
+ expect(await loadGraph(tempDir, g2)).toBe(true);
362
+ await g2.scanLeaves();
363
+ const r = await g2.build(["c"]);
364
+ expect(r.computed.size).toBe(0);
365
+ });
366
+ });
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Failure handling
370
+ // ---------------------------------------------------------------------------
371
+ describe("failure handling", () => {
372
+ it("marks a throwing node failed without corrupting the graph, and retries", async () => {
373
+ await writeFile(p("a.txt"), "good");
374
+ const graph = new BuildGraph();
375
+ graph.node("validator", async (ctx) => {
376
+ const content = await ctx.read(p("a.txt"));
377
+ if (content.includes("bad")) throw new Error("validation failed");
378
+ return content;
379
+ });
380
+ graph.node("downstream", async (ctx) => "ok:" + (await ctx.get("validator")));
381
+
382
+ const r1 = await graph.build(["downstream"]);
383
+ expect(r1.ok).toBe(true);
384
+
385
+ // Break the input → node fails, error reported, old state intact
386
+ await writeFile(p("a.txt"), "bad data");
387
+ graph.invalidatePath(p("a.txt"));
388
+ const r2 = await graph.build(["downstream"]);
389
+ expect(r2.ok).toBe(false);
390
+ const err = r2.errors.get("downstream");
391
+ expect(err).toBeInstanceOf(GraphComputeError);
392
+ expect(err.nodeId).toBe("validator");
393
+ expect(graph.failed.has("validator")).toBe(true);
394
+ // Previous value/fingerprint preserved (graph not corrupted)
395
+ expect(graph.values.get("validator")).toBe("good");
396
+ expect(graph.values.get("downstream")).toBe("ok:good");
397
+
398
+ // Fix the input → failed node is retried and succeeds
399
+ await writeFile(p("a.txt"), "good again");
400
+ graph.invalidatePath(p("a.txt"));
401
+ const r3 = await graph.build(["downstream"]);
402
+ expect(r3.ok).toBe(true);
403
+ expect(r3.results.get("downstream")).toBe("ok:good again");
404
+ expect(graph.failed.size).toBe(0);
405
+ });
406
+
407
+ it("a failure in one root does not affect other roots", async () => {
408
+ await writeFile(p("a.txt"), "fine");
409
+ const graph = new BuildGraph();
410
+ graph.node("broken", () => {
411
+ throw new Error("always fails");
412
+ });
413
+ graph.node("healthy", (ctx) => ctx.read(p("a.txt")));
414
+
415
+ const r = await graph.build(["broken", "healthy"]);
416
+ expect(r.ok).toBe(false);
417
+ expect(r.errors.has("broken")).toBe(true);
418
+ expect(r.results.get("healthy")).toBe("fine");
419
+ });
420
+
421
+ it("retries a failed node on the next pass even with no input change", async () => {
422
+ let attempts = 0;
423
+ const graph = new BuildGraph();
424
+ graph.node("flaky", () => {
425
+ attempts++;
426
+ if (attempts < 2) throw new Error("transient");
427
+ return "recovered";
428
+ });
429
+
430
+ const r1 = await graph.build(["flaky"]);
431
+ expect(r1.ok).toBe(false);
432
+ const r2 = await graph.build(["flaky"]);
433
+ expect(r2.ok).toBe(true);
434
+ expect(r2.results.get("flaky")).toBe("recovered");
435
+ });
436
+ });
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // Misc engine behavior
440
+ // ---------------------------------------------------------------------------
441
+ describe("engine behavior", () => {
442
+ it("detects dependency cycles", async () => {
443
+ const graph = new BuildGraph();
444
+ graph.node("a", (ctx) => ctx.get("b"));
445
+ graph.node("b", (ctx) => ctx.get("a"));
446
+ const r = await graph.build(["a"]);
447
+ expect(r.ok).toBe(false);
448
+ expect(String(r.errors.get("a"))).toMatch(/cycle/i);
449
+ });
450
+
451
+ it("recomputes a node whose recorded dep was removed", async () => {
452
+ await writeFile(p("a.txt"), "x");
453
+ const graph = new BuildGraph();
454
+ graph.node("dep", (ctx) => ctx.read(p("a.txt")));
455
+ graph.node("parent", async (ctx) => {
456
+ const hasDep = graph.hasNode("dep");
457
+ return hasDep ? "with:" + (await ctx.get("dep")) : "alone";
458
+ });
459
+ await graph.build(["parent"]);
460
+
461
+ graph.removeNode("dep");
462
+ const r = await graph.build(["parent"]);
463
+ expect(r.results.get("parent")).toBe("alone");
464
+ });
465
+
466
+ it("gc drops state for deleted documents and orphaned leaves", async () => {
467
+ await writeFile(p("a.txt"), "a");
468
+ await writeFile(p("b.txt"), "b");
469
+ const graph = new BuildGraph();
470
+ graph.node("pageA", (ctx) => ctx.read(p("a.txt")));
471
+ graph.node("pageB", (ctx) => ctx.read(p("b.txt")));
472
+ await graph.build(["pageA", "pageB"]);
473
+
474
+ graph.gc(new Set(["pageA"]));
475
+ expect(graph.edges.has("pageB")).toBe(false);
476
+ expect(graph.fingerprints.has("pageB")).toBe(false);
477
+ expect(graph.fingerprints.has(fileNodeId(p("b.txt")))).toBe(false);
478
+ expect(graph.fingerprints.has(fileNodeId(p("a.txt")))).toBe(true);
479
+ });
480
+
481
+ it("demand() recomputes a clean node whose value is not in memory", async () => {
482
+ await writeFile(p("a.txt"), "val");
483
+ const g1 = new BuildGraph();
484
+ g1.node("n", (ctx) => ctx.read(p("a.txt")));
485
+ await g1.build(["n"]);
486
+ const json = JSON.stringify(g1.serialize());
487
+
488
+ const g2 = new BuildGraph();
489
+ let count = 0;
490
+ g2.node("n", async (ctx) => {
491
+ count++;
492
+ return await ctx.read(p("a.txt"));
493
+ });
494
+ g2.load(JSON.parse(json));
495
+ await g2.scanLeaves();
496
+
497
+ // Verified clean, but the value lives only in memory → demand recomputes
498
+ expect(await g2.demand("n")).toBe("val");
499
+ expect(count).toBe(1);
500
+ });
501
+
502
+ it("processes roots in the given order (priority scheduling)", async () => {
503
+ await writeFile(p("a.txt"), "x");
504
+ const order = [];
505
+ const graph = new BuildGraph();
506
+ graph.node("first", async (ctx) => {
507
+ order.push("first");
508
+ return await ctx.read(p("a.txt"));
509
+ });
510
+ graph.node("second", async (ctx) => {
511
+ order.push("second");
512
+ return await ctx.read(p("a.txt"));
513
+ });
514
+ await graph.build(["second", "first"]);
515
+ expect(order).toEqual(["second", "first"]);
516
+ });
517
+
518
+ it("exposes useful stats", async () => {
519
+ await writeFile(p("a.txt"), "x");
520
+ const graph = new BuildGraph();
521
+ graph.node("n", (ctx) => ctx.read(p("a.txt")));
522
+ await graph.build(["n"]);
523
+ const stats = graph.getStats();
524
+ expect(stats.derivedNodes).toBe(1);
525
+ expect(stats.leaves).toBe(1);
526
+ expect(stats.edges).toBe(1);
527
+ expect(stats.failed).toBe(0);
528
+ });
529
+ });
@@ -252,8 +252,14 @@ export async function generateAutoIndices(output, directories, source, templates
252
252
  let skippedHtmlCount = 0;
253
253
 
254
254
  for (const dir of outputDirs) {
255
+ // Skip output directories that were never created (the source folder
256
+ // produced no output files, e.g. it was empty)
257
+ if (!existsSync(dir)) {
258
+ continue;
259
+ }
260
+
255
261
  const indexPath = join(dir, 'index.html');
256
-
262
+
257
263
  // Skip if this directory had a source index.md/txt/yml that was already processed
258
264
  if (dirsWithSourceIndex.has(dir)) {
259
265
  continue;