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.
- package/README.md +11 -6
- package/docs/architecture.md +5 -2
- package/docs/cli.md +135 -53
- package/docs/definition-workflow.md +130 -0
- package/docs/getting-started.md +21 -48
- package/docs/model-reference.md +4 -0
- package/docs/model.md +1 -0
- package/docs/templates/change-brief.md +48 -0
- package/package.json +8 -5
- package/schemas/model-diff.schema.json +107 -0
- package/scripts/audit-fr-to-code.mjs +25 -0
- package/scripts/check-generated-docs.mjs +24 -5
- package/scripts/check-generated-graph-svg.mjs +26 -8
- package/scripts/check-generated-graph.mjs +25 -5
- package/scripts/check-model.mjs +95 -5
- package/scripts/ddduck.mjs +216 -22
- package/scripts/generate-agent-readiness-report.mjs +8 -0
- package/scripts/generate-docs.mjs +29 -3
- package/scripts/generate-graph-svg.mjs +53 -16
- package/scripts/generate-graph.mjs +26 -1
- package/scripts/lib/agent-readiness-evals.mjs +32 -0
- package/scripts/lib/agent-readiness-report.mjs +14 -0
- package/scripts/lib/cli-contract.mjs +65 -15
- package/scripts/lib/context-pack.mjs +49 -1
- package/scripts/lib/ddduck-config.mjs +31 -1
- package/scripts/lib/fr-to-code-audit.mjs +30 -0
- package/scripts/lib/product-authoring.mjs +52 -0
- package/scripts/lib/product-diff.mjs +155 -0
- package/scripts/lib/product-layout.mjs +42 -1
- package/scripts/lib/product-operation.mjs +140 -22
- package/scripts/lib/product-paths.mjs +16 -0
- package/scripts/lib/product-query.mjs +102 -20
- package/scripts/lib/product-root-resolver.mjs +40 -0
- package/scripts/lib/scan-ignore.mjs +14 -3
- package/scripts/lib/skill-installer.mjs +227 -39
- package/scripts/query-model.mjs +18 -7
- package/scripts/run-agent-readiness-evals.mjs +18 -2
- package/skills/update-ddduck-specs/SKILL.md +25 -83
- package/skills/update-ddduck-specs/references/authoring-and-verification.md +60 -0
- package/skills/update-ddduck-specs/references/modeling-and-evidence.md +56 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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");
|