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
@@ -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
  }