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,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,11 +73,16 @@ 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: [
61
84
  "Usage: ddduck <command> [options]",
62
- "Commands: init, check, generate, query, install, create, move, split, retire.",
85
+ "Commands: init, check, generate, query, diff, install, create, move, split, retire.",
63
86
  "Run `ddduck <command> --help` for command usage.",
64
87
  ],
65
88
  init: [
@@ -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,32 +106,43 @@ 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
  ],
121
+ diff: [
122
+ "Syntax: ddduck diff --base <previous-product-root> [--root <product-root>] [--json]",
123
+ "Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery); --base is required; output is text.",
124
+ "Writes: nothing; source roots are validated independently and generated freshness is not required.",
125
+ "Success output: a stable-ID comparison of added, removed, changed, and relocated canonical records, with source digests and explicit exclusions.",
126
+ "Exit status: 0 on a completed comparison (including differences) or help; 2 for a busy root; 1 for invalid input, source, interrupted state, or different Model IDs.",
127
+ "JSON: --json emits one ModelDiff document; comparison is canonical YAML only and requires human interpretation.",
128
+ ],
98
129
  install: [
99
130
  "Syntax: ddduck install skill update-ddduck-specs [--repo <repository-root>]",
100
131
  "Defaults: --repo is the current directory.",
101
- "Writes: the selected host skill adapter and .ddduck/agent-skills.lock.json in --repo.",
132
+ "Writes: the selected host skill bundle and .ddduck/agent-skills.lock.json in --repo.",
102
133
  "Success output: one text result with the action (created, upgraded, or no-op), repository, canonical path, and lock path.",
103
134
  "Exit status: 0 on installation, no-op, or help; nonzero on invalid input or conflicting host state.",
104
135
  "JSON: unavailable; --json is not accepted.",
105
136
  ],
106
137
  create: [
107
138
  "Syntax: ddduck create guarantee --origin <origin> --classification <invariant|acceptance-criterion> --owner <domain-id> --statement <text> [--root <product-root>] [--json]",
108
- "Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery); the next origin/classification serial is allocated.",
109
- "Writes: the new Guarantee, its owning Domain, and all generated views through staged publication.",
110
- "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.",
139
+ "Syntax: ddduck create domain --id domain:<slug> --name <text> --purpose <text> [--root <product-root>] [--json]",
140
+ "Syntax: ddduck create concept --id concept:<slug> --owner domain:<slug> --name <text> --purpose <text> [--root <product-root>] [--json]",
141
+ "Syntax: ddduck create use-case --file <yaml-file> [--root <product-root>] [--json]",
142
+ "Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery); guarantees allocate the next origin/classification serial; other kinds require an explicit ID or complete UseCase input.",
143
+ "Writes: the new node, its owning collection, and all generated views through staged publication; input files are read-only.",
144
+ "Success output: one text result with affected IDs, root, canonical paths, and generated paths.",
145
+ "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
146
  "JSON: --json emits the same result as one JSON object.",
113
147
  ],
114
148
  move: [
@@ -116,7 +150,7 @@ export function renderHelp(command) {
116
150
  "Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery).",
117
151
  "Writes: the Guarantee, affected Domains, and all generated views through staged publication.",
118
152
  "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.",
153
+ "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
154
  "JSON: --json emits the same result as one JSON object.",
121
155
  ],
122
156
  split: [
@@ -124,7 +158,7 @@ export function renderHelp(command) {
124
158
  "Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery).",
125
159
  "Writes: the source Guarantee lifecycle state and all generated views through staged publication.",
126
160
  "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.",
161
+ "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
162
  "JSON: --json emits the same result as one JSON object.",
129
163
  ],
130
164
  retire: [
@@ -132,7 +166,7 @@ export function renderHelp(command) {
132
166
  "Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery).",
133
167
  "Writes: the Guarantee lifecycle state and all generated views through staged publication.",
134
168
  "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.",
169
+ "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
170
  "JSON: --json emits the same result as one JSON object.",
137
171
  ],
138
172
  "audit-fr-to-code": [
@@ -142,6 +176,14 @@ export function renderHelp(command) {
142
176
  return `${(usage[command] ?? usage.undefined).join("\n")}\n`;
143
177
  }
144
178
 
179
+ /**
180
+ * Format any error into the CLI contract shape: `Error: <message>` (multi-line
181
+ * diagnostics preserved) followed by a `Next: <action>` line, preferring the
182
+ * error's own nextAction over the caller's fallback.
183
+ * @param {unknown} error - The thrown error or value.
184
+ * @param {{nextAction?: string}} [options] - Fallback next action.
185
+ * @returns {string} The formatted error text (no trailing newline).
186
+ */
145
187
  export function formatCliError(error, { nextAction } = {}) {
146
188
  const message = error instanceof Error ? error.message : String(error);
147
189
  const action = error?.nextAction ?? nextAction ?? "Review the diagnostic, correct the input, and retry.";
@@ -156,7 +198,15 @@ export function formatCliError(error, { nextAction } = {}) {
156
198
  return [`Error: ${lines[0]}`, ...lines.slice(1), `Next: ${action}`].join("\n");
157
199
  }
158
200
 
201
+ /**
202
+ * Write a formatted error to stderr and set the process exit code.
203
+ * @param {unknown} error - The thrown error or value.
204
+ * @param {{stderr?: {write: (chunk: string) => unknown}, nextAction?: string}} [options] - Stream override and fallback next action.
205
+ * @returns {void}
206
+ */
159
207
  export function writeCliError(error, { stderr = process.stderr, nextAction } = {}) {
160
208
  stderr.write(`${formatCliError(error, { nextAction })}\n`);
161
- process.exitCode = 1;
209
+ // Exit 1 is the default failure code; an error may carry a distinct code
210
+ // (exit 2 marks the retryable busy case, see ProductBusyError).
211
+ process.exitCode = Number.isInteger(error?.exitCode) ? error.exitCode : 1;
162
212
  }
@@ -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) => {
@@ -117,6 +141,12 @@ function renderReport(record, productionAnchors, testAnchors) {
117
141
  requirement: record.requirement,
118
142
  coverage: record.coverage,
119
143
  verdict: record.verdict,
144
+ verificationScope: {
145
+ verdictSource: "input-record",
146
+ anchorIntegrityChecked: true,
147
+ testsExecuted: false,
148
+ behaviorVerified: false,
149
+ },
120
150
  productionAnchors,
121
151
  testAnchors,
122
152
  reviewerDisposition: record.reviewerDisposition,
@@ -0,0 +1,52 @@
1
+ const kinds = {
2
+ Domain: { prefix: "domain", directory: "domains", collection: "domains" },
3
+ Concept: { prefix: "concept", directory: "concepts", collection: "concepts" },
4
+ UseCase: { prefix: "use-case", directory: "use-cases", collection: "useCases" },
5
+ };
6
+
7
+ export function buildAuthoringPlan(snapshot, request) {
8
+ const definition = kinds[request.kind];
9
+ if (!definition) throw new Error(`Unsupported creation kind ${request.kind}`);
10
+ const models = snapshot.nodes.filter(({ kind }) => kind === "Model");
11
+ if (models.length !== 1) throw new Error("Creation requires exactly one Model");
12
+ const [model] = models;
13
+ const node =
14
+ request.kind === "UseCase"
15
+ ? globalThis.structuredClone(request.node)
16
+ : {
17
+ schemaVersion: "1",
18
+ kind: request.kind,
19
+ id: request.id,
20
+ model: model.id,
21
+ ...(request.kind === "Concept" ? { ownerDomain: request.ownerDomain } : {}),
22
+ name: request.name,
23
+ purpose: request.purpose,
24
+ ...(request.kind === "Domain" ? { concepts: [], interfaces: [], guarantees: [] } : {}),
25
+ };
26
+ if (node?.kind !== request.kind) throw new Error(`create ${definition.prefix} requires kind ${request.kind}`);
27
+ if (node.model !== model.id) throw new Error(`New node model must be ${model.id}`);
28
+ if (typeof node.id !== "string" || !new RegExp(`^${definition.prefix}:[a-z0-9][a-z0-9-]*$`).test(node.id)) {
29
+ throw new Error(`Invalid ${request.kind} ID ${JSON.stringify(node.id)}`);
30
+ }
31
+ if (snapshot.nodes.some(({ id }) => id === node.id)) throw new Error(`Model node ${node.id} already exists`);
32
+ const destination = `model/${definition.directory}/${node.id.slice(definition.prefix.length + 1)}.yaml`;
33
+ if (Object.values(snapshot.canonicalPaths).includes(destination)) {
34
+ throw new Error(`Canonical destination already occupied: ${destination}`);
35
+ }
36
+ const parent =
37
+ request.kind === "Concept"
38
+ ? snapshot.nodes.find(({ id, kind }) => kind === "Domain" && id === node.ownerDomain)
39
+ : model;
40
+ if (!parent) throw new Error(`Unknown domain ${node.ownerDomain}`);
41
+ return {
42
+ operation: `create ${definition.prefix}`,
43
+ affectedIds: [node.id, parent.id],
44
+ replacements: [
45
+ { path: destination, value: node },
46
+ {
47
+ path: snapshot.canonicalPaths[parent.id],
48
+ value: { ...parent, [definition.collection]: [...(parent[definition.collection] ?? []), node.id] },
49
+ },
50
+ ],
51
+ };
52
+ }