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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ddduck",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "An opinionated DDD framework for authoring, validating, and visualizing machine-readable product-spec models.",
5
5
  "keywords": [
6
6
  "ddd",
@@ -23,7 +23,7 @@
23
23
  "url": "https://github.com/diegomarino/ddduck/issues"
24
24
  },
25
25
  "engines": {
26
- "node": ">=20"
26
+ "node": ">=22"
27
27
  },
28
28
  "type": "module",
29
29
  "files": [
@@ -32,6 +32,8 @@
32
32
  "policies/",
33
33
  "skills/",
34
34
  "docs/architecture.md",
35
+ "docs/definition-workflow.md",
36
+ "docs/templates/change-brief.md",
35
37
  "docs/getting-started.md",
36
38
  "docs/model.md",
37
39
  "docs/model-reference.md",
@@ -41,7 +43,8 @@
41
43
  "ddduck": "scripts/ddduck.mjs"
42
44
  },
43
45
  "scripts": {
44
- "check": "npm run check:model && npm run check:docs && npm run check:graph && npm run check:graph:svg && npm run lint && npm run lint:md && npm run format:check && npm test",
46
+ "check": "npm run check:static && npm test",
47
+ "check:static": "npm run check:model && npm run check:docs && npm run check:graph && npm run check:graph:svg && npm run lint && npm run lint:md && npm run format:check",
45
48
  "check:docs": "node scripts/check-generated-docs.mjs",
46
49
  "check:graph": "node scripts/check-generated-graph.mjs",
47
50
  "check:graph:svg": "node scripts/check-generated-graph-svg.mjs",
@@ -51,7 +54,7 @@
51
54
  "generate:graph": "node scripts/generate-graph.mjs",
52
55
  "generate:graph:svg": "node scripts/generate-graph-svg.mjs",
53
56
  "lint": "eslint .",
54
- "lint:md": "markdownlint-cli2 \"*.md\" \"docs/**/*.md\" \"examples/**/*.md\"",
57
+ "lint:md": "markdownlint-cli2 \"*.md\" \"docs/**/*.md\" \"examples/**/*.md\" \"#CHANGELOG.md\" \"#docs/audits\" \"#docs/superpowers\"",
55
58
  "test": "node --test test/*.test.mjs",
56
59
  "test:coverage": "node --test --experimental-test-coverage test/*.test.mjs",
57
60
  "pack:dry-run": "npm pack --dry-run --cache .npm-cache",
@@ -71,6 +74,6 @@
71
74
  "prettier": "^3.9.6"
72
75
  },
73
76
  "overrides": {
74
- "fast-uri": "^3.1.5"
77
+ "fast-uri": "^3.1.7"
75
78
  }
76
79
  }
@@ -0,0 +1,107 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://ddduck.local/schemas/model-diff.schema.json",
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "required": [
7
+ "schemaVersion",
8
+ "kind",
9
+ "before",
10
+ "after",
11
+ "scope",
12
+ "excludedScopes",
13
+ "added",
14
+ "removed",
15
+ "changed",
16
+ "relocated"
17
+ ],
18
+ "properties": {
19
+ "schemaVersion": { "const": "1" },
20
+ "kind": { "const": "ModelDiff" },
21
+ "before": { "$ref": "#/$defs/identity" },
22
+ "after": { "$ref": "#/$defs/identity" },
23
+ "scope": { "const": "canonical-yaml-only" },
24
+ "excludedScopes": { "const": ["decision-content", "evidence-content", "delivery-artifacts", "runtime"] },
25
+ "added": { "type": "array", "items": { "$ref": "#/$defs/record" } },
26
+ "removed": { "type": "array", "items": { "$ref": "#/$defs/record" } },
27
+ "changed": {
28
+ "type": "array",
29
+ "items": {
30
+ "type": "object",
31
+ "additionalProperties": false,
32
+ "required": ["id", "kind", "changes"],
33
+ "properties": {
34
+ "id": { "type": "string" },
35
+ "kind": { "type": "string" },
36
+ "changes": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/change" } }
37
+ }
38
+ }
39
+ },
40
+ "relocated": {
41
+ "type": "array",
42
+ "items": {
43
+ "type": "object",
44
+ "additionalProperties": false,
45
+ "required": ["id", "kind", "beforePath", "afterPath"],
46
+ "properties": {
47
+ "id": { "type": "string" },
48
+ "kind": { "type": "string" },
49
+ "beforePath": { "type": "string" },
50
+ "afterPath": { "type": "string" }
51
+ }
52
+ }
53
+ }
54
+ },
55
+ "$defs": {
56
+ "identity": {
57
+ "type": "object",
58
+ "additionalProperties": false,
59
+ "required": ["modelId", "sourceDigest"],
60
+ "properties": {
61
+ "modelId": { "type": "string", "pattern": "^model:" },
62
+ "sourceDigest": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
63
+ }
64
+ },
65
+ "record": {
66
+ "type": "object",
67
+ "additionalProperties": false,
68
+ "required": ["id", "kind", "sourcePath", "node"],
69
+ "properties": {
70
+ "id": { "type": "string" },
71
+ "kind": { "type": "string" },
72
+ "sourcePath": { "type": "string" },
73
+ "node": { "type": "object" }
74
+ }
75
+ },
76
+ "change": {
77
+ "type": "object",
78
+ "additionalProperties": false,
79
+ "required": ["path", "beforePresent", "afterPresent"],
80
+ "properties": {
81
+ "path": { "type": "string", "pattern": "^(?:/(?:[^~/]|~[01])*)+$" },
82
+ "beforePresent": { "type": "boolean" },
83
+ "afterPresent": { "type": "boolean" },
84
+ "before": {},
85
+ "after": {}
86
+ },
87
+ "allOf": [
88
+ {
89
+ "if": { "properties": { "beforePresent": { "const": true } } },
90
+ "then": { "required": ["before"] },
91
+ "else": { "not": { "required": ["before"] } }
92
+ },
93
+ {
94
+ "if": { "properties": { "afterPresent": { "const": true } } },
95
+ "then": { "required": ["after"] },
96
+ "else": { "not": { "required": ["after"] } }
97
+ },
98
+ {
99
+ "anyOf": [
100
+ { "properties": { "beforePresent": { "const": true } } },
101
+ { "properties": { "afterPresent": { "const": true } } }
102
+ ]
103
+ }
104
+ ]
105
+ }
106
+ }
107
+ }
@@ -1,5 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
4
+ * CLI for verifying FR-to-code audit records (`audit-fr-to-code --input
5
+ * <audit.yaml> --source-root <source-id>=<checkout> --json`). Reads the YAML
6
+ * audit record, binds each declared source to a local git checkout whose
7
+ * origin URL and pinned 40-hex revision must match, reads anchored files via
8
+ * `git show` at that exact revision (regular-file tree entries only), and
9
+ * delegates verdict verification to lib/fr-to-code-audit.mjs, emitting one
10
+ * JSON report on stdout.
11
+ */
12
+
3
13
  import { spawnSync } from "node:child_process";
4
14
  import { readFileSync } from "node:fs";
5
15
  import { fileURLToPath } from "node:url";
@@ -7,6 +17,13 @@ import { parseDocument } from "yaml";
7
17
  import { verifyFrToCodeAudit } from "./lib/fr-to-code-audit.mjs";
8
18
  import { CliUsageError, parseCommandArgs, renderHelp, writeCliError } from "./lib/cli-contract.mjs";
9
19
 
20
+ /**
21
+ * Parse arguments, verify the audit record against its pinned sources, and
22
+ * write the JSON report.
23
+ * @param {string[]} args - CLI arguments (--input, repeatable --source-root, --json).
24
+ * @param {{stdout?: {write: (chunk: string) => unknown}}} [io] - Output stream override for tests.
25
+ * @returns {void}
26
+ */
10
27
  export function runFrToCodeAudit(args, { stdout = process.stdout } = {}) {
11
28
  if (args.length === 1 && args[0] === "--help") {
12
29
  stdout.write(renderHelp("audit-fr-to-code"));
@@ -61,6 +78,14 @@ function validateDeclaredSources(record, filePath) {
61
78
  }
62
79
  }
63
80
 
81
+ /**
82
+ * Build the source reader that serves file contents from pinned git revisions.
83
+ * Requires exactly one --source-root mapping per declared source and verifies
84
+ * each checkout's origin URL and revision presence up front.
85
+ * @param {{sources: {id: string, repository: string, revision: string}[]}} record - The validated audit record.
86
+ * @param {{id: string, root: string}[]} sourceRoots - Parsed --source-root mappings.
87
+ * @returns {{readFile: (source: object, relativePath: string) => string}} Reader handed to verifyFrToCodeAudit.
88
+ */
64
89
  function createGitSourceReader(record, sourceRoots) {
65
90
  if (!Array.isArray(record.sources)) throw new Error("audit input must declare sources");
66
91
  const declaredIds = new Set(record.sources.map((source) => source?.id));
@@ -1,5 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
4
+ * Freshness gate for the generated docs view: rebuilds
5
+ * generated/docs/model-overview.md from the canonical YAML and compares it
6
+ * byte-for-byte against the committed file. Consumed by `ddduck check` and the
7
+ * staged operation runner (both import checkGeneratedDocs); also runnable
8
+ * standalone, where a stale view exits 1 with a regenerate remedy.
9
+ */
10
+
3
11
  import { readFileSync } from "node:fs";
4
12
  import path from "node:path";
5
13
  import { fileURLToPath } from "node:url";
@@ -8,10 +16,21 @@ import { resolveProductRoot } from "./lib/product-root-resolver.mjs";
8
16
 
9
17
  const outputPath = path.join("generated", "docs", "model-overview.md");
10
18
 
11
- function staleMessage(root) {
12
- return `${outputPath} is missing or stale; run ddduck generate --root ${root}`;
19
+ // The message states the fact only, so callers that add their own next-action
20
+ // line never state the remedy twice; `remedy` carries the regenerate hint for
21
+ // callers with no next-action surface (the standalone gate below).
22
+ function staleError(root) {
23
+ const error = new Error(`${outputPath} is missing or stale`);
24
+ error.remedy = `run ddduck generate --root ${root}`;
25
+ return error;
13
26
  }
14
27
 
28
+ /**
29
+ * Throw if generated/docs/model-overview.md is missing or differs from the
30
+ * output rebuilt from the current canonical YAML.
31
+ * @param {string} rootPath - Product root path.
32
+ * @returns {void}
33
+ */
15
34
  export function checkGeneratedDocs(rootPath) {
16
35
  const root = path.resolve(rootPath);
17
36
  const expected = buildModelOverview(root);
@@ -19,10 +38,10 @@ export function checkGeneratedDocs(rootPath) {
19
38
  try {
20
39
  actual = readFileSync(path.join(root, outputPath), "utf8");
21
40
  } catch (error) {
22
- if (error.code === "ENOENT") throw new Error(staleMessage(root));
41
+ if (error.code === "ENOENT") throw staleError(root);
23
42
  throw error;
24
43
  }
25
- if (actual !== expected) throw new Error(staleMessage(root));
44
+ if (actual !== expected) throw staleError(root);
26
45
  }
27
46
 
28
47
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
@@ -31,7 +50,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
31
50
  checkGeneratedDocs(resolveProductRoot({ explicitRoot: options.root }));
32
51
  if (options.verbose) console.log("generated docs ok");
33
52
  } catch (error) {
34
- console.error(error.message);
53
+ console.error(error.remedy ? `${error.message}; ${error.remedy}` : error.message);
35
54
  process.exit(1);
36
55
  }
37
56
  }
@@ -1,5 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
4
+ * Freshness gate for the canonical SVG view generated/graph/model-graph.svg.
5
+ * Rendering uses the async Graphviz WASM engine, so unlike the docs and
6
+ * JSON/NDJSON gates this one is always executed as a child process by
7
+ * `ddduck check`, the staged operation runner, and query freshness — never
8
+ * imported into their synchronous flows. Standalone runs exit 1 on a missing
9
+ * or stale SVG with a regenerate remedy.
10
+ */
11
+
3
12
  import { readFileSync } from "node:fs";
4
13
  import path from "node:path";
5
14
  import { fileURLToPath } from "node:url";
@@ -7,13 +16,22 @@ import { buildModelGraph } from "./generate-graph.mjs";
7
16
  import { buildModelGraphSvg, svgOutputPath } from "./generate-graph-svg.mjs";
8
17
  import { resolveProductRoot } from "./lib/product-root-resolver.mjs";
9
18
 
10
- function staleMessage(root) {
11
- return `${svgOutputPath} is missing or stale; run ddduck generate --root ${root}`;
19
+ // The message states the fact only, so callers that add their own next-action
20
+ // line never state the remedy twice; `remedy` carries the regenerate hint for
21
+ // callers with no next-action surface (the standalone gate below).
22
+ function staleError(root) {
23
+ const error = new Error(`${svgOutputPath} is missing or stale`);
24
+ error.remedy = `run ddduck generate --root ${root}`;
25
+ return error;
12
26
  }
13
27
 
14
- // Pin the canonical SVG byte-for-byte: rebuild it from the source model and
15
- // compare against the committed file. Deterministic because @hpcc-js/wasm is
16
- // version-pinned in the lockfile (the SVG embeds its Graphviz version).
28
+ /**
29
+ * Pin the canonical SVG byte-for-byte: rebuild it from the source model and
30
+ * compare against the committed file. Deterministic because @hpcc-js/wasm is
31
+ * version-pinned in the lockfile (the SVG embeds its Graphviz version).
32
+ * @param {string} rootPath - Product root path.
33
+ * @returns {Promise<void>} Rejects when the SVG is missing or stale.
34
+ */
17
35
  export async function checkGeneratedGraphSvg(rootPath) {
18
36
  const root = path.resolve(rootPath);
19
37
  const expected = await buildModelGraphSvg(buildModelGraph(root));
@@ -21,10 +39,10 @@ export async function checkGeneratedGraphSvg(rootPath) {
21
39
  try {
22
40
  actual = readFileSync(path.join(root, svgOutputPath), "utf8");
23
41
  } catch (error) {
24
- if (error.code === "ENOENT") throw new Error(staleMessage(root));
42
+ if (error.code === "ENOENT") throw staleError(root);
25
43
  throw error;
26
44
  }
27
- if (actual !== expected) throw new Error(staleMessage(root));
45
+ if (actual !== expected) throw staleError(root);
28
46
  }
29
47
 
30
48
  function parseArgs(args) {
@@ -54,7 +72,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
54
72
  await checkGeneratedGraphSvg(resolveProductRoot({ explicitRoot: options.root }));
55
73
  if (options.verbose) console.log("generated graph svg ok");
56
74
  } catch (error) {
57
- console.error(error.message);
75
+ console.error(error.remedy ? `${error.message}; ${error.remedy}` : error.message);
58
76
  process.exit(1);
59
77
  }
60
78
  }
@@ -1,15 +1,35 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
4
+ * Freshness gate for the generated graph views: rebuilds
5
+ * generated/graph/model-graph.json and .ndjson from the canonical YAML and
6
+ * compares each byte-for-byte against the committed files. Consumed by
7
+ * `ddduck check` and the staged operation runner (both import
8
+ * checkGeneratedGraph); also runnable standalone, where a stale view exits 1
9
+ * with a regenerate remedy.
10
+ */
11
+
3
12
  import { readFileSync } from "node:fs";
4
13
  import path from "node:path";
5
14
  import { fileURLToPath } from "node:url";
6
15
  import { buildModelGraphOutputs, jsonOutputPath, ndjsonOutputPath } from "./generate-graph.mjs";
7
16
  import { resolveProductRoot } from "./lib/product-root-resolver.mjs";
8
17
 
9
- function staleMessage(relativePath, root) {
10
- return `${relativePath} is missing or stale; run ddduck generate --root ${root}`;
18
+ // The message states the fact only, so callers that add their own next-action
19
+ // line never state the remedy twice; `remedy` carries the regenerate hint for
20
+ // callers with no next-action surface (the standalone gate below).
21
+ function staleError(relativePath, root) {
22
+ const error = new Error(`${relativePath} is missing or stale`);
23
+ error.remedy = `run ddduck generate --root ${root}`;
24
+ return error;
11
25
  }
12
26
 
27
+ /**
28
+ * Throw if model-graph.json or model-graph.ndjson is missing or differs from
29
+ * the outputs rebuilt from the current canonical YAML.
30
+ * @param {string} rootPath - Product root path.
31
+ * @returns {void}
32
+ */
13
33
  export function checkGeneratedGraph(rootPath) {
14
34
  const root = path.resolve(rootPath);
15
35
  const expected = buildModelGraphOutputs(root);
@@ -23,13 +43,13 @@ function checkOutput(root, relativePath, expected) {
23
43
  actual = readFileSync(path.join(root, relativePath), "utf8");
24
44
  } catch (error) {
25
45
  if (error.code === "ENOENT") {
26
- throw new Error(staleMessage(relativePath, root));
46
+ throw staleError(relativePath, root);
27
47
  }
28
48
  throw error;
29
49
  }
30
50
 
31
51
  if (actual !== expected) {
32
- throw new Error(staleMessage(relativePath, root));
52
+ throw staleError(relativePath, root);
33
53
  }
34
54
  }
35
55
 
@@ -60,7 +80,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
60
80
  checkGeneratedGraph(resolveProductRoot({ explicitRoot: options.root }));
61
81
  if (options.verbose) console.log("generated graph ok");
62
82
  } catch (error) {
63
- console.error(error.message);
83
+ console.error(error.remedy ? `${error.message}; ${error.remedy}` : error.message);
64
84
  process.exit(1);
65
85
  }
66
86
  }
@@ -1,5 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
4
+ * The model checker behind `ddduck check`: validates a product root's canonical
5
+ * YAML against the framework schemas, referential integrity (single Model,
6
+ * ownership, decisions), evidence anchors, guarantee lifecycle rules, and the
7
+ * executable policy checks (ownership, documentation model-reference
8
+ * resolution). With --base it also enforces guarantee history retention against
9
+ * a previous product root. Exports validateProduct for the operation runner,
10
+ * query loader, and root resolver; runs standalone as a CLI (exit 1 on errors).
11
+ */
12
+
3
13
  import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
4
14
  import path from "node:path";
5
15
  import { fileURLToPath } from "node:url";
@@ -7,7 +17,7 @@ import Ajv2020 from "ajv/dist/2020.js";
7
17
  import { unified } from "unified";
8
18
  import remarkParse from "remark-parse";
9
19
  import { parseDocument } from "yaml";
10
- import { detectProductLayout, loadProductNodes } from "./lib/product-layout.mjs";
20
+ import { detectProductLayout, loadProductNodes, nodeDirectoryKinds } from "./lib/product-layout.mjs";
11
21
  import { shouldIgnoreScanEntry } from "./lib/scan-ignore.mjs";
12
22
  import { findRepositoryRoot, resolveConfiguredIgnores } from "./lib/ddduck-config.mjs";
13
23
 
@@ -18,7 +28,7 @@ const executableRuleChecks = new Set([
18
28
  ]);
19
29
 
20
30
  const markdownReferencePattern =
21
- /\b(?:(?:model|domain|concept|rel|rule|use-case|interface):[a-z0-9][a-z0-9-]*|[A-Z][A-Z0-9-]+-(?:INV|AC)-[0-9]+|ADR-[0-9]{3})\b/g;
31
+ /\b(?:(?:model|domain|concept|rel|use-case|interface):[a-z0-9][a-z0-9-]*|[A-Z][A-Z0-9-]+-(?:INV|AC)-[0-9]+|ADR-[0-9]{3})\b/g;
22
32
 
23
33
  const defaultFrameworkRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
24
34
 
@@ -58,6 +68,11 @@ function parseArgs(args) {
58
68
  return parsed;
59
69
  }
60
70
 
71
+ /**
72
+ * One validation run over a product root: loads nodes and decisions, then
73
+ * accumulates every diagnostic in `errors` (file-relative, one per line) while
74
+ * counting nodes, references, and executed policies for the verbose summary.
75
+ */
61
76
  class ProductCheck {
62
77
  constructor(rootPath, frameworkPath, basePath, documentationRoots, { sourceOnly = false } = {}) {
63
78
  this.root = rootPath;
@@ -78,6 +93,7 @@ class ProductCheck {
78
93
  this.loadDecisions();
79
94
  this.loadNodes();
80
95
  this.validateSchemas();
96
+ this.validateNodeDirectories();
81
97
  this.validateEvidenceAnchors();
82
98
  this.validateReferences();
83
99
  this.validateGuaranteeLifecycle();
@@ -147,6 +163,22 @@ class ProductCheck {
147
163
  }
148
164
  }
149
165
 
166
+ validateNodeDirectories() {
167
+ for (const [id, filePath] of this.nodeFiles) {
168
+ const segments = path.relative(this.root, filePath).split(path.sep);
169
+ if (segments[0] !== "model") continue;
170
+ const expectedKind = nodeDirectoryKinds.get(segments[1]);
171
+ if (!expectedKind) continue;
172
+ const node = this.nodes.get(id);
173
+ if (node.kind !== expectedKind) {
174
+ this.addError(
175
+ id,
176
+ `node kind ${node.kind} does not match directory model/${segments[1]} (expected ${expectedKind})`,
177
+ );
178
+ }
179
+ }
180
+ }
181
+
150
182
  validateReferences() {
151
183
  const models = [...this.nodes.values()].filter((node) => node.kind === "Model");
152
184
  if (models.length !== 1) {
@@ -195,6 +227,15 @@ class ProductCheck {
195
227
  node.id,
196
228
  `evidence anchor path must resolve to an existing regular file below product root: ${evidence.path}`,
197
229
  );
230
+ continue;
231
+ }
232
+ // Markdown targets must contain the anchor text; non-Markdown anchors
233
+ // stay free-form labels.
234
+ if (evidence.path.endsWith(".md") && typeof evidence.anchor === "string") {
235
+ const content = readFileSync(path.resolve(this.root, evidence.path), "utf8");
236
+ if (!content.includes(evidence.anchor)) {
237
+ this.addError(node.id, `evidence anchor not found in ${evidence.path}: ${evidence.anchor}`);
238
+ }
198
239
  }
199
240
  }
200
241
  }
@@ -207,8 +248,17 @@ class ProductCheck {
207
248
  if (successors.length === 0) this.addError(node.id, "split guarantee requires active successors");
208
249
  for (const id of successors) {
209
250
  const successor = this.nodes.get(id);
210
- if (!successor || successor.kind !== "Guarantee" || successor.status !== "active")
211
- this.addError(node.id, `split successor must be an active guarantee ${id}`);
251
+ if (!successor || successor.kind !== "Guarantee") {
252
+ this.addError(node.id, `split successor must be a guarantee ${id}`);
253
+ continue;
254
+ }
255
+ // A successor may leave `active` through its own authorized lifecycle
256
+ // transition; its record must then carry a registered lifecycleDecision.
257
+ if (successor.status !== "active" && !this.decisions.has(successor.lifecycleDecision))
258
+ this.addError(
259
+ node.id,
260
+ `split successor must be an active guarantee or carry a registered lifecycleDecision ${id}`,
261
+ );
212
262
  }
213
263
  }
214
264
  if (node.kind === "DomainInterface" || node.kind === "UseCase") {
@@ -269,9 +319,35 @@ class ProductCheck {
269
319
  this.errors.push(`base: ${error.message}`);
270
320
  return;
271
321
  }
322
+ const legalStatusTransitions = new Map([
323
+ ["active", ["active", "split", "retired"]],
324
+ ["split", ["split"]],
325
+ ["retired", ["retired"]],
326
+ ]);
272
327
  for (const baseNode of baseNodes.values()) {
273
- if (baseNode.kind === "Guarantee" && !this.nodes.has(baseNode.id)) {
328
+ if (baseNode.kind !== "Guarantee") continue;
329
+ const current = this.nodes.get(baseNode.id);
330
+ if (!current) {
274
331
  this.errors.push(`guarantee disappeared from the product: ${baseNode.id}`);
332
+ continue;
333
+ }
334
+ if (!(legalStatusTransitions.get(baseNode.status) ?? []).includes(current.status)) {
335
+ this.addError(baseNode.id, `illegal guarantee status transition ${baseNode.status} -> ${current.status}`);
336
+ continue;
337
+ }
338
+ if (baseNode.status === "split" || baseNode.status === "retired") {
339
+ if (current.lifecycleDecision !== baseNode.lifecycleDecision)
340
+ this.addError(
341
+ baseNode.id,
342
+ `closed guarantee must keep lifecycleDecision ${baseNode.lifecycleDecision} from base`,
343
+ );
344
+ const baseSuccessors = asArray(baseNode.successors);
345
+ const currentSuccessors = asArray(current.successors);
346
+ if (
347
+ baseSuccessors.length !== currentSuccessors.length ||
348
+ baseSuccessors.some((id, index) => currentSuccessors[index] !== id)
349
+ )
350
+ this.addError(baseNode.id, `closed guarantee must keep successors ${baseSuccessors.join(", ")} from base`);
275
351
  }
276
352
  }
277
353
  }
@@ -373,6 +449,14 @@ function listFiles(directory, pattern) {
373
449
  }
374
450
  }
375
451
 
452
+ /**
453
+ * Recursively list Markdown files under a documentation root, skipping ignored
454
+ * scan entries, nested git checkouts, nested product roots, and the audit and
455
+ * superpowers doc areas.
456
+ * @param {string} rootPath - Documentation root to walk.
457
+ * @param {string[]} [ignoredEntryNames] - Extra directory names to skip (from .ddduck config).
458
+ * @returns {string[]} Sorted absolute Markdown file paths.
459
+ */
376
460
  function listMarkdownFiles(rootPath, ignoredEntryNames = []) {
377
461
  const files = [];
378
462
 
@@ -454,6 +538,12 @@ function executePolicyChecks(check, checksToRun, { includeDocumentation = true }
454
538
  }
455
539
  }
456
540
 
541
+ /**
542
+ * Validate a product root and return the completed check (inspect `.errors`).
543
+ * @param {string} rootPath - Product root to validate.
544
+ * @param {{baseRoot?: string, includeDocumentation?: boolean, documentationRoots?: string[], sourceOnly?: boolean}} [options] - Base product for history retention, documentation scope, and whether generated docs are scanned.
545
+ * @returns {ProductCheck} The finished check with errors and counters.
546
+ */
457
547
  export function validateProduct(
458
548
  rootPath,
459
549
  { baseRoot, includeDocumentation = true, documentationRoots, sourceOnly = false } = {},