ddduck 0.1.0 → 0.1.2

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.
@@ -1,3 +1,13 @@
1
+ /**
2
+ * The locked, staged mutation runner behind every product-changing ddduck
3
+ * command (generate, create/move/split/retire). Holds the invariants: one
4
+ * operation at a time per product root (.ddduck-operation.lock, reclaimable
5
+ * only when its owner PID is dead), all edits validated in a staging copy
6
+ * before any canonical or generated file is published, and canonical YAML
7
+ * rewritten comment-preservingly. Also exports the canonical generatedPaths
8
+ * list and the busy/leftover-state probes used by check and query.
9
+ */
10
+
1
11
  import { spawnSync } from "node:child_process";
2
12
  import {
3
13
  closeSync,
@@ -16,10 +26,11 @@ import {
16
26
  } from "node:fs";
17
27
  import path from "node:path";
18
28
  import { fileURLToPath } from "node:url";
19
- import { stringify } from "yaml";
29
+ import { isScalar, parseDocument, stringify } from "yaml";
20
30
  import { checkGeneratedDocs } from "../check-generated-docs.mjs";
21
31
  import { checkGeneratedGraph } from "../check-generated-graph.mjs";
22
32
  import { validateProduct } from "../check-model.mjs";
33
+ import { shellQuote } from "./context-pack.mjs";
23
34
  import { writeModelOverview } from "../generate-docs.mjs";
24
35
  import { writeModelGraph } from "../generate-graph.mjs";
25
36
  import { detectProductLayout, loadProductSnapshot } from "./product-layout.mjs";
@@ -29,19 +40,23 @@ import { nonProductSourceEntries } from "./product-root-resolver.mjs";
29
40
  const lockRelativePath = ".ddduck-operation.lock";
30
41
  const reclaimRelativePath = ".ddduck-operation.reclaim";
31
42
  const stagingPrefix = ".ddduck-operation-stage-";
32
- const generatedPaths = Object.freeze([
43
+ // The canonical generated views: one list consumed by mutation results, query
44
+ // freshness, the check gate, and the docs. The SVG is rendered by the Graphviz
45
+ // WASM engine, which is async, so we produce it in a child process to keep this
46
+ // runner synchronous (mirrors the check subprocess).
47
+ export const generatedPaths = Object.freeze([
33
48
  "generated/docs/model-overview.md",
34
49
  "generated/graph/model-graph.json",
35
50
  "generated/graph/model-graph.ndjson",
51
+ "generated/graph/model-graph.svg",
36
52
  ]);
37
- // The canonical SVG is published atomically alongside the reported outputs, but
38
- // kept off `generatedPaths` so it stays out of the CLI/query freshness contract.
39
- // It is rendered by the Graphviz WASM engine, which is async, so we produce it in
40
- // a child process to keep this runner synchronous (mirrors the check subprocess).
41
- const auxiliaryGeneratedPaths = Object.freeze(["generated/graph/model-graph.svg"]);
42
53
  const svgGeneratorScript = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "generate-graph-svg.mjs");
43
54
  const excludedSourceEntries = new Set([...nonProductSourceEntries, lockRelativePath, reclaimRelativePath]);
44
55
 
56
+ /**
57
+ * Raised when a product root's operation lock is held; carries exitCode 2 when
58
+ * the holder is a live process (the one retryable failure).
59
+ */
45
60
  export class ProductBusyError extends Error {
46
61
  constructor(root, lockPath, ownerPid) {
47
62
  const holder = ownerPid ? ` (held by running process ${ownerPid})` : "";
@@ -50,9 +65,22 @@ export class ProductBusyError extends Error {
50
65
  this.nextAction = ownerPid
51
66
  ? `Wait for process ${ownerPid} to finish and retry, or delete ${lockPath} and retry if that process is not a ddduck operation.`
52
67
  : `If no other ddduck operation is running on this product, delete ${lockPath} and retry.`;
68
+ // Contention with a running ddduck operation is the one retryable failure;
69
+ // exit code 2 lets callers retry it without string-matching stderr. Every
70
+ // other failure, including a lock without a live owner, stays exit 1.
71
+ if (ownerPid) this.exitCode = 2;
53
72
  }
54
73
  }
55
74
 
75
+ /**
76
+ * Run one mutation against a product root: acquire the lock, sweep dead
77
+ * reclaim claims and orphaned staging, copy the source into staging, apply the
78
+ * transform's replacement plan, validate the staged product, regenerate every
79
+ * generated view (SVG via subprocess), then publish canonical and generated
80
+ * files back file-by-file. The lock and staging are always cleaned up.
81
+ * @param {{root: string, transform: (snapshot: {nodes: object[], canonicalPaths: Record<string, string>}) => {operation: string, affectedIds: string[], replacements: {path: string, value: object}[]}}} params - Product root and plan-building transform.
82
+ * @returns {{operation: string, root: string, affectedIds: string[], canonicalPaths: string[], generatedPaths: string[]}} The published operation result.
83
+ */
56
84
  export function runProductOperation({ root, transform }) {
57
85
  if (typeof transform !== "function") throw new TypeError("product operation requires a transform function");
58
86
  const normalizedRoot = realpathSync(path.resolve(root));
@@ -60,9 +88,6 @@ export function runProductOperation({ root, transform }) {
60
88
  const publishGeneratedTargets = generatedPaths.map((relativePath) =>
61
89
  resolveContainedOutput(normalizedRoot, relativePath),
62
90
  );
63
- const publishAuxiliaryTargets = auxiliaryGeneratedPaths.map((relativePath) =>
64
- resolveContainedOutput(normalizedRoot, relativePath),
65
- );
66
91
  let lockAcquired = false;
67
92
  let stagingRoot;
68
93
 
@@ -77,7 +102,7 @@ export function runProductOperation({ root, transform }) {
77
102
  const snapshot = loadProductSnapshot(detectProductLayout(stagingRoot));
78
103
  const plan = validatePlan(transform(snapshot));
79
104
  const canonicalPaths = applyReplacements(stagingRoot, plan.replacements);
80
- validateStagedProduct(stagingRoot);
105
+ validateStagedProduct(stagingRoot, normalizedRoot);
81
106
  writeModelOverview(stagingRoot);
82
107
  writeModelGraph(stagingRoot);
83
108
  renderModelGraphSvg(stagingRoot);
@@ -96,10 +121,6 @@ export function runProductOperation({ root, transform }) {
96
121
  relativePath,
97
122
  targetPath: publishGeneratedTargets[index],
98
123
  })),
99
- ...auxiliaryGeneratedPaths.map((relativePath, index) => ({
100
- relativePath,
101
- targetPath: publishAuxiliaryTargets[index],
102
- })),
103
124
  ]);
104
125
  for (let index = 0; index < canonicalPaths.length; index += 1) {
105
126
  copyPublishedFile(stagingRoot, canonicalPaths[index], publishCanonicalTargets[index]);
@@ -107,9 +128,6 @@ export function runProductOperation({ root, transform }) {
107
128
  for (let index = 0; index < generatedPaths.length; index += 1) {
108
129
  copyPublishedFile(stagingRoot, generatedPaths[index], publishGeneratedTargets[index]);
109
130
  }
110
- for (let index = 0; index < auxiliaryGeneratedPaths.length; index += 1) {
111
- copyPublishedFile(stagingRoot, auxiliaryGeneratedPaths[index], publishAuxiliaryTargets[index]);
112
- }
113
131
 
114
132
  return {
115
133
  operation: plan.operation,
@@ -278,7 +296,12 @@ function readLockOwner(lockPath) {
278
296
  return { state: "unknown" };
279
297
  }
280
298
 
281
- function isProcessAlive(pid) {
299
+ /**
300
+ * Probe whether a PID belongs to a live process (signal 0; EPERM counts as alive).
301
+ * @param {number} pid - Process ID recorded in a lock, claim, or init stage name.
302
+ * @returns {boolean} True unless the process is definitely gone (ESRCH).
303
+ */
304
+ export function isProcessAlive(pid) {
282
305
  try {
283
306
  process.kill(pid, 0);
284
307
  return true;
@@ -310,6 +333,12 @@ function sweepOrphanedStaging(root) {
310
333
  }
311
334
  }
312
335
 
336
+ /**
337
+ * Throw ProductBusyError if the product's operation lock is held by a live
338
+ * process; used by read paths (check, query) that must not run mid-mutation.
339
+ * @param {string} root - Product root path.
340
+ * @returns {void}
341
+ */
313
342
  export function assertProductNotBusy(root) {
314
343
  const normalizedRoot = realpathSync(path.resolve(root));
315
344
  const lockPath = path.join(normalizedRoot, lockRelativePath);
@@ -319,6 +348,13 @@ export function assertProductNotBusy(root) {
319
348
  }
320
349
  }
321
350
 
351
+ /**
352
+ * List interrupted-operation debris (ownerless lock, dead reclaim claims,
353
+ * staging directories) that a future operation would reclaim; live locks are
354
+ * not leftovers.
355
+ * @param {string} root - Product root path.
356
+ * @returns {{root: string, entries: string[]}|null} The leftover entries, or null when the root is clean.
357
+ */
322
358
  export function findLeftoverOperationState(root) {
323
359
  const normalizedRoot = realpathSync(path.resolve(root));
324
360
  const lockPath = path.join(normalizedRoot, lockRelativePath);
@@ -400,13 +436,56 @@ function applyReplacements(stagingRoot, replacements) {
400
436
  }
401
437
  const target = resolveContainedOutput(stagingRoot, relativePath);
402
438
  mkdirSync(path.dirname(target), { recursive: true });
403
- writeFileSync(target, stringify(replacement.value));
439
+ writeFileSync(target, serializeReplacement(target, relativePath, replacement.value));
404
440
  seenPaths.add(relativePath);
405
441
  canonicalPaths.push(relativePath);
406
442
  }
407
443
  return canonicalPaths.sort();
408
444
  }
409
445
 
446
+ /**
447
+ * Rewrite an existing canonical file by editing its parsed YAML document key by
448
+ * key instead of re-serializing the plan value wholesale, so authored comments
449
+ * and formatting outside the keys a mutation actually changes survive. New
450
+ * files have no authored content to preserve and take the plain serialization.
451
+ * Staged validation still covers the exact published bytes: this runs before
452
+ * validateStagedProduct, and publication copies these staged bytes verbatim.
453
+ * @param {string} target - Absolute staged path of the canonical file.
454
+ * @param {string} relativePath - Root-relative canonical path (for diagnostics).
455
+ * @param {object} value - The replacement YAML mapping from the operation plan.
456
+ * @returns {string} The staged file's new YAML source.
457
+ */
458
+ function serializeReplacement(target, relativePath, value) {
459
+ let source;
460
+ try {
461
+ source = readFileSync(target, "utf8");
462
+ } catch (error) {
463
+ if (error.code !== "ENOENT") throw error;
464
+ return stringify(value);
465
+ }
466
+ const document = parseDocument(source, { keepSourceTokens: true, strict: true, uniqueKeys: true });
467
+ if (document.errors.length > 0) {
468
+ throw new Error(
469
+ `invalid YAML in canonical replacement ${relativePath}: ${document.errors.map((error) => error.message).join("; ")}`,
470
+ );
471
+ }
472
+ const current = document.toJSON();
473
+ if (current === null || typeof current !== "object" || Array.isArray(current)) return stringify(value);
474
+ for (const [key, next] of Object.entries(value)) {
475
+ if (key in current && JSON.stringify(current[key]) === JSON.stringify(next)) continue;
476
+ const existing = document.get(key, true);
477
+ if (isScalar(existing) && (next === null || typeof next !== "object")) {
478
+ existing.value = next;
479
+ } else {
480
+ document.set(key, document.createNode(next));
481
+ }
482
+ }
483
+ for (const key of Object.keys(current)) {
484
+ if (!(key in value)) document.delete(key);
485
+ }
486
+ return document.toString();
487
+ }
488
+
410
489
  function normalizeCanonicalPath(relativePath) {
411
490
  if (typeof relativePath !== "string") throw new TypeError("canonical replacement requires path");
412
491
  const normalized = relativePath.split(path.sep).join("/");
@@ -419,9 +498,40 @@ function normalizeCanonicalPath(relativePath) {
419
498
  return normalized;
420
499
  }
421
500
 
422
- function validateStagedProduct(stagingRoot) {
501
+ function validateStagedProduct(stagingRoot, root) {
423
502
  const check = validateProduct(stagingRoot);
424
- if (check.errors.length > 0) throw new Error(check.errors.join("\n"));
503
+ if (check.errors.length > 0) throw validationFailureError(root, check.errors);
504
+ }
505
+
506
+ /**
507
+ * Build the error for a failed product validation. Validation failures are
508
+ * model failures, not CLI-input failures, so the Next: line must point at the
509
+ * real unblock instead of the per-command usage hint: referential lifecycle
510
+ * blockers name the referencing file and the edit-regenerate-retry path,
511
+ * base-retention failures name the sanctioned remedies, and everything else is
512
+ * a source-file fix.
513
+ * @param {string} root - Product root the validation ran against.
514
+ * @param {string[]} errors - Checker diagnostics, one per line.
515
+ * @returns {Error} Error with a tailored nextAction property.
516
+ */
517
+ export function validationFailureError(root, errors) {
518
+ const error = new Error(`Validation failed for ${root}:\n${errors.join("\n")}`);
519
+ const referencingFiles = [
520
+ ...new Set(
521
+ errors
522
+ .filter((line) => /: (?:non-effective guarantee|split successor must be) /.test(line))
523
+ .map((line) => line.slice(0, line.indexOf(":"))),
524
+ ),
525
+ ];
526
+ if (referencingFiles.length > 0) {
527
+ error.nextAction = `Edit ${referencingFiles.join(", ")} to remove or replace the blocking guarantee reference, run ddduck generate --root ${shellQuote(root)}, and retry.`;
528
+ } else if (errors.some((line) => line.startsWith("guarantee disappeared from the product"))) {
529
+ error.nextAction =
530
+ "Restore the guarantee record from the base product, or record the transition with ddduck retire or ddduck split, then re-run ddduck check.";
531
+ } else {
532
+ error.nextAction = "Fix the listed source files, then re-run ddduck check.";
533
+ }
534
+ return error;
425
535
  }
426
536
 
427
537
  function copyPublishedFile(stagingRoot, relativePath, targetPath) {
@@ -1,6 +1,22 @@
1
+ /**
2
+ * Path-containment guard for every write below a product root. Any file
3
+ * ddduck creates or publishes (generated views, staged canonical files, lock
4
+ * and staging paths, eval outputs) resolves its destination through
5
+ * resolveContainedOutput, which rejects absolute paths, `..` traversal,
6
+ * escapes above the root, and symbolic links anywhere on the target path — so
7
+ * no operation can be steered into writing outside the root.
8
+ */
9
+
1
10
  import { lstatSync, realpathSync } from "node:fs";
2
11
  import path from "node:path";
3
12
 
13
+ /**
14
+ * Resolve a root-relative output path to an absolute one, proving it stays
15
+ * below the real root and traverses or targets no symbolic link.
16
+ * @param {string} rootPath - The containing root (product root or repo root).
17
+ * @param {string} relativePath - Root-relative destination path.
18
+ * @returns {string} The absolute, contained target path.
19
+ */
4
20
  export function resolveContainedOutput(rootPath, relativePath) {
5
21
  if (path.isAbsolute(relativePath)) {
6
22
  throw new Error("output path must be relative to root");
@@ -1,36 +1,58 @@
1
+ /**
2
+ * The read-only query engine behind `ddduck query`: loadQueryProduct builds a
3
+ * validated in-memory product (nodes, derived graph edges, source digest,
4
+ * lifecycle redirects for inactive guarantees) after refusing busy roots and
5
+ * interrupted-operation leftovers, and the query* functions implement the
6
+ * node, neighbors, impact, anchors, and spec operations. Anchors and spec
7
+ * also report generated-view freshness (the SVG checked via subprocess) and
8
+ * the check/generate verification commands agents should run.
9
+ */
10
+
11
+ import { spawnSync } from "node:child_process";
1
12
  import { readFileSync, readdirSync } from "node:fs";
2
13
  import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
3
15
  import { validateProduct } from "../check-model.mjs";
4
16
  import { buildModelOverview } from "../generate-docs.mjs";
5
17
  import { buildModelGraph, buildModelGraphOutputs } from "../generate-graph.mjs";
6
- import { sourceDigestFromSources } from "./context-pack.mjs";
18
+ import { shellQuote, sourceDigestFromSources, unknownModelNodeError } from "./context-pack.mjs";
7
19
  import { detectProductLayout, loadProductNodes } from "./product-layout.mjs";
8
- import { assertProductNotBusy } from "./product-operation.mjs";
20
+ import { assertProductNotBusy, findLeftoverOperationState, generatedPaths } from "./product-operation.mjs";
9
21
 
10
22
  const impactEdgeKinds = new Set(["owns", "requires", "preserves", "establishes", "uses", "guarantees"]);
11
23
  // Only these kinds accept an `evidence` field in their schemas, so source and
12
24
  // verification anchors may only be demanded of them.
13
25
  const evidenceAnchorKinds = new Set(["DomainInterface", "UseCase", "Guarantee"]);
14
- const generatedViewPaths = [
15
- "generated/docs/model-overview.md",
16
- "generated/graph/model-graph.json",
17
- "generated/graph/model-graph.ndjson",
18
- ];
26
+ const svgViewPath = "generated/graph/model-graph.svg";
27
+ const svgCheckerScript = path.resolve(
28
+ path.dirname(fileURLToPath(import.meta.url)),
29
+ "..",
30
+ "check-generated-graph-svg.mjs",
31
+ );
19
32
  function verificationCommandsFor(product) {
20
33
  const root = shellQuote(product.root);
21
34
  return [`ddduck check --root ${root}`, `ddduck generate --root ${root}`];
22
35
  }
23
36
 
24
- // Emitted commands are copied into shells by humans and agents; roots with
25
- // spaces or metacharacters must survive that round trip.
26
- function shellQuote(value) {
27
- if (/^[A-Za-z0-9_\-./]+$/.test(value)) return value;
28
- return `'${value.replaceAll("'", "'\\''")}'`;
29
- }
30
-
37
+ /**
38
+ * Load the queryable product for a root: refuse busy or interrupted state,
39
+ * validate the source (documentation excluded), and assemble nodes, edges,
40
+ * source paths, the sha256 source digest, decisions, and policies.
41
+ * @param {string} rootPath - Product root path.
42
+ * @param {{history?: boolean}} [options] - With history, inactive guarantees stay in `nodes`; otherwise they become lifecycleRedirects.
43
+ * @returns {{root: string, rootModelId: string, nodes: Map<string, object>, sourcePaths: Map<string, string>, sourceDigest: string, edges: object[], lifecycleRedirects: Map<string, object>, decisions: Map<string, string>, policies: string[]}} The loaded query product.
44
+ */
31
45
  export function loadQueryProduct(rootPath, { history = false } = {}) {
32
46
  const layout = detectProductLayout(rootPath);
33
47
  assertProductNotBusy(layout.root);
48
+ // Leftover state from an interrupted mutation means the snapshot may be
49
+ // partially published; refuse to serve a digest over it, exactly like check.
50
+ const leftover = findLeftoverOperationState(layout.root);
51
+ if (leftover) {
52
+ const error = new Error(`An interrupted ddduck operation left ${leftover.entries.join(", ")} in ${layout.root}`);
53
+ error.nextAction = `Run ddduck generate --root ${shellQuote(layout.root)} to reclaim the interrupted operation state, then retry the query.`;
54
+ throw error;
55
+ }
34
56
  const check = validateProduct(layout.root, { includeDocumentation: false });
35
57
  if (check.errors.length > 0) throw new Error(check.errors.join("\n"));
36
58
  const loaded = loadProductNodes(layout);
@@ -69,6 +91,14 @@ export function loadQueryProduct(rootPath, { history = false } = {}) {
69
91
  };
70
92
  }
71
93
 
94
+ /**
95
+ * Answer `query node`: the full node, or a lifecycleRedirect for an inactive
96
+ * guarantee ID.
97
+ * @param {object} product - Product from loadQueryProduct.
98
+ * @param {string} id - Model node ID.
99
+ * @param {{history?: boolean}} [options] - Echoed into the query envelope.
100
+ * @returns {object} The query document.
101
+ */
72
102
  export function queryNode(product, id, { history = false } = {}) {
73
103
  const node = product.nodes.get(id);
74
104
  if (node) {
@@ -78,15 +108,23 @@ export function queryNode(product, id, { history = false } = {}) {
78
108
  if (lifecycleRedirect) {
79
109
  return envelope(product, "node", id, history, { lifecycleRedirect });
80
110
  }
81
- throw new Error(`Unknown model node ${id}`);
111
+ throw unknownModelNodeError(product, id);
82
112
  }
83
113
 
114
+ /**
115
+ * Answer `query neighbors`: every incoming and outgoing edge of a node with a
116
+ * summary of the peer node on each edge.
117
+ * @param {object} product - Product from loadQueryProduct.
118
+ * @param {string} id - Model node ID.
119
+ * @param {{history?: boolean}} [options] - Echoed into the query envelope.
120
+ * @returns {object} The query document.
121
+ */
84
122
  export function queryNeighbors(product, id, { history = false } = {}) {
85
123
  const node = product.nodes.get(id);
86
124
  if (!node) {
87
125
  const lifecycleRedirect = product.lifecycleRedirects.get(id);
88
126
  if (lifecycleRedirect) return envelope(product, "neighbors", id, history, { lifecycleRedirect });
89
- throw new Error(`Unknown model node ${id}`);
127
+ throw unknownModelNodeError(product, id);
90
128
  }
91
129
  const incoming = product.edges
92
130
  .filter((edge) => edge.to === id)
@@ -103,12 +141,21 @@ export function queryNeighbors(product, id, { history = false } = {}) {
103
141
  });
104
142
  }
105
143
 
144
+ /**
145
+ * Answer `query impact`: breadth-first walk of everything that depends on the
146
+ * node, following owns/requires/preserves/establishes/uses/guarantees edges
147
+ * backwards, with each hit tagged by depth.
148
+ * @param {object} product - Product from loadQueryProduct.
149
+ * @param {string} id - Model node ID.
150
+ * @param {{history?: boolean}} [options] - Echoed into the query envelope.
151
+ * @returns {object} The query document.
152
+ */
106
153
  export function queryImpact(product, id, { history = false } = {}) {
107
154
  const node = product.nodes.get(id);
108
155
  if (!node) {
109
156
  const lifecycleRedirect = product.lifecycleRedirects.get(id);
110
157
  if (lifecycleRedirect) return envelope(product, "impact", id, history, { lifecycleRedirect });
111
- throw new Error(`Unknown model node ${id}`);
158
+ throw unknownModelNodeError(product, id);
112
159
  }
113
160
 
114
161
  const visited = new Set([id]);
@@ -136,12 +183,22 @@ export function queryImpact(product, id, { history = false } = {}) {
136
183
  });
137
184
  }
138
185
 
186
+ /**
187
+ * Answer `query anchors`: the node's declared evidence anchors, reachable
188
+ * decisions, executed policies, generated-view freshness, verification
189
+ * commands, and which evidence roles (source/decision/verification) are still
190
+ * missing for kinds that expect them.
191
+ * @param {object} product - Product from loadQueryProduct.
192
+ * @param {string} id - Model node ID.
193
+ * @param {{history?: boolean}} [options] - Echoed into the query envelope.
194
+ * @returns {object} The query document.
195
+ */
139
196
  export function queryAnchors(product, id, { history = false } = {}) {
140
197
  const node = product.nodes.get(id);
141
198
  if (!node) {
142
199
  const lifecycleRedirect = product.lifecycleRedirects.get(id);
143
200
  if (lifecycleRedirect) return envelope(product, "anchors", id, history, { lifecycleRedirect });
144
- throw new Error(`Unknown model node ${id}`);
201
+ throw unknownModelNodeError(product, id);
145
202
  }
146
203
 
147
204
  const declaredAnchors = [...(node.evidence ?? [])].sort(byAnchor);
@@ -167,6 +224,13 @@ export function queryAnchors(product, id, { history = false } = {}) {
167
224
  });
168
225
  }
169
226
 
227
+ /**
228
+ * Answer `query spec`: the root Model node, its owned Domains, generated-view
229
+ * freshness, and the verification commands.
230
+ * @param {object} product - Product from loadQueryProduct.
231
+ * @param {{history?: boolean}} [options] - Echoed into the query envelope.
232
+ * @returns {object} The query document.
233
+ */
170
234
  export function querySpec(product, { history = false } = {}) {
171
235
  const root = product.nodes.get(product.rootModelId);
172
236
  if (!root) throw new Error(`Unknown model node ${product.rootModelId}`);
@@ -252,12 +316,30 @@ function generatedViews(product) {
252
316
  ["generated/graph/model-graph.json", outputs.json],
253
317
  ["generated/graph/model-graph.ndjson", outputs.ndjson],
254
318
  ]);
255
- return generatedViewPaths.map((relativePath) => ({
319
+ return generatedPaths.map((relativePath) => ({
256
320
  path: relativePath,
257
- freshness: readGeneratedView(product.root, relativePath) === expectedByPath.get(relativePath) ? "fresh" : "stale",
321
+ freshness:
322
+ relativePath === svgViewPath
323
+ ? svgViewFreshness(product.root)
324
+ : readGeneratedView(product.root, relativePath) === expectedByPath.get(relativePath)
325
+ ? "fresh"
326
+ : "stale",
258
327
  }));
259
328
  }
260
329
 
330
+ // The canonical SVG is rendered by the async Graphviz WASM engine, so its
331
+ // freshness is checked in a child process to keep queries synchronous
332
+ // (mirrors the check and generate subprocesses).
333
+ function svgViewFreshness(root) {
334
+ const result = spawnSync(process.execPath, [svgCheckerScript, "--root", root], { encoding: "utf8" });
335
+ if (result.error) {
336
+ throw new Error(`Failed to run the model graph SVG check for ${root}: ${result.error.message}`);
337
+ }
338
+ if (result.status === 0) return "fresh";
339
+ if (/missing or stale/.test(result.stderr ?? "")) return "stale";
340
+ throw new Error(`Failed to run the model graph SVG check for ${root}: ${(result.stderr ?? "").trim()}`);
341
+ }
342
+
261
343
  function readGeneratedView(root, relativePath) {
262
344
  try {
263
345
  return readFileSync(path.join(root, relativePath), "utf8");
@@ -1,3 +1,13 @@
1
+ /**
2
+ * Resolves which product root a rootless command addresses, in priority
3
+ * order: explicit --root, the enclosing product root of the cwd, the
4
+ * productRoot pinned in .ddduck/config.json, then repository-wide discovery
5
+ * (a unique primary candidate, or a unique example candidate when run inside
6
+ * it; test/ and fixtures/ candidates are ignored, ambiguity is an error).
7
+ * Also owns resolveInitDestination, the init staging-name prefix, and the
8
+ * nonProductSourceEntries list that mutation staging excludes.
9
+ */
10
+
1
11
  import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
2
12
  import path from "node:path";
3
13
  import { parseDocument } from "yaml";
@@ -17,6 +27,12 @@ export const nonProductSourceEntries = Object.freeze([
17
27
  ".worktrees",
18
28
  ]);
19
29
 
30
+ /**
31
+ * Resolve the product root for a command: explicit root, enclosing root,
32
+ * configured root, or unique discovery — otherwise throw with candidates.
33
+ * @param {{cwd?: string, explicitRoot?: string}} [options] - Working directory and the --root override.
34
+ * @returns {string} The real (symlink-resolved) product root path.
35
+ */
20
36
  export function resolveProductRoot({ cwd = process.cwd(), explicitRoot } = {}) {
21
37
  if (explicitRoot) return validateSelectedRoot(path.resolve(cwd, explicitRoot), "Explicit product root");
22
38
 
@@ -41,9 +57,16 @@ export function resolveProductRoot({ cwd = process.cwd(), explicitRoot } = {}) {
41
57
  if (relevantExamples.length === 1) return relevantExamples[0].root;
42
58
 
43
59
  if (candidates.length > 1) throw ambiguousRoots(candidates);
60
+ if (candidates.length === 1) throw irrelevantCandidate(candidates[0]);
44
61
  throw new Error("No ddduck product root found; pass --root <product-root>");
45
62
  }
46
63
 
64
+ /**
65
+ * Resolve where `ddduck init` publishes: the explicit destination, the
66
+ * configured productRoot, or `ddd` under the current directory.
67
+ * @param {{cwd?: string, explicitDestination?: string}} [options] - Working directory and the optional positional destination.
68
+ * @returns {string} Absolute destination path.
69
+ */
47
70
  export function resolveInitDestination({ cwd = process.cwd(), explicitDestination } = {}) {
48
71
  if (explicitDestination) return path.resolve(cwd, explicitDestination);
49
72
  const repositoryRoot = findRepositoryRoot(cwd);
@@ -134,6 +157,13 @@ function passesProductValidation(root) {
134
157
  }
135
158
  }
136
159
 
160
+ /**
161
+ * Test whether a directory has the shape of a product root: a product.yaml
162
+ * that is a valid schemaVersion-1 Model with a model:<slug> ID, plus a model/
163
+ * directory. Shape only; full validation happens elsewhere.
164
+ * @param {string} root - Candidate directory.
165
+ * @returns {boolean} True when the directory looks like a product root.
166
+ */
137
167
  function hasProductRootShape(root) {
138
168
  try {
139
169
  const productPath = path.join(root, "product.yaml");
@@ -152,6 +182,16 @@ function hasProductRootShape(root) {
152
182
  }
153
183
  }
154
184
 
185
+ // A repository whose only candidate is an example resolves implicitly only
186
+ // from inside it. From anywhere else, name the candidate that was found and
187
+ // rejected as irrelevant (mirroring the ambiguity message) instead of claiming
188
+ // no product exists.
189
+ function irrelevantCandidate(candidate) {
190
+ return new Error(
191
+ `No ddduck product root selected; found ${candidate.classification} candidate: ${candidate.root}${candidate.valid ? "" : " (fails validation)"}. Pass --root ${candidate.root} or run inside it.`,
192
+ );
193
+ }
194
+
155
195
  function ambiguousRoots(candidates) {
156
196
  const roots = candidates
157
197
  .map((candidate) => `${candidate.classification}: ${candidate.root}${candidate.valid ? "" : " (fails validation)"}`)
@@ -1,8 +1,19 @@
1
+ /**
2
+ * The single skip predicate shared by every ddduck repository scanner (product
3
+ * root discovery and the documentation reference walk), so both agree on which
4
+ * directory entries are invisible. Name-based and deterministic; it never
5
+ * reads .gitignore.
6
+ */
7
+
1
8
  export const defaultIgnoredEntryNames = Object.freeze(["node_modules"]);
2
9
 
3
- // A directory/file entry is skipped by every ddduck repo scanner when its
4
- // basename is a dot-entry, a known dependency directory, or a product-declared
5
- // extra ignore. Name-based and deterministic; it never reads .gitignore.
10
+ /**
11
+ * Decide whether a scanner should skip a directory/file entry: dot-entries,
12
+ * known dependency directories, and product-declared extra ignores.
13
+ * @param {string} name - The entry's basename.
14
+ * @param {string[]} [extraNames] - Extra names from .ddduck/config.json ignore.
15
+ * @returns {boolean} True when the entry must be skipped.
16
+ */
6
17
  export function shouldIgnoreScanEntry(name, extraNames = []) {
7
18
  return name.startsWith(".") || defaultIgnoredEntryNames.includes(name) || extraNames.includes(name);
8
19
  }