ddduck 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +11 -6
  2. package/docs/architecture.md +5 -2
  3. package/docs/cli.md +135 -53
  4. package/docs/definition-workflow.md +130 -0
  5. package/docs/getting-started.md +21 -48
  6. package/docs/model-reference.md +4 -0
  7. package/docs/model.md +1 -0
  8. package/docs/templates/change-brief.md +48 -0
  9. package/package.json +8 -5
  10. package/schemas/model-diff.schema.json +107 -0
  11. package/scripts/audit-fr-to-code.mjs +25 -0
  12. package/scripts/check-generated-docs.mjs +24 -5
  13. package/scripts/check-generated-graph-svg.mjs +26 -8
  14. package/scripts/check-generated-graph.mjs +25 -5
  15. package/scripts/check-model.mjs +95 -5
  16. package/scripts/ddduck.mjs +216 -22
  17. package/scripts/generate-agent-readiness-report.mjs +8 -0
  18. package/scripts/generate-docs.mjs +29 -3
  19. package/scripts/generate-graph-svg.mjs +53 -16
  20. package/scripts/generate-graph.mjs +26 -1
  21. package/scripts/lib/agent-readiness-evals.mjs +32 -0
  22. package/scripts/lib/agent-readiness-report.mjs +14 -0
  23. package/scripts/lib/cli-contract.mjs +65 -15
  24. package/scripts/lib/context-pack.mjs +49 -1
  25. package/scripts/lib/ddduck-config.mjs +31 -1
  26. package/scripts/lib/fr-to-code-audit.mjs +30 -0
  27. package/scripts/lib/product-authoring.mjs +52 -0
  28. package/scripts/lib/product-diff.mjs +155 -0
  29. package/scripts/lib/product-layout.mjs +42 -1
  30. package/scripts/lib/product-operation.mjs +140 -22
  31. package/scripts/lib/product-paths.mjs +16 -0
  32. package/scripts/lib/product-query.mjs +102 -20
  33. package/scripts/lib/product-root-resolver.mjs +40 -0
  34. package/scripts/lib/scan-ignore.mjs +14 -3
  35. package/scripts/lib/skill-installer.mjs +227 -39
  36. package/scripts/query-model.mjs +18 -7
  37. package/scripts/run-agent-readiness-evals.mjs +18 -2
  38. package/skills/update-ddduck-specs/SKILL.md +25 -83
  39. package/skills/update-ddduck-specs/references/authoring-and-verification.md +60 -0
  40. package/skills/update-ddduck-specs/references/modeling-and-evidence.md +56 -0
  41. package/skills/update-ddduck-specs/references/reviewing-changes.md +45 -0
@@ -0,0 +1,155 @@
1
+ import path from "node:path";
2
+ import { isDeepStrictEqual } from "node:util";
3
+ import { validateProduct } from "../check-model.mjs";
4
+ import { shellQuote, sourceDigestFromSources } from "./context-pack.mjs";
5
+ import { detectProductLayout, loadProductNodes } from "./product-layout.mjs";
6
+ import { assertProductNotBusy, findLeftoverOperationState } from "./product-operation.mjs";
7
+
8
+ export function compareProductSnapshots(before, after) {
9
+ const beforeModel = modelId(before);
10
+ const afterModel = modelId(after);
11
+ if (beforeModel !== afterModel) {
12
+ const error = new Error(`Cannot compare different Model IDs: ${beforeModel} and ${afterModel}`);
13
+ error.nextAction = "Choose two product roots representing the same Model ID, then retry the diff.";
14
+ throw error;
15
+ }
16
+ const beforeNodes = new Map(before.nodes.map((node) => [node.id, node]));
17
+ const afterNodes = new Map(after.nodes.map((node) => [node.id, node]));
18
+ const result = { added: [], removed: [], changed: [], relocated: [] };
19
+ for (const id of [...new Set([...beforeNodes.keys(), ...afterNodes.keys()])].sort()) {
20
+ const previous = beforeNodes.get(id);
21
+ const current = afterNodes.get(id);
22
+ if (!previous) {
23
+ result.added.push(record(current, after.canonicalPaths[id]));
24
+ } else if (!current) {
25
+ result.removed.push(record(previous, before.canonicalPaths[id]));
26
+ } else {
27
+ const changes = fieldChanges(previous, current);
28
+ if (changes.length > 0) result.changed.push({ id, kind: current.kind, changes });
29
+ if (before.canonicalPaths[id] !== after.canonicalPaths[id]) {
30
+ result.relocated.push({
31
+ id,
32
+ kind: current.kind,
33
+ beforePath: before.canonicalPaths[id],
34
+ afterPath: after.canonicalPaths[id],
35
+ });
36
+ }
37
+ }
38
+ }
39
+ return result;
40
+ }
41
+
42
+ export function compareProductRoots(beforeRoot, afterRoot) {
43
+ const before = loadDiffRoot(beforeRoot);
44
+ const after = loadDiffRoot(afterRoot);
45
+ return {
46
+ schemaVersion: "1",
47
+ kind: "ModelDiff",
48
+ before: { modelId: modelId(before.snapshot), sourceDigest: before.sourceDigest },
49
+ after: { modelId: modelId(after.snapshot), sourceDigest: after.sourceDigest },
50
+ scope: "canonical-yaml-only",
51
+ excludedScopes: ["decision-content", "evidence-content", "delivery-artifacts", "runtime"],
52
+ ...compareProductSnapshots(before.snapshot, after.snapshot),
53
+ };
54
+ }
55
+
56
+ export function renderProductDiff(report) {
57
+ const lines = [
58
+ `Model diff: ${report.before.modelId}`,
59
+ `Before: ${report.before.sourceDigest}`,
60
+ `After: ${report.after.sourceDigest}`,
61
+ `Scope: ${report.scope}`,
62
+ `Excluded: ${report.excludedScopes.join(", ")}`,
63
+ ];
64
+ for (const entry of report.added) lines.push(`Added ${entry.id} (${entry.kind}) at ${entry.sourcePath}`);
65
+ for (const entry of report.removed) lines.push(`Removed ${entry.id} (${entry.kind}) from ${entry.sourcePath}`);
66
+ for (const entry of report.changed) {
67
+ lines.push(`Changed ${entry.id} (${entry.kind})`);
68
+ for (const change of entry.changes) {
69
+ const before = change.beforePresent ? JSON.stringify(change.before) : "<absent>";
70
+ const after = change.afterPresent ? JSON.stringify(change.after) : "<absent>";
71
+ lines.push(` ${change.path}: ${before} -> ${after}`);
72
+ }
73
+ }
74
+ for (const entry of report.relocated) {
75
+ lines.push(`Relocated ${entry.id} (${entry.kind}): ${entry.beforePath} -> ${entry.afterPath}`);
76
+ }
77
+ if ([report.added, report.removed, report.changed, report.relocated].every((entries) => entries.length === 0)) {
78
+ lines.push("No canonical record changes.");
79
+ }
80
+ lines.push("Structural comparison requires human interpretation; source reads are not atomic snapshots.");
81
+ return lines.join("\n");
82
+ }
83
+
84
+ function loadDiffRoot(rootPath) {
85
+ const layout = detectProductLayout(rootPath);
86
+ try {
87
+ assertReadable(layout.root);
88
+ const check = validateProduct(layout.root, { includeDocumentation: false, sourceOnly: true });
89
+ if (check.errors.length > 0) throw new Error(`Validation failed for ${layout.root}:\n${check.errors.join("\n")}`);
90
+ const loaded = loadProductNodes(layout);
91
+ const canonicalPaths = Object.fromEntries(
92
+ [...loaded.nodeFiles].map(([id, filePath]) => [
93
+ id,
94
+ path.relative(layout.root, filePath).split(path.sep).join("/"),
95
+ ]),
96
+ );
97
+ const sources = new Map([...loaded.nodeSources].map(([id, bytes]) => [canonicalPaths[id], bytes]));
98
+ assertReadable(layout.root);
99
+ return {
100
+ snapshot: { nodes: [...loaded.nodes.values()], canonicalPaths },
101
+ sourceDigest: sourceDigestFromSources(sources),
102
+ };
103
+ } catch (error) {
104
+ error.nextAction ??= `Fix the product root and its source files, run ddduck check --root ${shellQuote(layout.root)}, then retry the diff.`;
105
+ throw error;
106
+ }
107
+ }
108
+
109
+ function assertReadable(root) {
110
+ assertProductNotBusy(root);
111
+ const leftover = findLeftoverOperationState(root);
112
+ if (leftover) {
113
+ const error = new Error(`An interrupted ddduck operation left ${leftover.entries.join(", ")} in ${root}`);
114
+ error.nextAction = `Run ddduck generate --root ${shellQuote(root)} to reclaim the interrupted operation state, then retry the diff.`;
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ function modelId(snapshot) {
120
+ const models = snapshot.nodes.filter((node) => node.kind === "Model");
121
+ if (models.length !== 1) throw new Error("A product snapshot must contain exactly one Model record");
122
+ return models[0].id;
123
+ }
124
+
125
+ function record(node, sourcePath) {
126
+ return { id: node.id, kind: node.kind, sourcePath, node: globalThis.structuredClone(node) };
127
+ }
128
+
129
+ function fieldChanges(before, after, pointer = "") {
130
+ const changes = [];
131
+ for (const key of [...new Set([...Object.keys(before), ...Object.keys(after)])].sort()) {
132
+ const fieldPath = `${pointer}/${key.replaceAll("~", "~0").replaceAll("/", "~1")}`;
133
+ const beforePresent = Object.hasOwn(before, key);
134
+ const afterPresent = Object.hasOwn(after, key);
135
+ const previous = before[key];
136
+ const current = after[key];
137
+ if (beforePresent && afterPresent && isDeepStrictEqual(previous, current)) continue;
138
+ if (beforePresent && afterPresent && isMapping(previous) && isMapping(current)) {
139
+ changes.push(...fieldChanges(previous, current, fieldPath));
140
+ } else {
141
+ changes.push({
142
+ path: fieldPath,
143
+ beforePresent,
144
+ afterPresent,
145
+ ...(beforePresent ? { before: globalThis.structuredClone(previous) } : {}),
146
+ ...(afterPresent ? { after: globalThis.structuredClone(current) } : {}),
147
+ });
148
+ }
149
+ }
150
+ return changes.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
151
+ }
152
+
153
+ function isMapping(value) {
154
+ return value !== null && typeof value === "object" && !Array.isArray(value);
155
+ }
@@ -1,9 +1,33 @@
1
+ /**
2
+ * Defines the on-disk layout of a product root and loads its canonical YAML:
3
+ * product.yaml plus the model/{domains,concepts,relationships,use-cases,
4
+ * interfaces,guarantees} directories (nodeDirectoryKinds maps directory to
5
+ * node kind). loadProductNodes yields mutable nodes with file paths and raw
6
+ * sources (for the query digest); loadProductSnapshot yields the deep-frozen
7
+ * snapshot with root-relative canonical paths that mutation transforms plan
8
+ * against. Strict YAML parsing: duplicate keys and non-mapping files fail.
9
+ */
10
+
1
11
  import { readFileSync, readdirSync } from "node:fs";
2
12
  import path from "node:path";
3
13
  import { parseDocument } from "yaml";
4
14
 
5
- const nodeDirectories = ["domains", "concepts", "relationships", "use-cases", "interfaces", "guarantees"];
15
+ export const nodeDirectoryKinds = new Map([
16
+ ["domains", "Domain"],
17
+ ["concepts", "Concept"],
18
+ ["relationships", "Relationship"],
19
+ ["use-cases", "UseCase"],
20
+ ["interfaces", "DomainInterface"],
21
+ ["guarantees", "Guarantee"],
22
+ ]);
23
+
24
+ const nodeDirectories = [...nodeDirectoryKinds.keys()];
6
25
 
26
+ /**
27
+ * Describe the canonical layout below a product root.
28
+ * @param {string} rootPath - Product root path.
29
+ * @returns {{root: string, productPath: string, nodeDirectories: string[], decisionsDirectory: string, generatedDirectory: string}} Resolved layout paths.
30
+ */
7
31
  export function detectProductLayout(rootPath) {
8
32
  const root = path.resolve(rootPath);
9
33
  return {
@@ -15,6 +39,11 @@ export function detectProductLayout(rootPath) {
15
39
  };
16
40
  }
17
41
 
42
+ /**
43
+ * Load every canonical YAML node under a layout, rejecting duplicate node IDs.
44
+ * @param {{root: string, productPath: string, nodeDirectories: string[]}} layout - Layout from detectProductLayout.
45
+ * @returns {{nodes: Map<string, object>, nodeFiles: Map<string, string>, nodeSources: Map<string, Buffer>}} Nodes, their file paths, and raw source bytes keyed by ID.
46
+ */
18
47
  export function loadProductNodes(layout) {
19
48
  const nodes = new Map();
20
49
  const nodeFiles = new Map();
@@ -35,6 +64,12 @@ export function loadProductNodes(layout) {
35
64
  return { nodes, nodeFiles, nodeSources };
36
65
  }
37
66
 
67
+ /**
68
+ * Load the deep-frozen snapshot that mutation transforms receive: immutable
69
+ * nodes plus each node's root-relative canonical path.
70
+ * @param {{root: string, productPath: string, nodeDirectories: string[]}} layout - Layout from detectProductLayout.
71
+ * @returns {{nodes: readonly object[], canonicalPaths: Record<string, string>}} Frozen snapshot.
72
+ */
38
73
  export function loadProductSnapshot(layout) {
39
74
  const loaded = loadProductNodes(layout);
40
75
  const nodes = Object.freeze([...loaded.nodes.values()].map((node) => deepFreeze(node)));
@@ -49,6 +84,12 @@ export function loadProductSnapshot(layout) {
49
84
  return Object.freeze({ nodes, canonicalPaths });
50
85
  }
51
86
 
87
+ /**
88
+ * Parse one canonical YAML file strictly (unique keys) into a plain mapping.
89
+ * @param {string} filePath - File path, used in diagnostics.
90
+ * @param {string} [source] - YAML source; read from filePath when omitted.
91
+ * @returns {object} The parsed YAML mapping.
92
+ */
52
93
  export function parseYamlMapping(filePath, source = readFileSync(filePath, "utf8")) {
53
94
  const document = parseDocument(source, { keepSourceTokens: true, strict: true, uniqueKeys: true });
54
95
  if (document.errors.length > 0) {
@@ -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, isSeq, 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,64 @@ 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 if (
480
+ isSeq(existing) &&
481
+ Array.isArray(next) &&
482
+ next.length > existing.items.length &&
483
+ next.every((item) => item === null || typeof item !== "object") &&
484
+ existing.items.every((item, index) => isScalar(item) && item.value === next[index])
485
+ ) {
486
+ for (const item of next.slice(existing.items.length)) existing.add(document.createNode(item));
487
+ } else {
488
+ document.set(key, document.createNode(next));
489
+ }
490
+ }
491
+ for (const key of Object.keys(current)) {
492
+ if (!(key in value)) document.delete(key);
493
+ }
494
+ return document.toString();
495
+ }
496
+
410
497
  function normalizeCanonicalPath(relativePath) {
411
498
  if (typeof relativePath !== "string") throw new TypeError("canonical replacement requires path");
412
499
  const normalized = relativePath.split(path.sep).join("/");
@@ -419,9 +506,40 @@ function normalizeCanonicalPath(relativePath) {
419
506
  return normalized;
420
507
  }
421
508
 
422
- function validateStagedProduct(stagingRoot) {
509
+ function validateStagedProduct(stagingRoot, root) {
423
510
  const check = validateProduct(stagingRoot);
424
- if (check.errors.length > 0) throw new Error(check.errors.join("\n"));
511
+ if (check.errors.length > 0) throw validationFailureError(root, check.errors);
512
+ }
513
+
514
+ /**
515
+ * Build the error for a failed product validation. Validation failures are
516
+ * model failures, not CLI-input failures, so the Next: line must point at the
517
+ * real unblock instead of the per-command usage hint: referential lifecycle
518
+ * blockers name the referencing file and the edit-regenerate-retry path,
519
+ * base-retention failures name the sanctioned remedies, and everything else is
520
+ * a source-file fix.
521
+ * @param {string} root - Product root the validation ran against.
522
+ * @param {string[]} errors - Checker diagnostics, one per line.
523
+ * @returns {Error} Error with a tailored nextAction property.
524
+ */
525
+ export function validationFailureError(root, errors) {
526
+ const error = new Error(`Validation failed for ${root}:\n${errors.join("\n")}`);
527
+ const referencingFiles = [
528
+ ...new Set(
529
+ errors
530
+ .filter((line) => /: (?:non-effective guarantee|split successor must be) /.test(line))
531
+ .map((line) => line.slice(0, line.indexOf(":"))),
532
+ ),
533
+ ];
534
+ if (referencingFiles.length > 0) {
535
+ error.nextAction = `Edit ${referencingFiles.join(", ")} to remove or replace the blocking guarantee reference, run ddduck generate --root ${shellQuote(root)}, and retry.`;
536
+ } else if (errors.some((line) => line.startsWith("guarantee disappeared from the product"))) {
537
+ error.nextAction =
538
+ "Restore the guarantee record from the base product, or record the transition with ddduck retire or ddduck split, then re-run ddduck check.";
539
+ } else {
540
+ error.nextAction = "Fix the listed source files, then re-run ddduck check.";
541
+ }
542
+ return error;
425
543
  }
426
544
 
427
545
  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");