ddduck 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // Experimental: render generated/graph/model-graph.json to an SVG using the
4
- // Graphviz WASM engine (@hpcc-js/wasm). Pure JS + WASM, no system binary, so it
5
- // stays offline and deterministic. A hosted render API (e.g. Kroki) is a
6
- // possible future fallback but is intentionally not the default.
3
+ /**
4
+ * Renders generated/graph/model-graph.json to the canonical SVG view
5
+ * generated/graph/model-graph.svg using the Graphviz WASM engine
6
+ * (@hpcc-js/wasm): pure JS + WASM, no system binary, so it stays offline and
7
+ * deterministic. Because rendering is async, init, the operation runner, check,
8
+ * and query all invoke this script as a child process. Experimental layout
9
+ * variants (--layout/--all-layouts) land under .ddduck/graph-layouts/, outside
10
+ * the generated-view contract. A hosted render API (e.g. Kroki) is a possible
11
+ * future fallback but is intentionally not the default.
12
+ */
7
13
 
8
14
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
15
  import path from "node:path";
@@ -15,7 +21,8 @@ export const jsonInputPath = path.join("generated", "graph", "model-graph.json")
15
21
 
16
22
  // Graphviz layout engines worth comparing for a model graph. `dot` is the
17
23
  // hierarchical default; the force-directed and radial engines often read better
18
- // once the ownership tree gets wide. Output goes to `model-graph.<engine>.svg`.
24
+ // once the ownership tree gets wide. Variant output goes to
25
+ // `.ddduck/graph-layouts/model-graph.<engine>.svg`.
19
26
  export const LAYOUT_ENGINES = ["dot", "twopi", "circo", "fdp", "sfdp", "neato"];
20
27
 
21
28
  // Only `dot` and `fdp` do cluster-aware layout. The other engines still *draw*
@@ -32,8 +39,13 @@ export function engineSupportsClusters(engine) {
32
39
  export const svgOutputPath = path.join("generated", "graph", "model-graph.svg");
33
40
  export const canonicalEngine = "dot";
34
41
 
42
+ // Experimental layout variants (`--layout` / `--all-layouts`) are not part of
43
+ // the generated-view contract: `generated/` holds only the canonical views that
44
+ // `ddduck generate` refreshes and `ddduck check` gates. Variants land in the
45
+ // `.ddduck/` tool-metadata area instead, so they can never rot unswept inside
46
+ // `generated/`.
35
47
  export function svgOutputPathFor(engine) {
36
- return path.join("generated", "graph", `model-graph.${engine}.svg`);
48
+ return path.join(".ddduck", "graph-layouts", `model-graph.${engine}.svg`);
37
49
  }
38
50
 
39
51
  // Visual vocabulary keyed by the graph's node/edge `kind`. Shapes and fills are
@@ -214,9 +226,14 @@ function edgeStatements(graphEdge, nodeIds) {
214
226
  return [edge(graphEdge.from, graphEdge.to, { ...base, label: graphEdge.label ?? graphEdge.kind })];
215
227
  }
216
228
 
217
- // Pure translation from the model graph JSON to a deterministic DOT string.
218
- // `clusters` groups each domain and its owned nodes into a titled box; disable it
219
- // for engines that draw but do not lay out clusters (they would overlap).
229
+ /**
230
+ * Pure translation from the model graph JSON to a deterministic DOT string.
231
+ * `clusters` groups each domain and its owned nodes into a titled box; disable
232
+ * it for engines that draw but do not lay out clusters (they would overlap).
233
+ * @param {{modelId?: string, modelName?: string, nodes?: object[], edges?: object[]}} modelGraph - Parsed model-graph.json.
234
+ * @param {{clusters?: boolean, legend?: boolean}} [options] - Cluster domains into boxes; emit the legend cluster.
235
+ * @returns {string} The DOT source.
236
+ */
220
237
  export function graphToDot(modelGraph, { clusters = true, legend = clusters } = {}) {
221
238
  const nodes = modelGraph.nodes ?? [];
222
239
  const edges = modelGraph.edges ?? [];
@@ -254,9 +271,14 @@ function loadGraphviz() {
254
271
  return graphvizInstance;
255
272
  }
256
273
 
257
- // Render a DOT string to SVG via the Graphviz WASM engine. `engine` selects the
258
- // layout algorithm. Isolated so the backend can be swapped without touching the
259
- // translation above.
274
+ /**
275
+ * Render a DOT string to SVG via the Graphviz WASM engine. `engine` selects
276
+ * the layout algorithm. Isolated so the backend can be swapped without
277
+ * touching the translation above.
278
+ * @param {string} dot - DOT source from graphToDot.
279
+ * @param {string} [engine] - One of LAYOUT_ENGINES (default "dot").
280
+ * @returns {Promise<string>} The rendered SVG.
281
+ */
260
282
  export async function renderDotToSvg(dot, engine = "dot") {
261
283
  if (!LAYOUT_ENGINES.includes(engine)) {
262
284
  throw new Error(`unknown layout engine ${engine}; expected one of ${LAYOUT_ENGINES.join(", ")}`);
@@ -281,13 +303,22 @@ function writeSvg(rootPath, relativePath, svg) {
281
303
  return relativePath;
282
304
  }
283
305
 
284
- // Build the canonical diagram bytes (dot, clustered, legend). Shared by the
285
- // writer and the freshness check so both agree byte-for-byte.
306
+ /**
307
+ * Build the canonical diagram bytes (dot, clustered, legend). Shared by the
308
+ * writer and the freshness check so both agree byte-for-byte.
309
+ * @param {object} modelGraph - Parsed model-graph.json.
310
+ * @returns {Promise<string>} The canonical SVG bytes.
311
+ */
286
312
  export async function buildModelGraphSvg(modelGraph) {
287
313
  const dot = graphToDot(modelGraph, { clusters: true, legend: true });
288
314
  return renderDotToSvg(dot, canonicalEngine);
289
315
  }
290
316
 
317
+ /**
318
+ * Write the canonical generated/graph/model-graph.svg below the product root.
319
+ * @param {string} rootPath - Product root path.
320
+ * @returns {Promise<string>} The root-relative path that was written.
321
+ */
291
322
  export async function writeModelGraphSvgCanonical(rootPath) {
292
323
  const svg = await buildModelGraphSvg(readModelGraph(rootPath));
293
324
  return writeSvg(rootPath, svgOutputPath, svg);
@@ -298,8 +329,14 @@ export async function writeModelGraphSvg(rootPath, engine = "dot") {
298
329
  return writeSvg(rootPath, svgOutputPathFor(engine), await renderDotToSvg(dot, engine));
299
330
  }
300
331
 
301
- // Render one SVG per layout engine so the variants can be compared side by side.
302
- // Clusters are emitted only for the engines that lay them out (dot, fdp).
332
+ /**
333
+ * Render one SVG per layout engine so the variants can be compared side by
334
+ * side. Clusters are emitted only for the engines that lay them out (dot,
335
+ * fdp). Variants go to .ddduck/graph-layouts/, not generated/.
336
+ * @param {string} rootPath - Product root path.
337
+ * @param {string[]} [engines] - Layout engines to render (default all LAYOUT_ENGINES).
338
+ * @returns {Promise<string[]>} The root-relative variant paths written.
339
+ */
303
340
  export async function writeModelGraphSvgVariants(rootPath, engines = LAYOUT_ENGINES) {
304
341
  const modelGraph = readModelGraph(rootPath);
305
342
  const writtenPaths = [];
@@ -1,5 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
4
+ * Builds the generated graph views generated/graph/model-graph.json and
5
+ * .ndjson from a product root: one record per model node plus ownership,
6
+ * relationship, and guarantee-reference (requires/preserves/establishes/uses/
7
+ * guarantees) edges. buildModelGraph also feeds the SVG renderer and the query
8
+ * engine (with --history including inactive guarantees); the byte-exact
9
+ * outputs are pinned by check-generated-graph.mjs. Runnable standalone.
10
+ */
11
+
3
12
  import { mkdirSync, writeFileSync } from "node:fs";
4
13
  import path from "node:path";
5
14
  import { fileURLToPath } from "node:url";
@@ -10,6 +19,11 @@ import { resolveProductRoot } from "./lib/product-root-resolver.mjs";
10
19
  export const jsonOutputPath = path.join("generated", "graph", "model-graph.json");
11
20
  export const ndjsonOutputPath = path.join("generated", "graph", "model-graph.ndjson");
12
21
 
22
+ /**
23
+ * Build the serialized JSON and NDJSON graph views without writing them.
24
+ * @param {string} rootPath - Product root path.
25
+ * @returns {{json: string, ndjson: string}} The exact bytes of both generated views.
26
+ */
13
27
  export function buildModelGraphOutputs(rootPath) {
14
28
  const modelGraph = buildModelGraph(rootPath);
15
29
  return {
@@ -18,12 +32,23 @@ export function buildModelGraphOutputs(rootPath) {
18
32
  };
19
33
  }
20
34
 
35
+ /**
36
+ * Build the model graph object (nodes plus edges) for a product root.
37
+ * @param {string} rootPath - Product root path.
38
+ * @param {{history?: boolean}} [options] - With history, inactive guarantees are included.
39
+ * @returns {{schemaVersion: string, formatVersion: string, modelId: string, modelName: string, generatedBy: string, nodes: object[], edges: object[]}} The assembled graph.
40
+ */
21
41
  export function buildModelGraph(rootPath, { history = false } = {}) {
22
42
  const layout = detectProductLayout(rootPath);
23
43
  const graph = loadGraph(layout, { history });
24
44
  return assembleModelGraph(graph, findModelId(graph));
25
45
  }
26
46
 
47
+ /**
48
+ * Write generated/graph/model-graph.json and .ndjson below the product root.
49
+ * @param {string} rootPath - Product root path.
50
+ * @returns {string[]} The root-relative paths that were written.
51
+ */
27
52
  export function writeModelGraph(rootPath) {
28
53
  const outputs = buildModelGraphOutputs(rootPath);
29
54
  const writtenPaths = [];
@@ -63,7 +88,7 @@ function assembleModelGraph(graph, modelId) {
63
88
  formatVersion: "final",
64
89
  modelId: model.id,
65
90
  modelName: requireField(model, "name"),
66
- nameStatus: model.nameStatus ?? "stable",
91
+ ...(model.nameStatus === undefined ? {} : { nameStatus: model.nameStatus }),
67
92
  generatedBy: "scripts/generate-graph.mjs",
68
93
  nodes,
69
94
  edges,
@@ -1,3 +1,14 @@
1
+ /**
2
+ * Agent-readiness eval harness: validates JSONL eval records (strict shape,
3
+ * unique IDs, repo-contained roots, shell-safe query args), replays each
4
+ * record's query in-process through runQuery, and checks the record's
5
+ * candidateEvidence against the resulting document — citations must occur in
6
+ * it and facts must strictly equal the value at their JSON Pointer, with
7
+ * <product-root> as the portable placeholder for the resolved root. Consumed
8
+ * by run-agent-readiness-evals.mjs; results are deterministic (sorted cases
9
+ * and diagnostics).
10
+ */
11
+
1
12
  import { readFileSync, realpathSync } from "node:fs";
2
13
  import path from "node:path";
3
14
  import { runQuery } from "../query-model.mjs";
@@ -11,6 +22,11 @@ const candidateEvidenceKeys = new Set(["origin", "citations", "facts"]);
11
22
  const citationKeys = new Set(["id", "sourcePath"]);
12
23
  const factKeys = new Set(["pointer", "equals"]);
13
24
 
25
+ /**
26
+ * Run every eval record in a JSONL input file and aggregate the case results.
27
+ * @param {{inputPath: string, repoRoot: string}} options - JSONL input path and the repository root that bounds product roots.
28
+ * @returns {{schemaVersion: string, passed: boolean, cases: object[]}} Deterministically sorted eval outcome.
29
+ */
14
30
  export function runAgentReadinessEvals({ inputPath, repoRoot }) {
15
31
  const resolvedRepoRoot = path.resolve(repoRoot);
16
32
  const records = readRecords(inputPath);
@@ -27,6 +43,16 @@ export function runAgentReadinessEvals({ inputPath, repoRoot }) {
27
43
  };
28
44
  }
29
45
 
46
+ /**
47
+ * Verify candidate evidence against a query document: origin must be "query",
48
+ * every citation must occur in the document, and every fact's JSON Pointer
49
+ * must resolve to a value strictly equal to `equals` (after <product-root>
50
+ * substitution).
51
+ * @param {object} document - The JSON document produced by the replayed query.
52
+ * @param {{origin?: string, citations?: object[], facts?: object[]}} candidateEvidence - Evidence claimed by the eval record.
53
+ * @param {{productRoot?: string}} [context] - Resolved product root for the <product-root> placeholder.
54
+ * @returns {string[]} Sorted diagnostics; empty when the evidence holds.
55
+ */
30
56
  export function validateCandidateEvidence(document, candidateEvidence, { productRoot } = {}) {
31
57
  const diagnostics = [];
32
58
  if (!isObject(candidateEvidence)) {
@@ -306,6 +332,12 @@ function collectCitations(value, citations = []) {
306
332
  return citations.sort(compareCitations);
307
333
  }
308
334
 
335
+ /**
336
+ * Resolve an RFC 6901 JSON Pointer against a document.
337
+ * @param {unknown} document - The query document to walk.
338
+ * @param {string} pointer - JSON Pointer ("" selects the whole document).
339
+ * @returns {{ok: true, value: unknown}|{ok: false, error: string}} The resolved value or the failure reason.
340
+ */
309
341
  function resolveJsonPointer(document, pointer) {
310
342
  if (pointer === "") return { ok: true, value: document };
311
343
  if (!pointer.startsWith("/")) return { ok: false, error: "must start with /" };
@@ -1,8 +1,22 @@
1
+ /**
2
+ * Builds the agent-readiness report for a product root, answering "how ready
3
+ * is this model for agent consumption": nodes missing evidence roles (via
4
+ * query anchors), stale generated views (via query spec freshness), owned
5
+ * nodes with no owning Domain, and nodes listed by multiple Domains. When
6
+ * validation fails the report collapses to unresolvedReferences only.
7
+ * Consumed by generate-agent-readiness-report.mjs.
8
+ */
9
+
1
10
  import { validateProduct } from "../check-model.mjs";
2
11
  import { loadQueryProduct, queryAnchors, querySpec } from "./product-query.mjs";
3
12
 
4
13
  const ownershipKinds = new Set(["Concept", "DomainInterface", "Guarantee"]);
5
14
 
15
+ /**
16
+ * Build the readiness report for one product root.
17
+ * @param {string} rootPath - Product root path.
18
+ * @returns {{missingEvidence: object[], unresolvedReferences: string[], staleGeneratedViews: object[], orphanedNodes: object[], ambiguousOwnership: object[]}|{unresolvedReferences: string[]}} Full report, or only unresolvedReferences when validation fails.
19
+ */
6
20
  export function generateAgentReadinessReport(rootPath) {
7
21
  const check = validateProduct(rootPath, { includeDocumentation: false });
8
22
  if (check.errors.length > 0) {
@@ -1,3 +1,13 @@
1
+ /**
2
+ * The shared CLI contract for every ddduck command surface: the usage-error
3
+ * type, the option/positional parser, the per-command --help text (including
4
+ * the documented exit codes: 0 success, 1 failure, 2 retryable busy), and the
5
+ * single-format error writer that always emits `Error: ...` plus a `Next:`
6
+ * action line. Consumed by ddduck.mjs, query-model.mjs, and
7
+ * audit-fr-to-code.mjs so agents can rely on one stable error shape.
8
+ */
9
+
10
+ /** Invalid CLI input; may carry a nextAction line for the error writer. */
1
11
  export class CliUsageError extends Error {
2
12
  constructor(message, { nextAction } = {}) {
3
13
  super(message);
@@ -6,6 +16,14 @@ export class CliUsageError extends Error {
6
16
  }
7
17
  }
8
18
 
19
+ /**
20
+ * Parse command arguments against a declared option/positional shape,
21
+ * supporting --name value and --name=value, boolean flags, repeatable
22
+ * options, and duplicate rejection.
23
+ * @param {string[]} args - Arguments after the command word.
24
+ * @param {{positionals?: {min?: number, max?: number, syntax?: string}, options?: Record<string, {value?: boolean, repeatable?: boolean}>}} [spec] - Accepted positional bounds and option definitions.
25
+ * @returns {{positionals: string[], options: Record<string, string|string[]|boolean>}} Parsed values (booleans default false, repeatables default []).
26
+ */
9
27
  export function parseCommandArgs(args, { positionals = {}, options = {} } = {}) {
10
28
  const minimum = positionals.min ?? 0;
11
29
  const maximum = positionals.max ?? minimum;
@@ -55,6 +73,11 @@ export function parseCommandArgs(args, { positionals = {}, options = {} } = {})
55
73
  return { positionals: values, options: parsed };
56
74
  }
57
75
 
76
+ /**
77
+ * Render the --help text for one command, or the top-level command list.
78
+ * @param {string} [command] - Command name; unknown or absent yields the overview.
79
+ * @returns {string} The help text, newline-terminated.
80
+ */
58
81
  export function renderHelp(command) {
59
82
  const usage = {
60
83
  undefined: [
@@ -74,8 +97,8 @@ export function renderHelp(command) {
74
97
  "Syntax: ddduck check [--root <product-root>] [--base <previous-product-root>] [--docs-root <docs-root> ...] [--source-only]",
75
98
  "Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery); --base is unset; documentation references are checked inside the product root unless --docs-root replaces that scope; generated freshness is checked.",
76
99
  "Writes: nothing.",
77
- "Success output: none.",
78
- "Exit status: 0 on a valid fresh product or help; nonzero on invalid source, stale views, leftover interrupted-operation state, or invalid input.",
100
+ "Success output: none on standard output; when --root is omitted, one standard-error note names the validated root.",
101
+ "Exit status: 0 on a valid fresh product or help; 2 when the product root is busy (operation lock held by a running process, retryable); 1 on invalid source, stale views, leftover interrupted-operation state, or invalid input.",
79
102
  "JSON: unavailable; --json is not accepted.",
80
103
  ],
81
104
  generate: [
@@ -83,15 +106,15 @@ export function renderHelp(command) {
83
106
  "Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery); text output is used.",
84
107
  "Writes: all required generated docs and graph views after staged validation.",
85
108
  "Success output: one text result with the root and refreshed generated paths.",
86
- "Exit status: 0 on publication or help; nonzero with no intended product changes on failure.",
109
+ "Exit status: 0 on publication or help; 2 when the product root is busy (operation lock held by a running process, retryable); 1 with no intended product changes on any other failure.",
87
110
  "JSON: --json emits the same result as one JSON object.",
88
111
  ],
89
112
  query: [
90
113
  "Syntax: ddduck query <node|neighbors|impact|anchors|spec|context> [options] [--json]",
91
- "Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery) except context requires it; --history is false.",
114
+ "Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery); --history is false.",
92
115
  "Writes: nothing.",
93
116
  "Success output: exactly one JSON query document.",
94
- "Exit status: 0 on a resolved query or help; nonzero on invalid input, product, or selection.",
117
+ "Exit status: 0 on a resolved query or help; 2 when the product root is busy (operation lock held by a running process, retryable); 1 on invalid input, product, or selection.",
95
118
  "JSON: output is always JSON; --json is accepted and has no effect.",
96
119
  "Options: --id <model-node-id> (repeatable for context), --root <product-root>, --history.",
97
120
  ],
@@ -108,7 +131,7 @@ export function renderHelp(command) {
108
131
  "Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery); the next origin/classification serial is allocated.",
109
132
  "Writes: the new Guarantee, its owning Domain, and all generated views through staged publication.",
110
133
  "Success output: one text result with the allocated ID, root, canonical paths, and generated paths.",
111
- "Exit status: 0 on publication or help; nonzero with no intended product changes on failure.",
134
+ "Exit status: 0 on publication or help; 2 when the product root is busy (operation lock held by a running process, retryable); 1 with no intended product changes on any other failure.",
112
135
  "JSON: --json emits the same result as one JSON object.",
113
136
  ],
114
137
  move: [
@@ -116,7 +139,7 @@ export function renderHelp(command) {
116
139
  "Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery).",
117
140
  "Writes: the Guarantee, affected Domains, and all generated views through staged publication.",
118
141
  "Success output: one text result with affected IDs, root, canonical paths, and generated paths.",
119
- "Exit status: 0 on publication or help; nonzero with no intended product changes on failure.",
142
+ "Exit status: 0 on publication or help; 2 when the product root is busy (operation lock held by a running process, retryable); 1 with no intended product changes on any other failure.",
120
143
  "JSON: --json emits the same result as one JSON object.",
121
144
  ],
122
145
  split: [
@@ -124,7 +147,7 @@ export function renderHelp(command) {
124
147
  "Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery).",
125
148
  "Writes: the source Guarantee lifecycle state and all generated views through staged publication.",
126
149
  "Success output: one text result with affected IDs, root, canonical paths, and generated paths.",
127
- "Exit status: 0 on publication or help; nonzero with no intended product changes on failure.",
150
+ "Exit status: 0 on publication or help; 2 when the product root is busy (operation lock held by a running process, retryable); 1 with no intended product changes on any other failure.",
128
151
  "JSON: --json emits the same result as one JSON object.",
129
152
  ],
130
153
  retire: [
@@ -132,7 +155,7 @@ export function renderHelp(command) {
132
155
  "Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery).",
133
156
  "Writes: the Guarantee lifecycle state and all generated views through staged publication.",
134
157
  "Success output: one text result with affected IDs, root, canonical paths, and generated paths.",
135
- "Exit status: 0 on publication or help; nonzero with no intended product changes on failure.",
158
+ "Exit status: 0 on publication or help; 2 when the product root is busy (operation lock held by a running process, retryable); 1 with no intended product changes on any other failure.",
136
159
  "JSON: --json emits the same result as one JSON object.",
137
160
  ],
138
161
  "audit-fr-to-code": [
@@ -142,6 +165,14 @@ export function renderHelp(command) {
142
165
  return `${(usage[command] ?? usage.undefined).join("\n")}\n`;
143
166
  }
144
167
 
168
+ /**
169
+ * Format any error into the CLI contract shape: `Error: <message>` (multi-line
170
+ * diagnostics preserved) followed by a `Next: <action>` line, preferring the
171
+ * error's own nextAction over the caller's fallback.
172
+ * @param {unknown} error - The thrown error or value.
173
+ * @param {{nextAction?: string}} [options] - Fallback next action.
174
+ * @returns {string} The formatted error text (no trailing newline).
175
+ */
145
176
  export function formatCliError(error, { nextAction } = {}) {
146
177
  const message = error instanceof Error ? error.message : String(error);
147
178
  const action = error?.nextAction ?? nextAction ?? "Review the diagnostic, correct the input, and retry.";
@@ -156,7 +187,15 @@ export function formatCliError(error, { nextAction } = {}) {
156
187
  return [`Error: ${lines[0]}`, ...lines.slice(1), `Next: ${action}`].join("\n");
157
188
  }
158
189
 
190
+ /**
191
+ * Write a formatted error to stderr and set the process exit code.
192
+ * @param {unknown} error - The thrown error or value.
193
+ * @param {{stderr?: {write: (chunk: string) => unknown}, nextAction?: string}} [options] - Stream override and fallback next action.
194
+ * @returns {void}
195
+ */
159
196
  export function writeCliError(error, { stderr = process.stderr, nextAction } = {}) {
160
197
  stderr.write(`${formatCliError(error, { nextAction })}\n`);
161
- process.exitCode = 1;
198
+ // Exit 1 is the default failure code; an error may carry a distinct code
199
+ // (exit 2 marks the retryable busy case, see ProductBusyError).
200
+ process.exitCode = Number.isInteger(error?.exitCode) ? error.exitCode : 1;
162
201
  }
@@ -1,7 +1,49 @@
1
+ /**
2
+ * Implements `ddduck query context`: the context pack that returns the
3
+ * selected active nodes in full, every edge touching them, and summaries of
4
+ * the unselected endpoints, all under a sha256 source digest of the canonical
5
+ * YAML so consumers can detect drift. Also home to the shared sourceDigest
6
+ * helpers, shellQuote for copy-pasteable emitted commands, and the
7
+ * unknown-model-node error used across the query surface.
8
+ */
9
+
1
10
  import { createHash } from "node:crypto";
2
11
  import { readFileSync } from "node:fs";
3
12
  import path from "node:path";
4
13
 
14
+ /**
15
+ * Quote a value for safe copy-paste into a shell. Emitted commands are copied
16
+ * into shells by humans and agents; roots with spaces or metacharacters must
17
+ * survive that round trip.
18
+ * @param {string} value - The path or argument to quote.
19
+ * @returns {string} The value, single-quoted unless already shell-safe.
20
+ */
21
+ export function shellQuote(value) {
22
+ if (/^[A-Za-z0-9_\-./]+$/.test(value)) return value;
23
+ return `'${value.replaceAll("'", "'\\''")}'`;
24
+ }
25
+
26
+ /**
27
+ * Build the error for an ID that names no model node. An unknown ID is a model
28
+ * failure, not a CLI-input failure: point the caller at the generated graph
29
+ * view, the surface that enumerates every node ID, instead of the usage hint.
30
+ * @param {{root: string}} product - The loaded query product.
31
+ * @param {string} id - The unresolved model node ID.
32
+ * @returns {Error} Error with a node-listing nextAction.
33
+ */
34
+ export function unknownModelNodeError(product, id) {
35
+ const error = new Error(`Unknown model node ${id}`);
36
+ error.nextAction = `List the model's node IDs in ${path.join(product.root, "generated", "graph", "model-graph.ndjson")} (one node per line), then retry.`;
37
+ return error;
38
+ }
39
+
40
+ /**
41
+ * Resolve a context pack for a set of selected node IDs: full selected nodes,
42
+ * direct edges, and neighbor summaries. Inactive guarantees cannot be selected.
43
+ * @param {{rootModelId: string, nodes: Map<string, object>, edges: object[], sourcePaths: Map<string, string>, sourceDigest: string, lifecycleRedirects: Map<string, object>}} product - The loaded query product.
44
+ * @param {string[]} selectedIds - Distinct model node IDs to select.
45
+ * @returns {object} The context pack document (schemaVersion, query, snapshot, result).
46
+ */
5
47
  export function resolveContextPack(product, selectedIds) {
6
48
  const ids = validateSelectedIds(product, selectedIds);
7
49
  const selectedSet = new Set(ids);
@@ -29,6 +71,12 @@ export function sourceDigest(root, sourcePaths) {
29
71
  return sourceDigestFromSources(sources);
30
72
  }
31
73
 
74
+ /**
75
+ * Compute the canonical sha256 digest over already-read source files: paths
76
+ * sorted, each path and content NUL-separated.
77
+ * @param {Map<string, Buffer|string>} sources - Root-relative path to file bytes.
78
+ * @returns {string} Hex digest identifying this exact canonical snapshot.
79
+ */
32
80
  export function sourceDigestFromSources(sources) {
33
81
  const hash = createHash("sha256");
34
82
  for (const relativePath of [...sources.keys()].sort(byString)) {
@@ -64,7 +112,7 @@ function validateSelectedIds(product, selectedIds) {
64
112
  if (lifecycleRedirect) {
65
113
  throw new Error(`Cannot select inactive model node ${id} (${lifecycleRedirect.status})`);
66
114
  }
67
- throw new Error(`Unknown model node ${id}`);
115
+ throw unknownModelNodeError(product, id);
68
116
  }
69
117
  return ids;
70
118
  }
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Repository-level configuration for ddduck: locates the repository root (the
3
+ * nearest .git ancestor, else the starting directory) and reads/validates
4
+ * .ddduck/config.json, whose productRoot pins the repository's default product
5
+ * root and whose optional ignore list extends the repo scanners' skipped
6
+ * directory names. Consumed by the product root resolver, init, and the
7
+ * documentation reference check.
8
+ */
9
+
1
10
  import { existsSync, lstatSync, readFileSync } from "node:fs";
2
11
  import path from "node:path";
3
12
 
@@ -5,17 +14,32 @@ const configRelativePath = path.join(".ddduck", "config.json");
5
14
 
6
15
  export const defaultConfigIgnore = Object.freeze(["vendor", "target", "build", "dist", "__pycache__"]);
7
16
 
17
+ /**
18
+ * Find the nearest ancestor directory containing .git; without one, fall back
19
+ * to the starting directory itself (never a file path).
20
+ * @param {string} [startPath] - Directory or file to start from (default cwd).
21
+ * @returns {string} Absolute repository root, always a directory.
22
+ */
8
23
  export function findRepositoryRoot(startPath = process.cwd()) {
9
24
  let current = path.resolve(startPath);
10
25
  if (existsSync(current) && !lstatSync(current).isDirectory()) current = path.dirname(current);
26
+ // The adjusted starting directory is the fallback when no .git ancestor exists,
27
+ // so a file startPath never leaks back out as if it were a directory.
28
+ const fallback = current;
11
29
  while (true) {
12
30
  if (existsSync(path.join(current, ".git"))) return current;
13
31
  const parent = path.dirname(current);
14
- if (parent === current) return path.resolve(startPath);
32
+ if (parent === current) return fallback;
15
33
  current = parent;
16
34
  }
17
35
  }
18
36
 
37
+ /**
38
+ * Load and validate .ddduck/config.json from a repository root; a malformed
39
+ * config throws, an absent one returns null.
40
+ * @param {string} repositoryRoot - Repository root to read the config from.
41
+ * @returns {{schemaVersion: "1", productRoot: string, ignore?: string[]}|null} The validated config or null.
42
+ */
19
43
  export function loadDdduckConfig(repositoryRoot) {
20
44
  const configPath = path.join(repositoryRoot, configRelativePath);
21
45
  if (!existsSync(configPath)) return null;
@@ -50,6 +74,12 @@ export function loadDdduckConfig(repositoryRoot) {
50
74
  return config;
51
75
  }
52
76
 
77
+ /**
78
+ * Resolve the ignored directory names for repo scans: the config's ignore list
79
+ * when present, else the defaults (vendor, target, build, dist, __pycache__).
80
+ * @param {string} repositoryRoot - Repository root whose config applies.
81
+ * @returns {string[]} Directory names to skip.
82
+ */
53
83
  export function resolveConfiguredIgnores(repositoryRoot) {
54
84
  const config = loadDdduckConfig(repositoryRoot);
55
85
  if (!config || config.ignore === undefined) return [...defaultConfigIgnore];
@@ -1,3 +1,13 @@
1
+ /**
2
+ * Verification core for FR-to-code audit records, shared by the
3
+ * audit-fr-to-code CLI and its tests. Validates a record against
4
+ * schemas/fr-to-code-audit.schema.json, checks the requirement ID (and traced
5
+ * guarantee) appear in the pinned requirement source, confirms every
6
+ * production/test anchor's line range exists and digests its excerpt
7
+ * (sha256), and enforces verdict consistency (realized-and-tested,
8
+ * realized-untested, unrealized) before rendering the deterministic report.
9
+ */
10
+
1
11
  import { createHash } from "node:crypto";
2
12
  import { readFileSync } from "node:fs";
3
13
  import path from "node:path";
@@ -12,6 +22,12 @@ const auditSchema = JSON.parse(
12
22
  );
13
23
  const validateAudit = new Ajv2020({ allErrors: true, strict: true }).compile(auditSchema);
14
24
 
25
+ /**
26
+ * Verify one audit record against its sources and render the report.
27
+ * @param {object} record - The parsed FrToCodeAudit YAML mapping.
28
+ * @param {{readFile: (source: object, relativePath: string) => string}} sourceReader - Reader that serves file text at each source's pinned revision.
29
+ * @returns {object} The FrToCodeAuditReport document with digested anchors.
30
+ */
15
31
  export function verifyFrToCodeAudit(record, sourceReader) {
16
32
  const normalized = validateRecord(record);
17
33
  requireReader(sourceReader);
@@ -69,6 +85,14 @@ function requireText(text, value, label) {
69
85
  if (!text.includes(value)) throw new Error(`FrToCodeAudit ${label} ${value} is missing from its source file`);
70
86
  }
71
87
 
88
+ /**
89
+ * Resolve each anchor's source file, verify its line range fits the file, and
90
+ * replace the excerpt with a sha256 digest in the rendered report.
91
+ * @param {{sourceId: string, path: string, startLine: number, endLine: number}[]} anchors - Production or test anchors.
92
+ * @param {Map<string, object>} sourcesById - Declared sources keyed by ID.
93
+ * @param {{readFile: (source: object, relativePath: string) => string}} sourceReader - Pinned-revision file reader.
94
+ * @returns {object[]} Sorted anchors with excerptDigest entries.
95
+ */
72
96
  function renderAnchors(anchors, sourcesById, sourceReader) {
73
97
  return anchors
74
98
  .map((anchor) => {
@@ -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) {