ddduck 0.1.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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/docs/architecture.md +77 -0
  4. package/docs/cli.md +239 -0
  5. package/docs/getting-started.md +98 -0
  6. package/docs/model-reference.md +92 -0
  7. package/docs/model.md +75 -0
  8. package/package.json +76 -0
  9. package/policies/concept-owner-domain.yaml +8 -0
  10. package/policies/documentation-model-reference-resolution.yaml +8 -0
  11. package/policies/no-dangling-model-reference.yaml +8 -0
  12. package/policies/policy-spec.schema.json +23 -0
  13. package/schemas/context-pack.schema.json +83 -0
  14. package/schemas/fr-to-code-audit.schema.json +85 -0
  15. package/schemas/product/concept.schema.json +17 -0
  16. package/schemas/product/domain-interface.schema.json +18 -0
  17. package/schemas/product/domain.schema.json +24 -0
  18. package/schemas/product/evidence-anchor.schema.json +30 -0
  19. package/schemas/product/guarantee.schema.json +35 -0
  20. package/schemas/product/model.schema.json +20 -0
  21. package/schemas/product/relationship.schema.json +21 -0
  22. package/schemas/product/use-case.schema.json +27 -0
  23. package/scripts/audit-fr-to-code.mjs +162 -0
  24. package/scripts/check-generated-docs.mjs +58 -0
  25. package/scripts/check-generated-graph-svg.mjs +60 -0
  26. package/scripts/check-generated-graph.mjs +66 -0
  27. package/scripts/check-model.mjs +488 -0
  28. package/scripts/ddduck.mjs +542 -0
  29. package/scripts/generate-agent-readiness-report.mjs +23 -0
  30. package/scripts/generate-docs.mjs +205 -0
  31. package/scripts/generate-graph-svg.mjs +359 -0
  32. package/scripts/generate-graph.mjs +268 -0
  33. package/scripts/lib/agent-readiness-evals.mjs +433 -0
  34. package/scripts/lib/agent-readiness-report.mjs +79 -0
  35. package/scripts/lib/cli-contract.mjs +162 -0
  36. package/scripts/lib/context-pack.mjs +107 -0
  37. package/scripts/lib/ddduck-config.mjs +57 -0
  38. package/scripts/lib/fr-to-code-audit.mjs +144 -0
  39. package/scripts/lib/product-layout.mjs +93 -0
  40. package/scripts/lib/product-operation.mjs +431 -0
  41. package/scripts/lib/product-paths.mjs +43 -0
  42. package/scripts/lib/product-query.mjs +284 -0
  43. package/scripts/lib/product-root-resolver.mjs +167 -0
  44. package/scripts/lib/scan-ignore.mjs +8 -0
  45. package/scripts/lib/skill-installer.mjs +410 -0
  46. package/scripts/query-model.mjs +64 -0
  47. package/scripts/run-agent-readiness-evals.mjs +57 -0
  48. package/skills/update-ddduck-specs/SKILL.md +98 -0
@@ -0,0 +1,79 @@
1
+ import { validateProduct } from "../check-model.mjs";
2
+ import { loadQueryProduct, queryAnchors, querySpec } from "./product-query.mjs";
3
+
4
+ const ownershipKinds = new Set(["Concept", "DomainInterface", "Guarantee"]);
5
+
6
+ export function generateAgentReadinessReport(rootPath) {
7
+ const check = validateProduct(rootPath, { includeDocumentation: false });
8
+ if (check.errors.length > 0) {
9
+ return { unresolvedReferences: [...check.errors].sort(compareStrings) };
10
+ }
11
+
12
+ const product = loadQueryProduct(rootPath);
13
+ const ownedNodes = [...product.nodes.values()].filter(({ kind }) => ownershipKinds.has(kind)).sort(byId);
14
+ const ownersByNode = collectOwners(product);
15
+
16
+ const missingEvidence = [...product.nodes.values()]
17
+ .sort(byId)
18
+ .map((node) => {
19
+ const result = queryAnchors(product, node.id).result;
20
+ return {
21
+ id: result.node.id,
22
+ kind: result.node.kind,
23
+ sourcePath: result.node.sourcePath,
24
+ missingRoles: result.missingEvidence,
25
+ };
26
+ })
27
+ .filter(({ missingRoles }) => missingRoles.length > 0);
28
+ const staleGeneratedViews = querySpec(product)
29
+ .result.generatedViews.filter(({ freshness }) => freshness !== "fresh")
30
+ .sort(byPath);
31
+ const orphanedNodes = ownedNodes
32
+ .filter(({ id }) => (ownersByNode.get(id)?.size ?? 0) === 0)
33
+ .map((node) => summarizeNode(node, product));
34
+ const ambiguousOwnership = ownedNodes
35
+ .filter(({ id }) => (ownersByNode.get(id)?.size ?? 0) > 1)
36
+ .map((node) => ({
37
+ ...summarizeNode(node, product),
38
+ ownerDomains: [...ownersByNode.get(node.id)].sort(compareStrings),
39
+ }));
40
+
41
+ return {
42
+ missingEvidence,
43
+ unresolvedReferences: [],
44
+ staleGeneratedViews,
45
+ orphanedNodes,
46
+ ambiguousOwnership,
47
+ };
48
+ }
49
+
50
+ function collectOwners(product) {
51
+ const ownersByNode = new Map();
52
+ for (const edge of product.edges.filter(({ kind }) => kind === "owns")) {
53
+ if (product.nodes.get(edge.from)?.kind !== "Domain") continue;
54
+ if (!product.nodes.has(edge.to)) continue;
55
+ if (!ownersByNode.has(edge.to)) ownersByNode.set(edge.to, new Set());
56
+ ownersByNode.get(edge.to).add(edge.from);
57
+ }
58
+ return ownersByNode;
59
+ }
60
+
61
+ function summarizeNode(node, product) {
62
+ return {
63
+ id: node.id,
64
+ kind: node.kind,
65
+ sourcePath: product.sourcePaths.get(node.id),
66
+ };
67
+ }
68
+
69
+ function byId(left, right) {
70
+ return compareStrings(left.id, right.id);
71
+ }
72
+
73
+ function byPath(left, right) {
74
+ return compareStrings(left.path, right.path);
75
+ }
76
+
77
+ function compareStrings(left, right) {
78
+ return left < right ? -1 : left > right ? 1 : 0;
79
+ }
@@ -0,0 +1,162 @@
1
+ export class CliUsageError extends Error {
2
+ constructor(message, { nextAction } = {}) {
3
+ super(message);
4
+ this.name = "CliUsageError";
5
+ if (nextAction !== undefined) this.nextAction = nextAction;
6
+ }
7
+ }
8
+
9
+ export function parseCommandArgs(args, { positionals = {}, options = {} } = {}) {
10
+ const minimum = positionals.min ?? 0;
11
+ const maximum = positionals.max ?? minimum;
12
+ const parsed = {};
13
+ for (const [name, definition] of Object.entries(options)) {
14
+ if (!definition.value) parsed[name] = false;
15
+ if (definition.repeatable) parsed[name] = [];
16
+ }
17
+ const values = [];
18
+
19
+ for (let index = 0; index < args.length; index += 1) {
20
+ const argument = args[index];
21
+ if (!argument.startsWith("--")) {
22
+ values.push(argument);
23
+ continue;
24
+ }
25
+ const separatorIndex = argument.indexOf("=");
26
+ const name = separatorIndex === -1 ? argument.slice(2) : argument.slice(2, separatorIndex);
27
+ const inlineValue = separatorIndex === -1 ? undefined : argument.slice(separatorIndex + 1);
28
+ const definition = options[name];
29
+ if (!definition) throw new CliUsageError(`Unknown option --${name}`);
30
+ if (!definition.repeatable && (definition.value ? parsed[name] !== undefined : parsed[name])) {
31
+ throw new CliUsageError(`Duplicate option --${name}`);
32
+ }
33
+ if (!definition.value) {
34
+ if (inlineValue !== undefined) throw new CliUsageError(`Option --${name} does not take a value`);
35
+ parsed[name] = true;
36
+ continue;
37
+ }
38
+ const value = inlineValue ?? args[index + 1];
39
+ if (value === undefined || value === "") throw new CliUsageError(`Missing value for --${name}`);
40
+ if (inlineValue === undefined && value.startsWith("--")) {
41
+ throw new CliUsageError(`Missing value for --${name}; pass values starting with -- as --${name}=<value>`);
42
+ }
43
+ if (definition.repeatable) parsed[name].push(value);
44
+ else parsed[name] = value;
45
+ if (inlineValue === undefined) index += 1;
46
+ }
47
+
48
+ if (values.length < minimum || values.length > maximum) {
49
+ const expected = minimum === maximum ? minimum : `${minimum}-${maximum}`;
50
+ const usage = positionals.syntax ? `; usage: ${positionals.syntax}` : "";
51
+ throw new CliUsageError(
52
+ `Expected ${expected} positional argument${minimum === 1 && maximum === 1 ? "" : "s"}${usage}`,
53
+ );
54
+ }
55
+ return { positionals: values, options: parsed };
56
+ }
57
+
58
+ export function renderHelp(command) {
59
+ const usage = {
60
+ undefined: [
61
+ "Usage: ddduck <command> [options]",
62
+ "Commands: init, check, generate, query, install, create, move, split, retire.",
63
+ "Run `ddduck <command> --help` for command usage.",
64
+ ],
65
+ init: [
66
+ "Syntax: ddduck init [destination] --id model:<product-id> [--json]",
67
+ "Defaults: destination is the productRoot configured in .ddduck/config.json, else ddd.",
68
+ "Writes: product.yaml, canonical directories, and all generated views in the new product root.",
69
+ "Success output: one text result with root, Model ID, canonical paths, and generated paths.",
70
+ "Exit status: 0 on success or help; nonzero if input is invalid or the destination is not empty.",
71
+ "JSON: --json emits the same result as one JSON object.",
72
+ ],
73
+ check: [
74
+ "Syntax: ddduck check [--root <product-root>] [--base <previous-product-root>] [--docs-root <docs-root> ...] [--source-only]",
75
+ "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
+ "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.",
79
+ "JSON: unavailable; --json is not accepted.",
80
+ ],
81
+ generate: [
82
+ "Syntax: ddduck generate [--root <product-root>] [--json]",
83
+ "Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery); text output is used.",
84
+ "Writes: all required generated docs and graph views after staged validation.",
85
+ "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.",
87
+ "JSON: --json emits the same result as one JSON object.",
88
+ ],
89
+ query: [
90
+ "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.",
92
+ "Writes: nothing.",
93
+ "Success output: exactly one JSON query document.",
94
+ "Exit status: 0 on a resolved query or help; nonzero on invalid input, product, or selection.",
95
+ "JSON: output is always JSON; --json is accepted and has no effect.",
96
+ "Options: --id <model-node-id> (repeatable for context), --root <product-root>, --history.",
97
+ ],
98
+ install: [
99
+ "Syntax: ddduck install skill update-ddduck-specs [--repo <repository-root>]",
100
+ "Defaults: --repo is the current directory.",
101
+ "Writes: the selected host skill adapter and .ddduck/agent-skills.lock.json in --repo.",
102
+ "Success output: one text result with the action (created, upgraded, or no-op), repository, canonical path, and lock path.",
103
+ "Exit status: 0 on installation, no-op, or help; nonzero on invalid input or conflicting host state.",
104
+ "JSON: unavailable; --json is not accepted.",
105
+ ],
106
+ create: [
107
+ "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.",
112
+ "JSON: --json emits the same result as one JSON object.",
113
+ ],
114
+ move: [
115
+ "Syntax: ddduck move guarantee <guarantee-id> --to <domain-id> [--root <product-root>] [--json]",
116
+ "Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery).",
117
+ "Writes: the Guarantee, affected Domains, and all generated views through staged publication.",
118
+ "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.",
120
+ "JSON: --json emits the same result as one JSON object.",
121
+ ],
122
+ split: [
123
+ "Syntax: ddduck split guarantee <guarantee-id> --into <successor-id,successor-id> --decision ADR-NNN [--root <product-root>] [--json]",
124
+ "Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery).",
125
+ "Writes: the source Guarantee lifecycle state and all generated views through staged publication.",
126
+ "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.",
128
+ "JSON: --json emits the same result as one JSON object.",
129
+ ],
130
+ retire: [
131
+ "Syntax: ddduck retire guarantee <guarantee-id> --decision ADR-NNN [--root <product-root>] [--json]",
132
+ "Defaults: --root is the resolved product root (enclosing directory, config, or unique discovery).",
133
+ "Writes: the Guarantee lifecycle state and all generated views through staged publication.",
134
+ "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.",
136
+ "JSON: --json emits the same result as one JSON object.",
137
+ ],
138
+ "audit-fr-to-code": [
139
+ "Usage: audit-fr-to-code --input <audit.yaml> --source-root <source-id>=<checkout> [--source-root <source-id>=<checkout> ...] --json",
140
+ ],
141
+ };
142
+ return `${(usage[command] ?? usage.undefined).join("\n")}\n`;
143
+ }
144
+
145
+ export function formatCliError(error, { nextAction } = {}) {
146
+ const message = error instanceof Error ? error.message : String(error);
147
+ const action = error?.nextAction ?? nextAction ?? "Review the diagnostic, correct the input, and retry.";
148
+ const lines = message
149
+ .split(/\r?\n/)
150
+ .map((line) => line.trimEnd())
151
+ .filter((line) => line.length > 0);
152
+ if (lines.length <= 1) {
153
+ const safeMessage = (lines[0] ?? "").replace(/[.\s]+$/, "");
154
+ return `Error: ${safeMessage}. Next: ${action}`;
155
+ }
156
+ return [`Error: ${lines[0]}`, ...lines.slice(1), `Next: ${action}`].join("\n");
157
+ }
158
+
159
+ export function writeCliError(error, { stderr = process.stderr, nextAction } = {}) {
160
+ stderr.write(`${formatCliError(error, { nextAction })}\n`);
161
+ process.exitCode = 1;
162
+ }
@@ -0,0 +1,107 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export function resolveContextPack(product, selectedIds) {
6
+ const ids = validateSelectedIds(product, selectedIds);
7
+ const selectedSet = new Set(ids);
8
+ const selected = ids.map((id) => renderNode(product.nodes.get(id), product.sourcePaths));
9
+ const edges = directEdges(product.edges, selectedSet);
10
+ const neighbors = summarizeUnselectedEndpoints(product, selectedSet, edges);
11
+
12
+ return {
13
+ schemaVersion: "1",
14
+ query: { operation: "context", ids, history: false },
15
+ rootModelId: product.rootModelId,
16
+ snapshot: { algorithm: "sha256", sourceDigest: product.sourceDigest },
17
+ result: { selected, edges, neighbors },
18
+ diagnostics: [],
19
+ };
20
+ }
21
+
22
+ export function sourceDigest(root, sourcePaths) {
23
+ const sources = new Map(
24
+ [...new Set(sourcePaths.values())].map((relativePath) => [
25
+ relativePath,
26
+ readFileSync(path.resolve(root, relativePath)),
27
+ ]),
28
+ );
29
+ return sourceDigestFromSources(sources);
30
+ }
31
+
32
+ export function sourceDigestFromSources(sources) {
33
+ const hash = createHash("sha256");
34
+ for (const relativePath of [...sources.keys()].sort(byString)) {
35
+ hash.update(relativePath, "utf8");
36
+ hash.update("\0", "utf8");
37
+ hash.update(sources.get(relativePath));
38
+ hash.update("\0", "utf8");
39
+ }
40
+ return hash.digest("hex");
41
+ }
42
+
43
+ function validateSelectedIds(product, selectedIds) {
44
+ if (!Array.isArray(selectedIds)) throw new Error("Context pack selection must be an array of IDs");
45
+ if (selectedIds.length === 0) throw new Error("Context pack requires at least one selected ID");
46
+ if (selectedIds.some((id) => typeof id !== "string" || id.length === 0)) {
47
+ throw new Error("Context pack selection must contain non-empty string IDs");
48
+ }
49
+
50
+ const ids = [...selectedIds].sort(byString);
51
+ for (let index = 1; index < ids.length; index += 1) {
52
+ if (ids[index] === ids[index - 1]) throw new Error(`Context pack selection contains duplicate ID ${ids[index]}`);
53
+ }
54
+
55
+ for (const id of ids) {
56
+ const node = product.nodes.get(id);
57
+ if (node) {
58
+ if (node.kind === "Guarantee" && node.status !== "active") {
59
+ throw new Error(`Cannot select inactive model node ${id} (${node.status})`);
60
+ }
61
+ continue;
62
+ }
63
+ const lifecycleRedirect = product.lifecycleRedirects.get(id);
64
+ if (lifecycleRedirect) {
65
+ throw new Error(`Cannot select inactive model node ${id} (${lifecycleRedirect.status})`);
66
+ }
67
+ throw new Error(`Unknown model node ${id}`);
68
+ }
69
+ return ids;
70
+ }
71
+
72
+ function directEdges(edges, selectedIds) {
73
+ const edgeById = new Map();
74
+ for (const edge of edges) {
75
+ if ((selectedIds.has(edge.from) || selectedIds.has(edge.to)) && !edgeById.has(edge.id)) {
76
+ edgeById.set(edge.id, edge);
77
+ }
78
+ }
79
+ return [...edgeById.values()].sort((left, right) => byString(left.id, right.id));
80
+ }
81
+
82
+ function summarizeUnselectedEndpoints(product, selectedIds, edges) {
83
+ const neighborIds = new Set();
84
+ for (const edge of edges) {
85
+ if (!selectedIds.has(edge.from)) neighborIds.add(edge.from);
86
+ if (!selectedIds.has(edge.to)) neighborIds.add(edge.to);
87
+ }
88
+ return [...neighborIds].sort(byString).map((id) => summarizeNode(product.nodes.get(id), product.sourcePaths));
89
+ }
90
+
91
+ function renderNode(node, sourcePaths) {
92
+ return { ...node, sourcePath: sourcePaths.get(node.id) };
93
+ }
94
+
95
+ function summarizeNode(node, sourcePaths) {
96
+ if (!node) throw new Error("derived graph edge endpoint is missing from the query product");
97
+ return {
98
+ id: node.id,
99
+ kind: node.kind,
100
+ name: node.name ?? null,
101
+ sourcePath: sourcePaths.get(node.id),
102
+ };
103
+ }
104
+
105
+ function byString(left, right) {
106
+ return left.localeCompare(right);
107
+ }
@@ -0,0 +1,57 @@
1
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const configRelativePath = path.join(".ddduck", "config.json");
5
+
6
+ export const defaultConfigIgnore = Object.freeze(["vendor", "target", "build", "dist", "__pycache__"]);
7
+
8
+ export function findRepositoryRoot(startPath = process.cwd()) {
9
+ let current = path.resolve(startPath);
10
+ if (existsSync(current) && !lstatSync(current).isDirectory()) current = path.dirname(current);
11
+ while (true) {
12
+ if (existsSync(path.join(current, ".git"))) return current;
13
+ const parent = path.dirname(current);
14
+ if (parent === current) return path.resolve(startPath);
15
+ current = parent;
16
+ }
17
+ }
18
+
19
+ export function loadDdduckConfig(repositoryRoot) {
20
+ const configPath = path.join(repositoryRoot, configRelativePath);
21
+ if (!existsSync(configPath)) return null;
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(readFileSync(configPath, "utf8"));
25
+ } catch (error) {
26
+ throw new Error(`${configRelativePath}: invalid JSON: ${error.message}`);
27
+ }
28
+ if (parsed?.schemaVersion !== "1") throw new Error(`${configRelativePath}: schemaVersion must be "1"`);
29
+ if (typeof parsed.productRoot !== "string" || parsed.productRoot.length === 0) {
30
+ throw new Error(`${configRelativePath}: productRoot must be a non-empty string`);
31
+ }
32
+ if (path.isAbsolute(parsed.productRoot) || parsed.productRoot.split(/[\\/]+/).includes("..")) {
33
+ throw new Error(`${configRelativePath}: productRoot must stay inside the repository`);
34
+ }
35
+ const config = { schemaVersion: "1", productRoot: parsed.productRoot };
36
+ if (parsed.ignore !== undefined) {
37
+ if (!Array.isArray(parsed.ignore)) {
38
+ throw new Error(`${configRelativePath}: ignore must be an array of directory names`);
39
+ }
40
+ for (const entry of parsed.ignore) {
41
+ if (typeof entry !== "string" || entry.length === 0) {
42
+ throw new Error(`${configRelativePath}: ignore entries must be non-empty strings`);
43
+ }
44
+ if (entry.includes("/") || entry.includes("\\") || entry === "." || entry === "..") {
45
+ throw new Error(`${configRelativePath}: ignore entries must be plain directory names`);
46
+ }
47
+ }
48
+ config.ignore = parsed.ignore;
49
+ }
50
+ return config;
51
+ }
52
+
53
+ export function resolveConfiguredIgnores(repositoryRoot) {
54
+ const config = loadDdduckConfig(repositoryRoot);
55
+ if (!config || config.ignore === undefined) return [...defaultConfigIgnore];
56
+ return config.ignore;
57
+ }
@@ -0,0 +1,144 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import Ajv2020 from "ajv/dist/2020.js";
6
+
7
+ const auditSchema = JSON.parse(
8
+ readFileSync(
9
+ path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../schemas/fr-to-code-audit.schema.json"),
10
+ "utf8",
11
+ ),
12
+ );
13
+ const validateAudit = new Ajv2020({ allErrors: true, strict: true }).compile(auditSchema);
14
+
15
+ export function verifyFrToCodeAudit(record, sourceReader) {
16
+ const normalized = validateRecord(record);
17
+ requireReader(sourceReader);
18
+ const sourcesById = new Map(normalized.sources.map((source) => [source.id, source]));
19
+ const requirementSource = requireSource(sourcesById, normalized.requirement.sourceId);
20
+ const requirementPath = requireRelativePath(normalized.requirement.sourcePath, "requirement source path");
21
+ const requirementText = sourceReader.readFile(requirementSource, requirementPath);
22
+ requireText(requirementText, normalized.requirement.id, "requirement ID");
23
+ if (normalized.requirement.tracedGuarantee) {
24
+ requireText(requirementText, normalized.requirement.tracedGuarantee, "traced guarantee");
25
+ }
26
+
27
+ const productionAnchors = renderAnchors(normalized.productionAnchors, sourcesById, sourceReader);
28
+ const testAnchors = renderAnchors(normalized.testAnchors, sourcesById, sourceReader);
29
+ validateVerdict(normalized.verdict, productionAnchors, testAnchors);
30
+ return renderReport(normalized, productionAnchors, testAnchors);
31
+ }
32
+
33
+ function validateRecord(record) {
34
+ if (!validateAudit(record)) {
35
+ throw new Error(`FrToCodeAudit must match the audit schema: ${validateAudit.errors[0].message}`);
36
+ }
37
+ if (record.sources.some((source, index) => record.sources.findIndex(({ id }) => id === source.id) !== index)) {
38
+ throw new Error("FrToCodeAudit sources must have unique IDs");
39
+ }
40
+ for (const anchor of [...record.productionAnchors, ...record.testAnchors]) {
41
+ if (anchor.endLine < anchor.startLine) {
42
+ throw new Error("FrToCodeAudit anchor endLine must be greater than or equal to startLine");
43
+ }
44
+ }
45
+ return JSON.parse(JSON.stringify(record));
46
+ }
47
+
48
+ function requireReader(sourceReader) {
49
+ if (!sourceReader || typeof sourceReader.readFile !== "function") {
50
+ throw new Error("FrToCodeAudit sourceReader must provide readFile(source, relativePath)");
51
+ }
52
+ }
53
+
54
+ function requireSource(sourcesById, sourceId) {
55
+ const source = sourcesById.get(sourceId);
56
+ if (!source) throw new Error(`FrToCodeAudit references undeclared source ID ${sourceId}`);
57
+ return source;
58
+ }
59
+
60
+ function requireRelativePath(relativePath, label) {
61
+ if (path.posix.isAbsolute(relativePath) || relativePath.split("/").includes("..")) {
62
+ throw new Error(`FrToCodeAudit ${label} must be source-relative without lexical escapes`);
63
+ }
64
+ return relativePath;
65
+ }
66
+
67
+ function requireText(text, value, label) {
68
+ if (typeof text !== "string") throw new Error(`FrToCodeAudit source reader must return text for ${label}`);
69
+ if (!text.includes(value)) throw new Error(`FrToCodeAudit ${label} ${value} is missing from its source file`);
70
+ }
71
+
72
+ function renderAnchors(anchors, sourcesById, sourceReader) {
73
+ return anchors
74
+ .map((anchor) => {
75
+ const source = requireSource(sourcesById, anchor.sourceId);
76
+ const relativePath = requireRelativePath(anchor.path, "anchor path");
77
+ const text = sourceReader.readFile(source, relativePath);
78
+ if (typeof text !== "string")
79
+ throw new Error(`FrToCodeAudit source reader must return text for ${anchor.sourceId}:${relativePath}`);
80
+ const lines = text.split("\n");
81
+ const lineCount = lines.at(-1) === "" ? lines.length - 1 : lines.length;
82
+ if (anchor.endLine > lineCount) {
83
+ throw new Error(
84
+ `FrToCodeAudit anchor ${anchor.startLine}-${anchor.endLine} is outside ${anchor.sourceId}:${relativePath}`,
85
+ );
86
+ }
87
+ const excerpt = lines.slice(anchor.startLine - 1, anchor.endLine).join("\n");
88
+ return {
89
+ sourceId: anchor.sourceId,
90
+ path: relativePath,
91
+ startLine: anchor.startLine,
92
+ endLine: anchor.endLine,
93
+ excerptDigest: { algorithm: "sha256", value: digest(excerpt) },
94
+ };
95
+ })
96
+ .sort(byAnchor);
97
+ }
98
+
99
+ function validateVerdict(verdict, productionAnchors, testAnchors) {
100
+ if (verdict === "realized-and-tested" && (productionAnchors.length === 0 || testAnchors.length === 0)) {
101
+ throw new Error("FrToCodeAudit realized-and-tested requires production and test anchors");
102
+ }
103
+ if (verdict === "realized-untested" && (productionAnchors.length === 0 || testAnchors.length !== 0)) {
104
+ throw new Error("FrToCodeAudit realized-untested requires production anchors and no test anchors");
105
+ }
106
+ if (verdict === "unrealized" && (productionAnchors.length !== 0 || testAnchors.length !== 0)) {
107
+ throw new Error("FrToCodeAudit unrealized permits no production or test anchors");
108
+ }
109
+ }
110
+
111
+ function renderReport(record, productionAnchors, testAnchors) {
112
+ return {
113
+ schemaVersion: "1",
114
+ kind: "FrToCodeAuditReport",
115
+ audit: { id: record.id },
116
+ sources: [...record.sources].sort((left, right) => compareLexical(left.id, right.id)),
117
+ requirement: record.requirement,
118
+ coverage: record.coverage,
119
+ verdict: record.verdict,
120
+ productionAnchors,
121
+ testAnchors,
122
+ reviewerDisposition: record.reviewerDisposition,
123
+ diagnostics: [],
124
+ };
125
+ }
126
+
127
+ function digest(text) {
128
+ return createHash("sha256").update(text, "utf8").digest("hex");
129
+ }
130
+
131
+ function byAnchor(left, right) {
132
+ return (
133
+ compareLexical(left.sourceId, right.sourceId) ||
134
+ compareLexical(left.path, right.path) ||
135
+ left.startLine - right.startLine ||
136
+ left.endLine - right.endLine
137
+ );
138
+ }
139
+
140
+ function compareLexical(left, right) {
141
+ if (left < right) return -1;
142
+ if (left > right) return 1;
143
+ return 0;
144
+ }
@@ -0,0 +1,93 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { parseDocument } from "yaml";
4
+
5
+ const nodeDirectories = ["domains", "concepts", "relationships", "use-cases", "interfaces", "guarantees"];
6
+
7
+ export function detectProductLayout(rootPath) {
8
+ const root = path.resolve(rootPath);
9
+ return {
10
+ root,
11
+ productPath: path.join(root, "product.yaml"),
12
+ nodeDirectories,
13
+ decisionsDirectory: path.join(root, "decisions"),
14
+ generatedDirectory: path.join(root, "generated"),
15
+ };
16
+ }
17
+
18
+ export function loadProductNodes(layout) {
19
+ const nodes = new Map();
20
+ const nodeFiles = new Map();
21
+ const nodeSources = new Map();
22
+ const files = productFiles(layout);
23
+
24
+ for (const filePath of files) {
25
+ const source = readFileSync(filePath);
26
+ const node = parseYamlMapping(filePath, source.toString("utf8"));
27
+ if (nodes.has(node.id)) {
28
+ throw new Error(`duplicate model node ${node.id}`);
29
+ }
30
+ nodes.set(node.id, node);
31
+ nodeFiles.set(node.id, filePath);
32
+ nodeSources.set(node.id, source);
33
+ }
34
+
35
+ return { nodes, nodeFiles, nodeSources };
36
+ }
37
+
38
+ export function loadProductSnapshot(layout) {
39
+ const loaded = loadProductNodes(layout);
40
+ const nodes = Object.freeze([...loaded.nodes.values()].map((node) => deepFreeze(node)));
41
+ const canonicalPaths = Object.freeze(
42
+ Object.fromEntries(
43
+ [...loaded.nodeFiles].map(([id, filePath]) => [
44
+ id,
45
+ path.relative(layout.root, filePath).split(path.sep).join("/"),
46
+ ]),
47
+ ),
48
+ );
49
+ return Object.freeze({ nodes, canonicalPaths });
50
+ }
51
+
52
+ export function parseYamlMapping(filePath, source = readFileSync(filePath, "utf8")) {
53
+ const document = parseDocument(source, { keepSourceTokens: true, strict: true, uniqueKeys: true });
54
+ if (document.errors.length > 0) {
55
+ throw new Error(`invalid YAML in ${filePath}: ${document.errors.map((error) => error.message).join("; ")}`);
56
+ }
57
+ const value = document.toJSON();
58
+ if (!isPlainObject(value)) {
59
+ throw new Error(`model node file must contain a YAML mapping: ${filePath}`);
60
+ }
61
+ return value;
62
+ }
63
+
64
+ function productFiles(layout) {
65
+ return [
66
+ layout.productPath,
67
+ ...layout.nodeDirectories.flatMap((directory) => listYamlFiles(path.join(layout.root, "model", directory))),
68
+ ];
69
+ }
70
+
71
+ function listYamlFiles(directory) {
72
+ try {
73
+ return readdirSync(directory)
74
+ .filter((file) => file.endsWith(".yaml"))
75
+ .sort()
76
+ .map((file) => path.join(directory, file));
77
+ } catch (error) {
78
+ if (error.code === "ENOENT") {
79
+ return [];
80
+ }
81
+ throw error;
82
+ }
83
+ }
84
+
85
+ function isPlainObject(value) {
86
+ return value !== null && typeof value === "object" && !Array.isArray(value);
87
+ }
88
+
89
+ function deepFreeze(value) {
90
+ if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value;
91
+ for (const child of Object.values(value)) deepFreeze(child);
92
+ return Object.freeze(value);
93
+ }