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.
- package/README.md +8 -5
- package/docs/architecture.md +5 -2
- package/docs/cli.md +73 -52
- package/docs/getting-started.md +6 -3
- package/docs/model-reference.md +4 -0
- package/docs/model.md +1 -0
- package/package.json +5 -4
- 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 +166 -21
- package/scripts/generate-agent-readiness-report.mjs +8 -0
- package/scripts/generate-docs.mjs +28 -2
- 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 +49 -10
- package/scripts/lib/context-pack.mjs +49 -1
- package/scripts/lib/ddduck-config.mjs +31 -1
- package/scripts/lib/fr-to-code-audit.mjs +24 -0
- package/scripts/lib/product-layout.mjs +42 -1
- package/scripts/lib/product-operation.mjs +132 -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 +57 -4
- package/scripts/query-model.mjs +18 -7
- package/scripts/run-agent-readiness-evals.mjs +18 -2
- package/skills/update-ddduck-specs/SKILL.md +1 -1
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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.
|
|
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("
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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
|
|
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;
|
|
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;
|
|
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)
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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) {
|