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.
- package/README.md +11 -6
- package/docs/architecture.md +5 -2
- package/docs/cli.md +135 -53
- package/docs/definition-workflow.md +130 -0
- package/docs/getting-started.md +21 -48
- package/docs/model-reference.md +4 -0
- package/docs/model.md +1 -0
- package/docs/templates/change-brief.md +48 -0
- package/package.json +8 -5
- package/schemas/model-diff.schema.json +107 -0
- 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 +216 -22
- package/scripts/generate-agent-readiness-report.mjs +8 -0
- package/scripts/generate-docs.mjs +29 -3
- 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 +65 -15
- package/scripts/lib/context-pack.mjs +49 -1
- package/scripts/lib/ddduck-config.mjs +31 -1
- package/scripts/lib/fr-to-code-audit.mjs +30 -0
- package/scripts/lib/product-authoring.mjs +52 -0
- package/scripts/lib/product-diff.mjs +155 -0
- package/scripts/lib/product-layout.mjs +42 -1
- package/scripts/lib/product-operation.mjs +140 -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 +227 -39
- package/scripts/query-model.mjs +18 -7
- package/scripts/run-agent-readiness-evals.mjs +18 -2
- package/skills/update-ddduck-specs/SKILL.md +25 -83
- package/skills/update-ddduck-specs/references/authoring-and-verification.md +60 -0
- package/skills/update-ddduck-specs/references/modeling-and-evidence.md +56 -0
- package/skills/update-ddduck-specs/references/reviewing-changes.md +45 -0
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,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 {
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Renders generated/graph/model-graph.json to the canonical SVG view
|
|
5
|
+
* generated/graph/model-graph.svg using the Graphviz WASM engine
|
|
6
|
+
* (@hpcc-js/wasm): pure JS + WASM, no system binary, so it stays offline and
|
|
7
|
+
* deterministic. Because rendering is async, init, the operation runner, check,
|
|
8
|
+
* and query all invoke this script as a child process. Experimental layout
|
|
9
|
+
* variants (--layout/--all-layouts) land under .ddduck/graph-layouts/, outside
|
|
10
|
+
* the generated-view contract. A hosted render API (e.g. Kroki) is a possible
|
|
11
|
+
* future fallback but is intentionally not the default.
|
|
12
|
+
*/
|
|
7
13
|
|
|
8
14
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
15
|
import path from "node:path";
|
|
@@ -15,7 +21,8 @@ export const jsonInputPath = path.join("generated", "graph", "model-graph.json")
|
|
|
15
21
|
|
|
16
22
|
// Graphviz layout engines worth comparing for a model graph. `dot` is the
|
|
17
23
|
// hierarchical default; the force-directed and radial engines often read better
|
|
18
|
-
// once the ownership tree gets wide.
|
|
24
|
+
// once the ownership tree gets wide. Variant output goes to
|
|
25
|
+
// `.ddduck/graph-layouts/model-graph.<engine>.svg`.
|
|
19
26
|
export const LAYOUT_ENGINES = ["dot", "twopi", "circo", "fdp", "sfdp", "neato"];
|
|
20
27
|
|
|
21
28
|
// Only `dot` and `fdp` do cluster-aware layout. The other engines still *draw*
|
|
@@ -32,8 +39,13 @@ export function engineSupportsClusters(engine) {
|
|
|
32
39
|
export const svgOutputPath = path.join("generated", "graph", "model-graph.svg");
|
|
33
40
|
export const canonicalEngine = "dot";
|
|
34
41
|
|
|
42
|
+
// Experimental layout variants (`--layout` / `--all-layouts`) are not part of
|
|
43
|
+
// the generated-view contract: `generated/` holds only the canonical views that
|
|
44
|
+
// `ddduck generate` refreshes and `ddduck check` gates. Variants land in the
|
|
45
|
+
// `.ddduck/` tool-metadata area instead, so they can never rot unswept inside
|
|
46
|
+
// `generated/`.
|
|
35
47
|
export function svgOutputPathFor(engine) {
|
|
36
|
-
return path.join("
|
|
48
|
+
return path.join(".ddduck", "graph-layouts", `model-graph.${engine}.svg`);
|
|
37
49
|
}
|
|
38
50
|
|
|
39
51
|
// Visual vocabulary keyed by the graph's node/edge `kind`. Shapes and fills are
|
|
@@ -214,9 +226,14 @@ function edgeStatements(graphEdge, nodeIds) {
|
|
|
214
226
|
return [edge(graphEdge.from, graphEdge.to, { ...base, label: graphEdge.label ?? graphEdge.kind })];
|
|
215
227
|
}
|
|
216
228
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
229
|
+
/**
|
|
230
|
+
* Pure translation from the model graph JSON to a deterministic DOT string.
|
|
231
|
+
* `clusters` groups each domain and its owned nodes into a titled box; disable
|
|
232
|
+
* it for engines that draw but do not lay out clusters (they would overlap).
|
|
233
|
+
* @param {{modelId?: string, modelName?: string, nodes?: object[], edges?: object[]}} modelGraph - Parsed model-graph.json.
|
|
234
|
+
* @param {{clusters?: boolean, legend?: boolean}} [options] - Cluster domains into boxes; emit the legend cluster.
|
|
235
|
+
* @returns {string} The DOT source.
|
|
236
|
+
*/
|
|
220
237
|
export function graphToDot(modelGraph, { clusters = true, legend = clusters } = {}) {
|
|
221
238
|
const nodes = modelGraph.nodes ?? [];
|
|
222
239
|
const edges = modelGraph.edges ?? [];
|
|
@@ -254,9 +271,14 @@ function loadGraphviz() {
|
|
|
254
271
|
return graphvizInstance;
|
|
255
272
|
}
|
|
256
273
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
274
|
+
/**
|
|
275
|
+
* Render a DOT string to SVG via the Graphviz WASM engine. `engine` selects
|
|
276
|
+
* the layout algorithm. Isolated so the backend can be swapped without
|
|
277
|
+
* touching the translation above.
|
|
278
|
+
* @param {string} dot - DOT source from graphToDot.
|
|
279
|
+
* @param {string} [engine] - One of LAYOUT_ENGINES (default "dot").
|
|
280
|
+
* @returns {Promise<string>} The rendered SVG.
|
|
281
|
+
*/
|
|
260
282
|
export async function renderDotToSvg(dot, engine = "dot") {
|
|
261
283
|
if (!LAYOUT_ENGINES.includes(engine)) {
|
|
262
284
|
throw new Error(`unknown layout engine ${engine}; expected one of ${LAYOUT_ENGINES.join(", ")}`);
|
|
@@ -281,13 +303,22 @@ function writeSvg(rootPath, relativePath, svg) {
|
|
|
281
303
|
return relativePath;
|
|
282
304
|
}
|
|
283
305
|
|
|
284
|
-
|
|
285
|
-
|
|
306
|
+
/**
|
|
307
|
+
* Build the canonical diagram bytes (dot, clustered, legend). Shared by the
|
|
308
|
+
* writer and the freshness check so both agree byte-for-byte.
|
|
309
|
+
* @param {object} modelGraph - Parsed model-graph.json.
|
|
310
|
+
* @returns {Promise<string>} The canonical SVG bytes.
|
|
311
|
+
*/
|
|
286
312
|
export async function buildModelGraphSvg(modelGraph) {
|
|
287
313
|
const dot = graphToDot(modelGraph, { clusters: true, legend: true });
|
|
288
314
|
return renderDotToSvg(dot, canonicalEngine);
|
|
289
315
|
}
|
|
290
316
|
|
|
317
|
+
/**
|
|
318
|
+
* Write the canonical generated/graph/model-graph.svg below the product root.
|
|
319
|
+
* @param {string} rootPath - Product root path.
|
|
320
|
+
* @returns {Promise<string>} The root-relative path that was written.
|
|
321
|
+
*/
|
|
291
322
|
export async function writeModelGraphSvgCanonical(rootPath) {
|
|
292
323
|
const svg = await buildModelGraphSvg(readModelGraph(rootPath));
|
|
293
324
|
return writeSvg(rootPath, svgOutputPath, svg);
|
|
@@ -298,8 +329,14 @@ export async function writeModelGraphSvg(rootPath, engine = "dot") {
|
|
|
298
329
|
return writeSvg(rootPath, svgOutputPathFor(engine), await renderDotToSvg(dot, engine));
|
|
299
330
|
}
|
|
300
331
|
|
|
301
|
-
|
|
302
|
-
|
|
332
|
+
/**
|
|
333
|
+
* Render one SVG per layout engine so the variants can be compared side by
|
|
334
|
+
* side. Clusters are emitted only for the engines that lay them out (dot,
|
|
335
|
+
* fdp). Variants go to .ddduck/graph-layouts/, not generated/.
|
|
336
|
+
* @param {string} rootPath - Product root path.
|
|
337
|
+
* @param {string[]} [engines] - Layout engines to render (default all LAYOUT_ENGINES).
|
|
338
|
+
* @returns {Promise<string[]>} The root-relative variant paths written.
|
|
339
|
+
*/
|
|
303
340
|
export async function writeModelGraphSvgVariants(rootPath, engines = LAYOUT_ENGINES) {
|
|
304
341
|
const modelGraph = readModelGraph(rootPath);
|
|
305
342
|
const writtenPaths = [];
|