ddduck 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/docs/architecture.md +5 -2
- package/docs/cli.md +73 -52
- package/docs/getting-started.md +6 -3
- package/docs/model-reference.md +4 -0
- package/docs/model.md +1 -0
- package/package.json +5 -4
- package/scripts/audit-fr-to-code.mjs +25 -0
- package/scripts/check-generated-docs.mjs +24 -5
- package/scripts/check-generated-graph-svg.mjs +26 -8
- package/scripts/check-generated-graph.mjs +25 -5
- package/scripts/check-model.mjs +95 -5
- package/scripts/ddduck.mjs +166 -21
- package/scripts/generate-agent-readiness-report.mjs +8 -0
- package/scripts/generate-docs.mjs +28 -2
- package/scripts/generate-graph-svg.mjs +53 -16
- package/scripts/generate-graph.mjs +26 -1
- package/scripts/lib/agent-readiness-evals.mjs +32 -0
- package/scripts/lib/agent-readiness-report.mjs +14 -0
- package/scripts/lib/cli-contract.mjs +49 -10
- package/scripts/lib/context-pack.mjs +49 -1
- package/scripts/lib/ddduck-config.mjs +31 -1
- package/scripts/lib/fr-to-code-audit.mjs +24 -0
- package/scripts/lib/product-layout.mjs +42 -1
- package/scripts/lib/product-operation.mjs +132 -22
- package/scripts/lib/product-paths.mjs +16 -0
- package/scripts/lib/product-query.mjs +102 -20
- package/scripts/lib/product-root-resolver.mjs +40 -0
- package/scripts/lib/scan-ignore.mjs +14 -3
- package/scripts/lib/skill-installer.mjs +57 -4
- package/scripts/query-model.mjs +18 -7
- package/scripts/run-agent-readiness-evals.mjs +18 -2
- package/skills/update-ddduck-specs/SKILL.md +1 -1
package/scripts/check-model.mjs
CHANGED
|
@@ -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|
|
|
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"
|
|
211
|
-
this.addError(node.id, `split successor must be
|
|
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
|
|
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 } = {},
|
package/scripts/ddduck.mjs
CHANGED
|
@@ -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,10 +30,17 @@ 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 {
|
|
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";
|
|
@@ -38,6 +55,11 @@ try {
|
|
|
38
55
|
writeCliError(error, { nextAction: nextActionFor(cliArgs) });
|
|
39
56
|
}
|
|
40
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Dispatch one parsed CLI invocation to its command handler.
|
|
60
|
+
* @param {string[]} args - Raw CLI arguments (process.argv minus node and script).
|
|
61
|
+
* @returns {void}
|
|
62
|
+
*/
|
|
41
63
|
function run(args) {
|
|
42
64
|
if (args.length === 1 && args[0] === "--help") {
|
|
43
65
|
process.stdout.write(renderHelp());
|
|
@@ -81,6 +103,12 @@ function run(args) {
|
|
|
81
103
|
}
|
|
82
104
|
}
|
|
83
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Implement `ddduck install skill update-ddduck-specs`: install the bundled
|
|
108
|
+
* host skill adapter and .ddduck/agent-skills.lock.json into --repo.
|
|
109
|
+
* @param {string[]} args - Arguments after the `install` command word.
|
|
110
|
+
* @returns {void}
|
|
111
|
+
*/
|
|
84
112
|
function install(args) {
|
|
85
113
|
const { positionals, options } = parseCommandArgs(args, {
|
|
86
114
|
positionals: { min: 2, max: 2 },
|
|
@@ -92,6 +120,13 @@ function install(args) {
|
|
|
92
120
|
}
|
|
93
121
|
const packageVersion = JSON.parse(readFileSync(path.join(frameworkRoot, "package.json"), "utf8")).version;
|
|
94
122
|
const repository = path.resolve(options.repo ?? process.cwd());
|
|
123
|
+
// A typo'd --repo must fail instead of silently manufacturing a directory
|
|
124
|
+
// tree (and a lock) at the wrong path while the real repository gets nothing.
|
|
125
|
+
if (!existsSync(repository) || !statSync(repository).isDirectory()) {
|
|
126
|
+
throw new CliUsageError(`install requires an existing repository directory: ${repository}`, {
|
|
127
|
+
nextAction: "Pass --repo <existing-repository-root> and retry.",
|
|
128
|
+
});
|
|
129
|
+
}
|
|
95
130
|
const result = installSkill({
|
|
96
131
|
repository,
|
|
97
132
|
skillName,
|
|
@@ -104,6 +139,14 @@ function install(args) {
|
|
|
104
139
|
);
|
|
105
140
|
}
|
|
106
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Implement `ddduck init`: build a new product root (canonical directories,
|
|
144
|
+
* product.yaml, all generated views) in a PID-stamped staging directory beside
|
|
145
|
+
* the destination, then publish it atomically via rename and write
|
|
146
|
+
* .ddduck/config.json if absent.
|
|
147
|
+
* @param {string[]} args - Arguments after the `init` command word.
|
|
148
|
+
* @returns {{result: object, json: boolean}} The operation result and whether --json was requested.
|
|
149
|
+
*/
|
|
107
150
|
function initialize(args) {
|
|
108
151
|
const { positionals, options } = parseCommandArgs(args, {
|
|
109
152
|
positionals: { min: 0, max: 1 },
|
|
@@ -112,7 +155,9 @@ function initialize(args) {
|
|
|
112
155
|
const destination = resolveInitDestination({ explicitDestination: positionals[0] });
|
|
113
156
|
const productId = requiredOption(options, "id", "init requires --id model:<product-id>");
|
|
114
157
|
if (!/^model:[a-z0-9][a-z0-9-]*$/.test(productId)) {
|
|
115
|
-
throw new Error(
|
|
158
|
+
throw new Error(
|
|
159
|
+
`Invalid product ID ${JSON.stringify(productId)}; expected model:<lowercase-slug> (for example model:library)`,
|
|
160
|
+
);
|
|
116
161
|
}
|
|
117
162
|
|
|
118
163
|
mkdirSync(path.dirname(destination), { recursive: true });
|
|
@@ -121,14 +166,19 @@ function initialize(args) {
|
|
|
121
166
|
}
|
|
122
167
|
|
|
123
168
|
// Stage names embed the destination so concurrent inits to sibling
|
|
124
|
-
// destinations never sweep each other's live staging directories
|
|
169
|
+
// destinations never sweep each other's live staging directories, and the
|
|
170
|
+
// creating PID so concurrent inits to the SAME destination only sweep stages
|
|
171
|
+
// whose creator is dead (mirroring the mutation lock's reclaim rule). Stages
|
|
172
|
+
// without a parseable live PID are interrupted-init debris and get swept.
|
|
125
173
|
const destinationStagePrefix = `${initStagingPrefix}${path.basename(destination)}-`;
|
|
126
174
|
for (const entry of readdirSync(path.dirname(destination))) {
|
|
127
|
-
if (entry.startsWith(destinationStagePrefix))
|
|
128
|
-
|
|
129
|
-
|
|
175
|
+
if (!entry.startsWith(destinationStagePrefix)) continue;
|
|
176
|
+
const stagePid = Number.parseInt(entry.slice(destinationStagePrefix.length).match(/^(\d+)-/)?.[1] ?? "", 10);
|
|
177
|
+
if (Number.isInteger(stagePid) && stagePid > 0 && isProcessAlive(stagePid)) continue;
|
|
178
|
+
rmSync(path.join(path.dirname(destination), entry), { recursive: true, force: true });
|
|
130
179
|
}
|
|
131
|
-
const stagingRoot = mkdtempSync(path.join(path.dirname(destination), destinationStagePrefix));
|
|
180
|
+
const stagingRoot = mkdtempSync(path.join(path.dirname(destination), `${destinationStagePrefix}${process.pid}-`));
|
|
181
|
+
let config;
|
|
132
182
|
try {
|
|
133
183
|
for (const directory of ["domains", "concepts", "relationships", "use-cases", "interfaces", "guarantees"]) {
|
|
134
184
|
mkdirSync(resolveContainedOutput(stagingRoot, path.join("model", directory)), { recursive: true });
|
|
@@ -145,10 +195,22 @@ function initialize(args) {
|
|
|
145
195
|
decisions: [],
|
|
146
196
|
});
|
|
147
197
|
refreshDerivedOutput(stagingRoot);
|
|
148
|
-
if (existsSync(destination)) rmdirSync(destination);
|
|
149
|
-
renameSync(stagingRoot, destination);
|
|
150
198
|
try {
|
|
151
|
-
|
|
199
|
+
if (existsSync(destination)) rmdirSync(destination);
|
|
200
|
+
renameSync(stagingRoot, destination);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
// A concurrent init to the same destination can publish between the
|
|
203
|
+
// emptiness check above and this rename; name the collision instead of
|
|
204
|
+
// surfacing the raw filesystem error.
|
|
205
|
+
if (error.code === "ENOTEMPTY" || error.code === "EEXIST") {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`Refusing to initialize non-empty directory ${destination}; another init published it concurrently`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
config = writeConfigIfAbsent(destination);
|
|
152
214
|
} catch (error) {
|
|
153
215
|
// Writing the config is the final publish step. If it fails (for example a
|
|
154
216
|
// regular file already occupies the .ddduck config path), roll back the
|
|
@@ -160,6 +222,7 @@ function initialize(args) {
|
|
|
160
222
|
} finally {
|
|
161
223
|
rmSync(stagingRoot, { recursive: true, force: true });
|
|
162
224
|
}
|
|
225
|
+
if (!config.created) warnPinnedRepositoryDefault(config, destination);
|
|
163
226
|
return {
|
|
164
227
|
json: options.json,
|
|
165
228
|
result: {
|
|
@@ -167,15 +230,46 @@ function initialize(args) {
|
|
|
167
230
|
root: realpathSync(destination),
|
|
168
231
|
affectedIds: [productId],
|
|
169
232
|
canonicalPaths: ["product.yaml"],
|
|
170
|
-
generatedPaths: [
|
|
171
|
-
|
|
172
|
-
"generated/graph/model-graph.json",
|
|
173
|
-
"generated/graph/model-graph.ndjson",
|
|
174
|
-
],
|
|
233
|
+
generatedPaths: [...generatedPaths],
|
|
234
|
+
...(config.created ? { configPath: config.configPath } : {}),
|
|
175
235
|
},
|
|
176
236
|
};
|
|
177
237
|
}
|
|
178
238
|
|
|
239
|
+
// A pre-existing repository config keeps selecting its own product root; a
|
|
240
|
+
// second init must say so or every rootless command silently addresses the
|
|
241
|
+
// other product.
|
|
242
|
+
function warnPinnedRepositoryDefault(config, destination) {
|
|
243
|
+
let configuredRoot;
|
|
244
|
+
try {
|
|
245
|
+
const loaded = loadDdduckConfig(config.repositoryRoot);
|
|
246
|
+
if (!loaded) return;
|
|
247
|
+
configuredRoot = path.resolve(config.repositoryRoot, loaded.productRoot);
|
|
248
|
+
} catch {
|
|
249
|
+
return; // An unreadable config surfaces on the next root resolution.
|
|
250
|
+
}
|
|
251
|
+
if (canonicalPath(configuredRoot) === canonicalPath(destination)) return;
|
|
252
|
+
process.stderr.write(
|
|
253
|
+
`init: ${config.configPath} still selects ${configuredRoot} as the repository default root; pass --root or update the config to select ${path.resolve(destination)}.\n`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function canonicalPath(candidate) {
|
|
258
|
+
try {
|
|
259
|
+
return realpathSync(candidate);
|
|
260
|
+
} catch {
|
|
261
|
+
return path.resolve(candidate);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Implement `ddduck check`: run check-model.mjs in a child process against the
|
|
267
|
+
* resolved root, then gate generated-view freshness (docs, graph JSON/NDJSON,
|
|
268
|
+
* and the SVG via a subprocess because rendering is async WASM) and refuse
|
|
269
|
+
* leftover interrupted-operation state. Busy roots exit 2 via ProductBusyError.
|
|
270
|
+
* @param {string[]} args - Arguments after the `check` command word.
|
|
271
|
+
* @returns {void}
|
|
272
|
+
*/
|
|
179
273
|
function executeChecker(args) {
|
|
180
274
|
const { options } = parseCommandArgs(args, {
|
|
181
275
|
options: {
|
|
@@ -186,6 +280,11 @@ function executeChecker(args) {
|
|
|
186
280
|
},
|
|
187
281
|
});
|
|
188
282
|
const root = resolveProductRoot({ explicitRoot: options.root });
|
|
283
|
+
// Name the validated root when it was resolved implicitly, so a config-pinned
|
|
284
|
+
// root can never be validated invisibly. stderr keeps stdout script-safe.
|
|
285
|
+
if (!options.root) {
|
|
286
|
+
process.stderr.write(`check: validating ${root} (root resolved automatically; pass --root to override)\n`);
|
|
287
|
+
}
|
|
189
288
|
assertProductNotBusy(root);
|
|
190
289
|
const checkerArgs = [path.join(frameworkRoot, "scripts", "check-model.mjs"), "--root", root];
|
|
191
290
|
if (options.base) checkerArgs.push("--base", path.resolve(options.base));
|
|
@@ -200,7 +299,8 @@ function executeChecker(args) {
|
|
|
200
299
|
if (result.stdout) process.stdout.write(result.stdout);
|
|
201
300
|
if (result.status !== 0) {
|
|
202
301
|
const diagnostic = result.stderr.trim();
|
|
203
|
-
throw new CliUsageError(`Validation failed for ${root}
|
|
302
|
+
if (!diagnostic) throw new CliUsageError(`Validation failed for ${root}`);
|
|
303
|
+
throw validationFailureError(root, diagnostic.split("\n"));
|
|
204
304
|
}
|
|
205
305
|
if (options["source-only"]) return;
|
|
206
306
|
try {
|
|
@@ -222,7 +322,11 @@ function executeChecker(args) {
|
|
|
222
322
|
throw new Error(`Failed to run the model graph SVG check for ${root}: ${svgCheck.error.message}`);
|
|
223
323
|
}
|
|
224
324
|
if (svgCheck.status !== 0) {
|
|
225
|
-
|
|
325
|
+
// The standalone gate prints its own regenerate remedy; strip it here so
|
|
326
|
+
// the remedy appears exactly once, in the Next: line below.
|
|
327
|
+
const diagnostic =
|
|
328
|
+
(svgCheck.stderr || "").trim().replace(/; run ddduck generate --root [^\n]*/g, "") ||
|
|
329
|
+
"generated/graph/model-graph.svg is missing or stale";
|
|
226
330
|
throw new CliUsageError(`Validation failed for ${root}: ${diagnostic}`, {
|
|
227
331
|
nextAction: `Run ddduck generate --root ${root}, then re-run ddduck check.`,
|
|
228
332
|
});
|
|
@@ -238,6 +342,12 @@ function executeChecker(args) {
|
|
|
238
342
|
}
|
|
239
343
|
}
|
|
240
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Implement `ddduck generate`: refresh every generated view through the locked
|
|
347
|
+
* staged operation runner with an empty (no canonical replacement) plan.
|
|
348
|
+
* @param {string[]} args - Arguments after the `generate` command word.
|
|
349
|
+
* @returns {void}
|
|
350
|
+
*/
|
|
241
351
|
function generate(args) {
|
|
242
352
|
const { options } = parseCommandArgs(args, { options: { root: { value: true }, json: { value: false } } });
|
|
243
353
|
const root = resolveProductRoot({ explicitRoot: options.root });
|
|
@@ -248,6 +358,14 @@ function generate(args) {
|
|
|
248
358
|
writeProductOperationResult(result, options.json);
|
|
249
359
|
}
|
|
250
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Implement the guarantee lifecycle commands create, move, split, and retire:
|
|
363
|
+
* parse per-command options, require a registered decision for split/retire,
|
|
364
|
+
* and run the resulting plan through the staged operation runner.
|
|
365
|
+
* @param {"create"|"move"|"split"|"retire"} command - Lifecycle command name.
|
|
366
|
+
* @param {string[]} args - Arguments after the command word.
|
|
367
|
+
* @returns {void}
|
|
368
|
+
*/
|
|
251
369
|
function transitionGuarantee(command, args) {
|
|
252
370
|
const optionDefinitions = {
|
|
253
371
|
create: {
|
|
@@ -307,6 +425,12 @@ function requireRegisteredDecision(root, decision, command) {
|
|
|
307
425
|
);
|
|
308
426
|
}
|
|
309
427
|
|
|
428
|
+
/**
|
|
429
|
+
* Print an operation result as one text line or one JSON object (--json).
|
|
430
|
+
* @param {{operation: string, root: string, affectedIds: string[], canonicalPaths: string[], generatedPaths: string[], configPath?: string}} result - Result from the operation runner or init.
|
|
431
|
+
* @param {boolean} json - Emit JSON instead of the text form.
|
|
432
|
+
* @returns {void}
|
|
433
|
+
*/
|
|
310
434
|
function writeProductOperationResult(result, json) {
|
|
311
435
|
if (json) {
|
|
312
436
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
@@ -314,11 +438,21 @@ function writeProductOperationResult(result, json) {
|
|
|
314
438
|
}
|
|
315
439
|
const affected = result.affectedIds.length > 0 ? ` ${result.affectedIds.join(", ")}` : "";
|
|
316
440
|
const canonical = result.canonicalPaths.length > 0 ? result.canonicalPaths.join(", ") : "none";
|
|
441
|
+
const config = result.configPath ? `; config: ${result.configPath} (created)` : "";
|
|
317
442
|
process.stdout.write(
|
|
318
|
-
`${result.operation}${affected} in ${result.root}; canonical: ${canonical}; generated: ${result.generatedPaths.join(", ")}\n`,
|
|
443
|
+
`${result.operation}${affected} in ${result.root}; canonical: ${canonical}; generated: ${result.generatedPaths.join(", ")}${config}\n`,
|
|
319
444
|
);
|
|
320
445
|
}
|
|
321
446
|
|
|
447
|
+
/**
|
|
448
|
+
* Build the operation plan for one guarantee lifecycle command from a frozen
|
|
449
|
+
* product snapshot; move/split/retire require the source guarantee to be active.
|
|
450
|
+
* @param {"create"|"move"|"split"|"retire"} command - Lifecycle command name.
|
|
451
|
+
* @param {{nodes: object[], canonicalPaths: Record<string, string>}} snapshot - Frozen staged product snapshot.
|
|
452
|
+
* @param {string|undefined} id - Target guarantee ID (absent for create).
|
|
453
|
+
* @param {Record<string, string|boolean>} options - Parsed command options.
|
|
454
|
+
* @returns {{operation: string, affectedIds: string[], replacements: {path: string, value: object}[]}} Plan for the operation runner.
|
|
455
|
+
*/
|
|
322
456
|
function buildGuaranteePlan(command, snapshot, id, options) {
|
|
323
457
|
if (command === "create") return createGuarantee(snapshot, options);
|
|
324
458
|
const guarantee = requireGuarantee(snapshot, id);
|
|
@@ -395,10 +529,14 @@ function moveGuarantee(snapshot, guarantee, destinationDomain) {
|
|
|
395
529
|
if (guarantee.ownerDomain === destinationDomain)
|
|
396
530
|
throw new Error(`${guarantee.id} is already owned by ${destinationDomain}`);
|
|
397
531
|
const previousOwner = guarantee.ownerDomain;
|
|
532
|
+
// ownershipHistory lists former owning Domains only (model-reference.md), so
|
|
533
|
+
// an owner that regains the guarantee leaves the history again.
|
|
398
534
|
const updated = {
|
|
399
535
|
...guarantee,
|
|
400
536
|
ownerDomain: destinationDomain,
|
|
401
|
-
ownershipHistory: [...new Set([...(guarantee.ownershipHistory ?? []), previousOwner])]
|
|
537
|
+
ownershipHistory: [...new Set([...(guarantee.ownershipHistory ?? []), previousOwner])].filter(
|
|
538
|
+
(domainId) => domainId !== destinationDomain,
|
|
539
|
+
),
|
|
402
540
|
};
|
|
403
541
|
return {
|
|
404
542
|
operation: "move guarantee",
|
|
@@ -517,10 +655,16 @@ function writeYaml(filePath, value) {
|
|
|
517
655
|
writeFileSync(filePath, stringify(value));
|
|
518
656
|
}
|
|
519
657
|
|
|
658
|
+
/**
|
|
659
|
+
* Write .ddduck/config.json at the repository root selecting the new product
|
|
660
|
+
* root, unless a config already exists (then it is left untouched).
|
|
661
|
+
* @param {string} destination - Absolute path of the freshly published product root.
|
|
662
|
+
* @returns {{configPath: string, repositoryRoot: string, created: boolean}} Where the config lives and whether it was created.
|
|
663
|
+
*/
|
|
520
664
|
function writeConfigIfAbsent(destination) {
|
|
521
665
|
const repositoryRoot = findRepositoryRoot(destination);
|
|
522
666
|
const configPath = path.join(repositoryRoot, ".ddduck", "config.json");
|
|
523
|
-
if (existsSync(configPath)) return;
|
|
667
|
+
if (existsSync(configPath)) return { configPath, repositoryRoot, created: false };
|
|
524
668
|
const relativeProductRoot = path.relative(repositoryRoot, destination).split(path.sep).join("/");
|
|
525
669
|
// findRepositoryRoot falls back to the destination itself when no enclosing
|
|
526
670
|
// .git exists, which makes the relative path empty; "." keeps productRoot
|
|
@@ -531,6 +675,7 @@ function writeConfigIfAbsent(destination) {
|
|
|
531
675
|
configPath,
|
|
532
676
|
`${JSON.stringify({ schemaVersion: "1", productRoot, ignore: [...defaultConfigIgnore] }, null, 2)}\n`,
|
|
533
677
|
);
|
|
678
|
+
return { configPath, repositoryRoot, created: true };
|
|
534
679
|
}
|
|
535
680
|
|
|
536
681
|
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
|
|
66
|
-
"",
|
|
92
|
+
...(view.model.nameStatus === undefined ? [] : [`Name status: \`${view.model.nameStatus}\``, ""]),
|
|
67
93
|
view.model.purpose,
|
|
68
94
|
"",
|
|
69
95
|
"## Domains",
|