ddduck 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +11 -6
  2. package/docs/architecture.md +5 -2
  3. package/docs/cli.md +135 -53
  4. package/docs/definition-workflow.md +130 -0
  5. package/docs/getting-started.md +21 -48
  6. package/docs/model-reference.md +4 -0
  7. package/docs/model.md +1 -0
  8. package/docs/templates/change-brief.md +48 -0
  9. package/package.json +8 -5
  10. package/schemas/model-diff.schema.json +107 -0
  11. package/scripts/audit-fr-to-code.mjs +25 -0
  12. package/scripts/check-generated-docs.mjs +24 -5
  13. package/scripts/check-generated-graph-svg.mjs +26 -8
  14. package/scripts/check-generated-graph.mjs +25 -5
  15. package/scripts/check-model.mjs +95 -5
  16. package/scripts/ddduck.mjs +216 -22
  17. package/scripts/generate-agent-readiness-report.mjs +8 -0
  18. package/scripts/generate-docs.mjs +29 -3
  19. package/scripts/generate-graph-svg.mjs +53 -16
  20. package/scripts/generate-graph.mjs +26 -1
  21. package/scripts/lib/agent-readiness-evals.mjs +32 -0
  22. package/scripts/lib/agent-readiness-report.mjs +14 -0
  23. package/scripts/lib/cli-contract.mjs +65 -15
  24. package/scripts/lib/context-pack.mjs +49 -1
  25. package/scripts/lib/ddduck-config.mjs +31 -1
  26. package/scripts/lib/fr-to-code-audit.mjs +30 -0
  27. package/scripts/lib/product-authoring.mjs +52 -0
  28. package/scripts/lib/product-diff.mjs +155 -0
  29. package/scripts/lib/product-layout.mjs +42 -1
  30. package/scripts/lib/product-operation.mjs +140 -22
  31. package/scripts/lib/product-paths.mjs +16 -0
  32. package/scripts/lib/product-query.mjs +102 -20
  33. package/scripts/lib/product-root-resolver.mjs +40 -0
  34. package/scripts/lib/scan-ignore.mjs +14 -3
  35. package/scripts/lib/skill-installer.mjs +227 -39
  36. package/scripts/query-model.mjs +18 -7
  37. package/scripts/run-agent-readiness-evals.mjs +18 -2
  38. package/skills/update-ddduck-specs/SKILL.md +25 -83
  39. package/skills/update-ddduck-specs/references/authoring-and-verification.md +60 -0
  40. package/skills/update-ddduck-specs/references/modeling-and-evidence.md +56 -0
  41. package/skills/update-ddduck-specs/references/reviewing-changes.md +45 -0
@@ -1,5 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
4
+ * Entry point for the `ddduck` CLI (the package bin). Dispatches the commands
5
+ * init, check, generate, query, install, and the guarantee lifecycle commands
6
+ * create/move/split/retire. Mutations run through the locked, staged operation
7
+ * runner in lib/product-operation.mjs; init publishes a fresh product root via
8
+ * PID-stamped staging; check delegates to check-model.mjs plus the generated
9
+ * freshness gates. Errors leave through writeCliError with a Next: action line.
10
+ */
11
+
3
12
  import {
4
13
  existsSync,
5
14
  mkdirSync,
@@ -10,6 +19,7 @@ import {
10
19
  renameSync,
11
20
  rmSync,
12
21
  rmdirSync,
22
+ statSync,
13
23
  writeFileSync,
14
24
  } from "node:fs";
15
25
  import path from "node:path";
@@ -20,13 +30,23 @@ import { writeModelOverview } from "./generate-docs.mjs";
20
30
  import { writeModelGraph } from "./generate-graph.mjs";
21
31
  import { checkGeneratedDocs } from "./check-generated-docs.mjs";
22
32
  import { checkGeneratedGraph } from "./check-generated-graph.mjs";
23
- import { assertProductNotBusy, findLeftoverOperationState, runProductOperation } from "./lib/product-operation.mjs";
33
+ import {
34
+ assertProductNotBusy,
35
+ findLeftoverOperationState,
36
+ generatedPaths,
37
+ isProcessAlive,
38
+ runProductOperation,
39
+ validationFailureError,
40
+ } from "./lib/product-operation.mjs";
24
41
  import { resolveContainedOutput } from "./lib/product-paths.mjs";
25
42
  import { initStagingPrefix, resolveInitDestination, resolveProductRoot } from "./lib/product-root-resolver.mjs";
26
- import { defaultConfigIgnore, findRepositoryRoot } from "./lib/ddduck-config.mjs";
43
+ import { defaultConfigIgnore, findRepositoryRoot, loadDdduckConfig } from "./lib/ddduck-config.mjs";
27
44
  import { installSkill } from "./lib/skill-installer.mjs";
28
45
  import { runQuery } from "./query-model.mjs";
29
46
  import { CliUsageError, parseCommandArgs, renderHelp, writeCliError } from "./lib/cli-contract.mjs";
47
+ import { buildAuthoringPlan } from "./lib/product-authoring.mjs";
48
+ import { parseYamlMapping } from "./lib/product-layout.mjs";
49
+ import { compareProductRoots, renderProductDiff } from "./lib/product-diff.mjs";
30
50
 
31
51
  const frameworkRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
32
52
 
@@ -38,6 +58,11 @@ try {
38
58
  writeCliError(error, { nextAction: nextActionFor(cliArgs) });
39
59
  }
40
60
 
61
+ /**
62
+ * Dispatch one parsed CLI invocation to its command handler.
63
+ * @param {string[]} args - Raw CLI arguments (process.argv minus node and script).
64
+ * @returns {void}
65
+ */
41
66
  function run(args) {
42
67
  if (args.length === 1 && args[0] === "--help") {
43
68
  process.stdout.write(renderHelp());
@@ -46,7 +71,7 @@ function run(args) {
46
71
  const [command, ...commandArgs] = args;
47
72
  if (
48
73
  !command ||
49
- !["init", "check", "generate", "query", "install", "create", "move", "split", "retire"].includes(command)
74
+ !["init", "check", "generate", "query", "diff", "install", "create", "move", "split", "retire"].includes(command)
50
75
  ) {
51
76
  throw new CliUsageError(`Unknown command ${command ?? "(missing)"}`);
52
77
  }
@@ -71,16 +96,36 @@ function run(args) {
71
96
  runQuery(commandArgs);
72
97
  return;
73
98
  }
99
+ if (command === "diff") {
100
+ const { options } = parseCommandArgs(commandArgs, {
101
+ options: { base: { value: true }, root: { value: true }, json: { value: false } },
102
+ });
103
+ const base = requiredOption(options, "base", "diff requires --base <previous-product-root>");
104
+ const root = resolveProductRoot({ explicitRoot: options.root });
105
+ const report = compareProductRoots(path.resolve(base), root);
106
+ process.stdout.write(`${options.json ? JSON.stringify(report) : renderProductDiff(report)}\n`);
107
+ return;
108
+ }
74
109
  if (command === "install") {
75
110
  install(commandArgs);
76
111
  return;
77
112
  }
113
+ if (command === "create" && ["domain", "concept", "use-case"].includes(commandArgs[0])) {
114
+ createNode(commandArgs);
115
+ return;
116
+ }
78
117
  if (["create", "move", "split", "retire"].includes(command)) {
79
118
  transitionGuarantee(command, commandArgs);
80
119
  return;
81
120
  }
82
121
  }
83
122
 
123
+ /**
124
+ * Implement `ddduck install skill update-ddduck-specs`: install the bundled
125
+ * host skill bundle and .ddduck/agent-skills.lock.json into --repo.
126
+ * @param {string[]} args - Arguments after the `install` command word.
127
+ * @returns {void}
128
+ */
84
129
  function install(args) {
85
130
  const { positionals, options } = parseCommandArgs(args, {
86
131
  positionals: { min: 2, max: 2 },
@@ -92,6 +137,13 @@ function install(args) {
92
137
  }
93
138
  const packageVersion = JSON.parse(readFileSync(path.join(frameworkRoot, "package.json"), "utf8")).version;
94
139
  const repository = path.resolve(options.repo ?? process.cwd());
140
+ // A typo'd --repo must fail instead of silently manufacturing a directory
141
+ // tree (and a lock) at the wrong path while the real repository gets nothing.
142
+ if (!existsSync(repository) || !statSync(repository).isDirectory()) {
143
+ throw new CliUsageError(`install requires an existing repository directory: ${repository}`, {
144
+ nextAction: "Pass --repo <existing-repository-root> and retry.",
145
+ });
146
+ }
95
147
  const result = installSkill({
96
148
  repository,
97
149
  skillName,
@@ -104,6 +156,14 @@ function install(args) {
104
156
  );
105
157
  }
106
158
 
159
+ /**
160
+ * Implement `ddduck init`: build a new product root (canonical directories,
161
+ * product.yaml, all generated views) in a PID-stamped staging directory beside
162
+ * the destination, then publish it atomically via rename and write
163
+ * .ddduck/config.json if absent.
164
+ * @param {string[]} args - Arguments after the `init` command word.
165
+ * @returns {{result: object, json: boolean}} The operation result and whether --json was requested.
166
+ */
107
167
  function initialize(args) {
108
168
  const { positionals, options } = parseCommandArgs(args, {
109
169
  positionals: { min: 0, max: 1 },
@@ -112,7 +172,9 @@ function initialize(args) {
112
172
  const destination = resolveInitDestination({ explicitDestination: positionals[0] });
113
173
  const productId = requiredOption(options, "id", "init requires --id model:<product-id>");
114
174
  if (!/^model:[a-z0-9][a-z0-9-]*$/.test(productId)) {
115
- throw new Error(`Invalid product ID ${JSON.stringify(productId)}`);
175
+ throw new Error(
176
+ `Invalid product ID ${JSON.stringify(productId)}; expected model:<lowercase-slug> (for example model:library)`,
177
+ );
116
178
  }
117
179
 
118
180
  mkdirSync(path.dirname(destination), { recursive: true });
@@ -121,14 +183,19 @@ function initialize(args) {
121
183
  }
122
184
 
123
185
  // Stage names embed the destination so concurrent inits to sibling
124
- // destinations never sweep each other's live staging directories.
186
+ // destinations never sweep each other's live staging directories, and the
187
+ // creating PID so concurrent inits to the SAME destination only sweep stages
188
+ // whose creator is dead (mirroring the mutation lock's reclaim rule). Stages
189
+ // without a parseable live PID are interrupted-init debris and get swept.
125
190
  const destinationStagePrefix = `${initStagingPrefix}${path.basename(destination)}-`;
126
191
  for (const entry of readdirSync(path.dirname(destination))) {
127
- if (entry.startsWith(destinationStagePrefix)) {
128
- rmSync(path.join(path.dirname(destination), entry), { recursive: true, force: true });
129
- }
192
+ if (!entry.startsWith(destinationStagePrefix)) continue;
193
+ const stagePid = Number.parseInt(entry.slice(destinationStagePrefix.length).match(/^(\d+)-/)?.[1] ?? "", 10);
194
+ if (Number.isInteger(stagePid) && stagePid > 0 && isProcessAlive(stagePid)) continue;
195
+ rmSync(path.join(path.dirname(destination), entry), { recursive: true, force: true });
130
196
  }
131
- const stagingRoot = mkdtempSync(path.join(path.dirname(destination), destinationStagePrefix));
197
+ const stagingRoot = mkdtempSync(path.join(path.dirname(destination), `${destinationStagePrefix}${process.pid}-`));
198
+ let config;
132
199
  try {
133
200
  for (const directory of ["domains", "concepts", "relationships", "use-cases", "interfaces", "guarantees"]) {
134
201
  mkdirSync(resolveContainedOutput(stagingRoot, path.join("model", directory)), { recursive: true });
@@ -145,10 +212,22 @@ function initialize(args) {
145
212
  decisions: [],
146
213
  });
147
214
  refreshDerivedOutput(stagingRoot);
148
- if (existsSync(destination)) rmdirSync(destination);
149
- renameSync(stagingRoot, destination);
150
215
  try {
151
- writeConfigIfAbsent(destination);
216
+ if (existsSync(destination)) rmdirSync(destination);
217
+ renameSync(stagingRoot, destination);
218
+ } catch (error) {
219
+ // A concurrent init to the same destination can publish between the
220
+ // emptiness check above and this rename; name the collision instead of
221
+ // surfacing the raw filesystem error.
222
+ if (error.code === "ENOTEMPTY" || error.code === "EEXIST") {
223
+ throw new Error(
224
+ `Refusing to initialize non-empty directory ${destination}; another init published it concurrently`,
225
+ );
226
+ }
227
+ throw error;
228
+ }
229
+ try {
230
+ config = writeConfigIfAbsent(destination);
152
231
  } catch (error) {
153
232
  // Writing the config is the final publish step. If it fails (for example a
154
233
  // regular file already occupies the .ddduck config path), roll back the
@@ -160,6 +239,7 @@ function initialize(args) {
160
239
  } finally {
161
240
  rmSync(stagingRoot, { recursive: true, force: true });
162
241
  }
242
+ if (!config.created) warnPinnedRepositoryDefault(config, destination);
163
243
  return {
164
244
  json: options.json,
165
245
  result: {
@@ -167,15 +247,46 @@ function initialize(args) {
167
247
  root: realpathSync(destination),
168
248
  affectedIds: [productId],
169
249
  canonicalPaths: ["product.yaml"],
170
- generatedPaths: [
171
- "generated/docs/model-overview.md",
172
- "generated/graph/model-graph.json",
173
- "generated/graph/model-graph.ndjson",
174
- ],
250
+ generatedPaths: [...generatedPaths],
251
+ ...(config.created ? { configPath: config.configPath } : {}),
175
252
  },
176
253
  };
177
254
  }
178
255
 
256
+ // A pre-existing repository config keeps selecting its own product root; a
257
+ // second init must say so or every rootless command silently addresses the
258
+ // other product.
259
+ function warnPinnedRepositoryDefault(config, destination) {
260
+ let configuredRoot;
261
+ try {
262
+ const loaded = loadDdduckConfig(config.repositoryRoot);
263
+ if (!loaded) return;
264
+ configuredRoot = path.resolve(config.repositoryRoot, loaded.productRoot);
265
+ } catch {
266
+ return; // An unreadable config surfaces on the next root resolution.
267
+ }
268
+ if (canonicalPath(configuredRoot) === canonicalPath(destination)) return;
269
+ process.stderr.write(
270
+ `init: ${config.configPath} still selects ${configuredRoot} as the repository default root; pass --root or update the config to select ${path.resolve(destination)}.\n`,
271
+ );
272
+ }
273
+
274
+ function canonicalPath(candidate) {
275
+ try {
276
+ return realpathSync(candidate);
277
+ } catch {
278
+ return path.resolve(candidate);
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Implement `ddduck check`: run check-model.mjs in a child process against the
284
+ * resolved root, then gate generated-view freshness (docs, graph JSON/NDJSON,
285
+ * and the SVG via a subprocess because rendering is async WASM) and refuse
286
+ * leftover interrupted-operation state. Busy roots exit 2 via ProductBusyError.
287
+ * @param {string[]} args - Arguments after the `check` command word.
288
+ * @returns {void}
289
+ */
179
290
  function executeChecker(args) {
180
291
  const { options } = parseCommandArgs(args, {
181
292
  options: {
@@ -186,6 +297,11 @@ function executeChecker(args) {
186
297
  },
187
298
  });
188
299
  const root = resolveProductRoot({ explicitRoot: options.root });
300
+ // Name the validated root when it was resolved implicitly, so a config-pinned
301
+ // root can never be validated invisibly. stderr keeps stdout script-safe.
302
+ if (!options.root) {
303
+ process.stderr.write(`check: validating ${root} (root resolved automatically; pass --root to override)\n`);
304
+ }
189
305
  assertProductNotBusy(root);
190
306
  const checkerArgs = [path.join(frameworkRoot, "scripts", "check-model.mjs"), "--root", root];
191
307
  if (options.base) checkerArgs.push("--base", path.resolve(options.base));
@@ -200,7 +316,8 @@ function executeChecker(args) {
200
316
  if (result.stdout) process.stdout.write(result.stdout);
201
317
  if (result.status !== 0) {
202
318
  const diagnostic = result.stderr.trim();
203
- throw new CliUsageError(`Validation failed for ${root}${diagnostic ? `:\n${diagnostic}` : ""}`);
319
+ if (!diagnostic) throw new CliUsageError(`Validation failed for ${root}`);
320
+ throw validationFailureError(root, diagnostic.split("\n"));
204
321
  }
205
322
  if (options["source-only"]) return;
206
323
  try {
@@ -222,7 +339,11 @@ function executeChecker(args) {
222
339
  throw new Error(`Failed to run the model graph SVG check for ${root}: ${svgCheck.error.message}`);
223
340
  }
224
341
  if (svgCheck.status !== 0) {
225
- const diagnostic = (svgCheck.stderr || "").trim() || "generated/graph/model-graph.svg is missing or stale";
342
+ // The standalone gate prints its own regenerate remedy; strip it here so
343
+ // the remedy appears exactly once, in the Next: line below.
344
+ const diagnostic =
345
+ (svgCheck.stderr || "").trim().replace(/; run ddduck generate --root [^\n]*/g, "") ||
346
+ "generated/graph/model-graph.svg is missing or stale";
226
347
  throw new CliUsageError(`Validation failed for ${root}: ${diagnostic}`, {
227
348
  nextAction: `Run ddduck generate --root ${root}, then re-run ddduck check.`,
228
349
  });
@@ -238,6 +359,12 @@ function executeChecker(args) {
238
359
  }
239
360
  }
240
361
 
362
+ /**
363
+ * Implement `ddduck generate`: refresh every generated view through the locked
364
+ * staged operation runner with an empty (no canonical replacement) plan.
365
+ * @param {string[]} args - Arguments after the `generate` command word.
366
+ * @returns {void}
367
+ */
241
368
  function generate(args) {
242
369
  const { options } = parseCommandArgs(args, { options: { root: { value: true }, json: { value: false } } });
243
370
  const root = resolveProductRoot({ explicitRoot: options.root });
@@ -248,6 +375,46 @@ function generate(args) {
248
375
  writeProductOperationResult(result, options.json);
249
376
  }
250
377
 
378
+ function createNode(args) {
379
+ const kind = args[0];
380
+ const shared = { root: { value: true }, json: { value: false } };
381
+ const fields =
382
+ kind === "use-case"
383
+ ? { file: { value: true } }
384
+ : {
385
+ id: { value: true },
386
+ name: { value: true },
387
+ purpose: { value: true },
388
+ ...(kind === "concept" ? { owner: { value: true } } : {}),
389
+ };
390
+ const { options } = parseCommandArgs(args, {
391
+ positionals: { min: 1, max: 1 },
392
+ options: { ...shared, ...fields },
393
+ });
394
+ const required = (field) => requiredOption(options, field, `create ${kind} requires --${field} <value>`);
395
+ const request =
396
+ kind === "use-case"
397
+ ? { kind: "UseCase", node: parseYamlMapping(path.resolve(required("file"))) }
398
+ : {
399
+ kind: kind === "domain" ? "Domain" : "Concept",
400
+ id: required("id"),
401
+ name: required("name"),
402
+ purpose: required("purpose"),
403
+ ...(kind === "concept" ? { ownerDomain: required("owner") } : {}),
404
+ };
405
+ const root = resolveProductRoot({ explicitRoot: options.root });
406
+ const result = runProductOperation({ root, transform: (snapshot) => buildAuthoringPlan(snapshot, request) });
407
+ writeProductOperationResult(result, options.json);
408
+ }
409
+
410
+ /**
411
+ * Implement the guarantee lifecycle commands create, move, split, and retire:
412
+ * parse per-command options, require a registered decision for split/retire,
413
+ * and run the resulting plan through the staged operation runner.
414
+ * @param {"create"|"move"|"split"|"retire"} command - Lifecycle command name.
415
+ * @param {string[]} args - Arguments after the command word.
416
+ * @returns {void}
417
+ */
251
418
  function transitionGuarantee(command, args) {
252
419
  const optionDefinitions = {
253
420
  create: {
@@ -307,6 +474,12 @@ function requireRegisteredDecision(root, decision, command) {
307
474
  );
308
475
  }
309
476
 
477
+ /**
478
+ * Print an operation result as one text line or one JSON object (--json).
479
+ * @param {{operation: string, root: string, affectedIds: string[], canonicalPaths: string[], generatedPaths: string[], configPath?: string}} result - Result from the operation runner or init.
480
+ * @param {boolean} json - Emit JSON instead of the text form.
481
+ * @returns {void}
482
+ */
310
483
  function writeProductOperationResult(result, json) {
311
484
  if (json) {
312
485
  process.stdout.write(`${JSON.stringify(result)}\n`);
@@ -314,11 +487,21 @@ function writeProductOperationResult(result, json) {
314
487
  }
315
488
  const affected = result.affectedIds.length > 0 ? ` ${result.affectedIds.join(", ")}` : "";
316
489
  const canonical = result.canonicalPaths.length > 0 ? result.canonicalPaths.join(", ") : "none";
490
+ const config = result.configPath ? `; config: ${result.configPath} (created)` : "";
317
491
  process.stdout.write(
318
- `${result.operation}${affected} in ${result.root}; canonical: ${canonical}; generated: ${result.generatedPaths.join(", ")}\n`,
492
+ `${result.operation}${affected} in ${result.root}; canonical: ${canonical}; generated: ${result.generatedPaths.join(", ")}${config}\n`,
319
493
  );
320
494
  }
321
495
 
496
+ /**
497
+ * Build the operation plan for one guarantee lifecycle command from a frozen
498
+ * product snapshot; move/split/retire require the source guarantee to be active.
499
+ * @param {"create"|"move"|"split"|"retire"} command - Lifecycle command name.
500
+ * @param {{nodes: object[], canonicalPaths: Record<string, string>}} snapshot - Frozen staged product snapshot.
501
+ * @param {string|undefined} id - Target guarantee ID (absent for create).
502
+ * @param {Record<string, string|boolean>} options - Parsed command options.
503
+ * @returns {{operation: string, affectedIds: string[], replacements: {path: string, value: object}[]}} Plan for the operation runner.
504
+ */
322
505
  function buildGuaranteePlan(command, snapshot, id, options) {
323
506
  if (command === "create") return createGuarantee(snapshot, options);
324
507
  const guarantee = requireGuarantee(snapshot, id);
@@ -395,10 +578,14 @@ function moveGuarantee(snapshot, guarantee, destinationDomain) {
395
578
  if (guarantee.ownerDomain === destinationDomain)
396
579
  throw new Error(`${guarantee.id} is already owned by ${destinationDomain}`);
397
580
  const previousOwner = guarantee.ownerDomain;
581
+ // ownershipHistory lists former owning Domains only (model-reference.md), so
582
+ // an owner that regains the guarantee leaves the history again.
398
583
  const updated = {
399
584
  ...guarantee,
400
585
  ownerDomain: destinationDomain,
401
- ownershipHistory: [...new Set([...(guarantee.ownershipHistory ?? []), previousOwner])],
586
+ ownershipHistory: [...new Set([...(guarantee.ownershipHistory ?? []), previousOwner])].filter(
587
+ (domainId) => domainId !== destinationDomain,
588
+ ),
402
589
  };
403
590
  return {
404
591
  operation: "move guarantee",
@@ -517,10 +704,16 @@ function writeYaml(filePath, value) {
517
704
  writeFileSync(filePath, stringify(value));
518
705
  }
519
706
 
707
+ /**
708
+ * Write .ddduck/config.json at the repository root selecting the new product
709
+ * root, unless a config already exists (then it is left untouched).
710
+ * @param {string} destination - Absolute path of the freshly published product root.
711
+ * @returns {{configPath: string, repositoryRoot: string, created: boolean}} Where the config lives and whether it was created.
712
+ */
520
713
  function writeConfigIfAbsent(destination) {
521
714
  const repositoryRoot = findRepositoryRoot(destination);
522
715
  const configPath = path.join(repositoryRoot, ".ddduck", "config.json");
523
- if (existsSync(configPath)) return;
716
+ if (existsSync(configPath)) return { configPath, repositoryRoot, created: false };
524
717
  const relativeProductRoot = path.relative(repositoryRoot, destination).split(path.sep).join("/");
525
718
  // findRepositoryRoot falls back to the destination itself when no enclosing
526
719
  // .git exists, which makes the relative path empty; "." keeps productRoot
@@ -531,6 +724,7 @@ function writeConfigIfAbsent(destination) {
531
724
  configPath,
532
725
  `${JSON.stringify({ schemaVersion: "1", productRoot, ignore: [...defaultConfigIgnore] }, null, 2)}\n`,
533
726
  );
727
+ return { configPath, repositoryRoot, created: true };
534
728
  }
535
729
 
536
730
  function nextActionFor(args) {
@@ -1,5 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
4
+ * CLI wrapper for the agent-readiness report (`--root <product-root>`):
5
+ * delegates to lib/agent-readiness-report.mjs and prints the JSON report of
6
+ * missing evidence roles, unresolved references, stale generated views,
7
+ * orphaned nodes, and ambiguous ownership. Exits 1 when validation failed
8
+ * (report reduced to unresolvedReferences) or on any error.
9
+ */
10
+
3
11
  import path from "node:path";
4
12
  import { generateAgentReadinessReport } from "./lib/agent-readiness-report.mjs";
5
13
 
@@ -1,5 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
4
+ * Renders the generated Markdown view generated/docs/model-overview.md from a
5
+ * product root's canonical YAML: domains with their concepts, interfaces, and
6
+ * active guarantees, then use cases, interfaces, relationships, and decisions.
7
+ * Inactive (split/retired) guarantees are excluded. buildModelOverview feeds
8
+ * the freshness gate (check-generated-docs.mjs); writeModelOverview is called
9
+ * by init and the staged operation runner. Also runnable standalone via --root.
10
+ */
11
+
3
12
  import { mkdirSync, writeFileSync } from "node:fs";
4
13
  import path from "node:path";
5
14
  import { fileURLToPath } from "node:url";
@@ -9,12 +18,22 @@ import { resolveProductRoot } from "./lib/product-root-resolver.mjs";
9
18
 
10
19
  const outputPath = path.join("generated", "docs", "model-overview.md");
11
20
 
21
+ /**
22
+ * Build the model overview Markdown for a product root without writing it.
23
+ * @param {string} rootPath - Product root path.
24
+ * @returns {string} The full generated Markdown document.
25
+ */
12
26
  export function buildModelOverview(rootPath) {
13
27
  const layout = detectProductLayout(rootPath);
14
28
  const graph = loadGraph(layout);
15
29
  return renderModelOverview(assembleModelView(graph, findModelId(graph)));
16
30
  }
17
31
 
32
+ /**
33
+ * Write generated/docs/model-overview.md below the product root.
34
+ * @param {string} rootPath - Product root path.
35
+ * @returns {string} The root-relative path that was written.
36
+ */
18
37
  export function writeModelOverview(rootPath) {
19
38
  const output = buildModelOverview(rootPath);
20
39
  const absoluteOutputPath = resolveContainedOutput(rootPath, outputPath);
@@ -30,6 +49,14 @@ function loadGraph(layout) {
30
49
  };
31
50
  }
32
51
 
52
+ /**
53
+ * Assemble the sorted, resolved view of the model that the renderer consumes:
54
+ * domains with their owned nodes resolved, relationships, decisions, use
55
+ * cases, and every DomainInterface in the product.
56
+ * @param {{nodes: Map<string, object>}} graph - Loaded product nodes (active guarantees only).
57
+ * @param {string} modelId - ID of the single Model node.
58
+ * @returns {{model: object, domains: object[], relationships: object[], decisions: string[], useCases: object[], interfaces: object[]}} The renderable view.
59
+ */
33
60
  function assembleModelView(graph, modelId) {
34
61
  const model = resolveNode(graph, modelId);
35
62
  const domains = asArray(model.domains)
@@ -62,8 +89,7 @@ function renderModelOverview(view) {
62
89
  "",
63
90
  `# ${view.model.name} (\`${view.model.id}\`)`,
64
91
  "",
65
- `Name status: \`${view.model.nameStatus ?? "stable"}\``,
66
- "",
92
+ ...(view.model.nameStatus === undefined ? [] : [`Name status: \`${view.model.nameStatus}\``, ""]),
67
93
  view.model.purpose,
68
94
  "",
69
95
  "## Domains",
@@ -111,7 +137,7 @@ function renderModelOverview(view) {
111
137
  lines.push("## Decisions", "");
112
138
  for (const decision of view.decisions) lines.push(`- \`${decision}\``);
113
139
  lines.push("");
114
- return lines.join("\n");
140
+ return `${lines.join("\n").trimEnd()}\n`;
115
141
  }
116
142
 
117
143
  function renderNodeList(lines, title, nodes) {
@@ -1,9 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // Experimental: render generated/graph/model-graph.json to an SVG using the
4
- // Graphviz WASM engine (@hpcc-js/wasm). Pure JS + WASM, no system binary, so it
5
- // stays offline and deterministic. A hosted render API (e.g. Kroki) is a
6
- // possible future fallback but is intentionally not the default.
3
+ /**
4
+ * Renders generated/graph/model-graph.json to the canonical SVG view
5
+ * generated/graph/model-graph.svg using the Graphviz WASM engine
6
+ * (@hpcc-js/wasm): pure JS + WASM, no system binary, so it stays offline and
7
+ * deterministic. Because rendering is async, init, the operation runner, check,
8
+ * and query all invoke this script as a child process. Experimental layout
9
+ * variants (--layout/--all-layouts) land under .ddduck/graph-layouts/, outside
10
+ * the generated-view contract. A hosted render API (e.g. Kroki) is a possible
11
+ * future fallback but is intentionally not the default.
12
+ */
7
13
 
8
14
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
15
  import path from "node:path";
@@ -15,7 +21,8 @@ export const jsonInputPath = path.join("generated", "graph", "model-graph.json")
15
21
 
16
22
  // Graphviz layout engines worth comparing for a model graph. `dot` is the
17
23
  // hierarchical default; the force-directed and radial engines often read better
18
- // once the ownership tree gets wide. Output goes to `model-graph.<engine>.svg`.
24
+ // once the ownership tree gets wide. Variant output goes to
25
+ // `.ddduck/graph-layouts/model-graph.<engine>.svg`.
19
26
  export const LAYOUT_ENGINES = ["dot", "twopi", "circo", "fdp", "sfdp", "neato"];
20
27
 
21
28
  // Only `dot` and `fdp` do cluster-aware layout. The other engines still *draw*
@@ -32,8 +39,13 @@ export function engineSupportsClusters(engine) {
32
39
  export const svgOutputPath = path.join("generated", "graph", "model-graph.svg");
33
40
  export const canonicalEngine = "dot";
34
41
 
42
+ // Experimental layout variants (`--layout` / `--all-layouts`) are not part of
43
+ // the generated-view contract: `generated/` holds only the canonical views that
44
+ // `ddduck generate` refreshes and `ddduck check` gates. Variants land in the
45
+ // `.ddduck/` tool-metadata area instead, so they can never rot unswept inside
46
+ // `generated/`.
35
47
  export function svgOutputPathFor(engine) {
36
- return path.join("generated", "graph", `model-graph.${engine}.svg`);
48
+ return path.join(".ddduck", "graph-layouts", `model-graph.${engine}.svg`);
37
49
  }
38
50
 
39
51
  // Visual vocabulary keyed by the graph's node/edge `kind`. Shapes and fills are
@@ -214,9 +226,14 @@ function edgeStatements(graphEdge, nodeIds) {
214
226
  return [edge(graphEdge.from, graphEdge.to, { ...base, label: graphEdge.label ?? graphEdge.kind })];
215
227
  }
216
228
 
217
- // Pure translation from the model graph JSON to a deterministic DOT string.
218
- // `clusters` groups each domain and its owned nodes into a titled box; disable it
219
- // for engines that draw but do not lay out clusters (they would overlap).
229
+ /**
230
+ * Pure translation from the model graph JSON to a deterministic DOT string.
231
+ * `clusters` groups each domain and its owned nodes into a titled box; disable
232
+ * it for engines that draw but do not lay out clusters (they would overlap).
233
+ * @param {{modelId?: string, modelName?: string, nodes?: object[], edges?: object[]}} modelGraph - Parsed model-graph.json.
234
+ * @param {{clusters?: boolean, legend?: boolean}} [options] - Cluster domains into boxes; emit the legend cluster.
235
+ * @returns {string} The DOT source.
236
+ */
220
237
  export function graphToDot(modelGraph, { clusters = true, legend = clusters } = {}) {
221
238
  const nodes = modelGraph.nodes ?? [];
222
239
  const edges = modelGraph.edges ?? [];
@@ -254,9 +271,14 @@ function loadGraphviz() {
254
271
  return graphvizInstance;
255
272
  }
256
273
 
257
- // Render a DOT string to SVG via the Graphviz WASM engine. `engine` selects the
258
- // layout algorithm. Isolated so the backend can be swapped without touching the
259
- // translation above.
274
+ /**
275
+ * Render a DOT string to SVG via the Graphviz WASM engine. `engine` selects
276
+ * the layout algorithm. Isolated so the backend can be swapped without
277
+ * touching the translation above.
278
+ * @param {string} dot - DOT source from graphToDot.
279
+ * @param {string} [engine] - One of LAYOUT_ENGINES (default "dot").
280
+ * @returns {Promise<string>} The rendered SVG.
281
+ */
260
282
  export async function renderDotToSvg(dot, engine = "dot") {
261
283
  if (!LAYOUT_ENGINES.includes(engine)) {
262
284
  throw new Error(`unknown layout engine ${engine}; expected one of ${LAYOUT_ENGINES.join(", ")}`);
@@ -281,13 +303,22 @@ function writeSvg(rootPath, relativePath, svg) {
281
303
  return relativePath;
282
304
  }
283
305
 
284
- // Build the canonical diagram bytes (dot, clustered, legend). Shared by the
285
- // writer and the freshness check so both agree byte-for-byte.
306
+ /**
307
+ * Build the canonical diagram bytes (dot, clustered, legend). Shared by the
308
+ * writer and the freshness check so both agree byte-for-byte.
309
+ * @param {object} modelGraph - Parsed model-graph.json.
310
+ * @returns {Promise<string>} The canonical SVG bytes.
311
+ */
286
312
  export async function buildModelGraphSvg(modelGraph) {
287
313
  const dot = graphToDot(modelGraph, { clusters: true, legend: true });
288
314
  return renderDotToSvg(dot, canonicalEngine);
289
315
  }
290
316
 
317
+ /**
318
+ * Write the canonical generated/graph/model-graph.svg below the product root.
319
+ * @param {string} rootPath - Product root path.
320
+ * @returns {Promise<string>} The root-relative path that was written.
321
+ */
291
322
  export async function writeModelGraphSvgCanonical(rootPath) {
292
323
  const svg = await buildModelGraphSvg(readModelGraph(rootPath));
293
324
  return writeSvg(rootPath, svgOutputPath, svg);
@@ -298,8 +329,14 @@ export async function writeModelGraphSvg(rootPath, engine = "dot") {
298
329
  return writeSvg(rootPath, svgOutputPathFor(engine), await renderDotToSvg(dot, engine));
299
330
  }
300
331
 
301
- // Render one SVG per layout engine so the variants can be compared side by side.
302
- // Clusters are emitted only for the engines that lay them out (dot, fdp).
332
+ /**
333
+ * Render one SVG per layout engine so the variants can be compared side by
334
+ * side. Clusters are emitted only for the engines that lay them out (dot,
335
+ * fdp). Variants go to .ddduck/graph-layouts/, not generated/.
336
+ * @param {string} rootPath - Product root path.
337
+ * @param {string[]} [engines] - Layout engines to render (default all LAYOUT_ENGINES).
338
+ * @returns {Promise<string[]>} The root-relative variant paths written.
339
+ */
303
340
  export async function writeModelGraphSvgVariants(rootPath, engines = LAYOUT_ENGINES) {
304
341
  const modelGraph = readModelGraph(rootPath);
305
342
  const writtenPaths = [];