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
|
@@ -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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
319
|
+
return generatedPaths.map((relativePath) => ({
|
|
256
320
|
path: relativePath,
|
|
257
|
-
freshness:
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
}
|