ddduck 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/docs/architecture.md +77 -0
- package/docs/cli.md +239 -0
- package/docs/getting-started.md +98 -0
- package/docs/model-reference.md +92 -0
- package/docs/model.md +75 -0
- package/package.json +76 -0
- package/policies/concept-owner-domain.yaml +8 -0
- package/policies/documentation-model-reference-resolution.yaml +8 -0
- package/policies/no-dangling-model-reference.yaml +8 -0
- package/policies/policy-spec.schema.json +23 -0
- package/schemas/context-pack.schema.json +83 -0
- package/schemas/fr-to-code-audit.schema.json +85 -0
- package/schemas/product/concept.schema.json +17 -0
- package/schemas/product/domain-interface.schema.json +18 -0
- package/schemas/product/domain.schema.json +24 -0
- package/schemas/product/evidence-anchor.schema.json +30 -0
- package/schemas/product/guarantee.schema.json +35 -0
- package/schemas/product/model.schema.json +20 -0
- package/schemas/product/relationship.schema.json +21 -0
- package/schemas/product/use-case.schema.json +27 -0
- package/scripts/audit-fr-to-code.mjs +162 -0
- package/scripts/check-generated-docs.mjs +58 -0
- package/scripts/check-generated-graph-svg.mjs +60 -0
- package/scripts/check-generated-graph.mjs +66 -0
- package/scripts/check-model.mjs +488 -0
- package/scripts/ddduck.mjs +542 -0
- package/scripts/generate-agent-readiness-report.mjs +23 -0
- package/scripts/generate-docs.mjs +205 -0
- package/scripts/generate-graph-svg.mjs +359 -0
- package/scripts/generate-graph.mjs +268 -0
- package/scripts/lib/agent-readiness-evals.mjs +433 -0
- package/scripts/lib/agent-readiness-report.mjs +79 -0
- package/scripts/lib/cli-contract.mjs +162 -0
- package/scripts/lib/context-pack.mjs +107 -0
- package/scripts/lib/ddduck-config.mjs +57 -0
- package/scripts/lib/fr-to-code-audit.mjs +144 -0
- package/scripts/lib/product-layout.mjs +93 -0
- package/scripts/lib/product-operation.mjs +431 -0
- package/scripts/lib/product-paths.mjs +43 -0
- package/scripts/lib/product-query.mjs +284 -0
- package/scripts/lib/product-root-resolver.mjs +167 -0
- package/scripts/lib/scan-ignore.mjs +8 -0
- package/scripts/lib/skill-installer.mjs +410 -0
- package/scripts/query-model.mjs +64 -0
- package/scripts/run-agent-readiness-evals.mjs +57 -0
- package/skills/update-ddduck-specs/SKILL.md +98 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
rmdirSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { stringify } from "yaml";
|
|
19
|
+
import { writeModelOverview } from "./generate-docs.mjs";
|
|
20
|
+
import { writeModelGraph } from "./generate-graph.mjs";
|
|
21
|
+
import { checkGeneratedDocs } from "./check-generated-docs.mjs";
|
|
22
|
+
import { checkGeneratedGraph } from "./check-generated-graph.mjs";
|
|
23
|
+
import { assertProductNotBusy, findLeftoverOperationState, runProductOperation } from "./lib/product-operation.mjs";
|
|
24
|
+
import { resolveContainedOutput } from "./lib/product-paths.mjs";
|
|
25
|
+
import { initStagingPrefix, resolveInitDestination, resolveProductRoot } from "./lib/product-root-resolver.mjs";
|
|
26
|
+
import { defaultConfigIgnore, findRepositoryRoot } from "./lib/ddduck-config.mjs";
|
|
27
|
+
import { installSkill } from "./lib/skill-installer.mjs";
|
|
28
|
+
import { runQuery } from "./query-model.mjs";
|
|
29
|
+
import { CliUsageError, parseCommandArgs, renderHelp, writeCliError } from "./lib/cli-contract.mjs";
|
|
30
|
+
|
|
31
|
+
const frameworkRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
32
|
+
|
|
33
|
+
const cliArgs = process.argv.slice(2);
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
run(cliArgs);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
writeCliError(error, { nextAction: nextActionFor(cliArgs) });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function run(args) {
|
|
42
|
+
if (args.length === 1 && args[0] === "--help") {
|
|
43
|
+
process.stdout.write(renderHelp());
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const [command, ...commandArgs] = args;
|
|
47
|
+
if (
|
|
48
|
+
!command ||
|
|
49
|
+
!["init", "check", "generate", "query", "install", "create", "move", "split", "retire"].includes(command)
|
|
50
|
+
) {
|
|
51
|
+
throw new CliUsageError(`Unknown command ${command ?? "(missing)"}`);
|
|
52
|
+
}
|
|
53
|
+
if (commandArgs.includes("--help")) {
|
|
54
|
+
process.stdout.write(renderHelp(command));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (command === "init") {
|
|
58
|
+
const { result, json } = initialize(commandArgs);
|
|
59
|
+
writeProductOperationResult(result, json);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (command === "check") {
|
|
63
|
+
executeChecker(commandArgs);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (command === "generate") {
|
|
67
|
+
generate(commandArgs);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (command === "query") {
|
|
71
|
+
runQuery(commandArgs);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (command === "install") {
|
|
75
|
+
install(commandArgs);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (["create", "move", "split", "retire"].includes(command)) {
|
|
79
|
+
transitionGuarantee(command, commandArgs);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function install(args) {
|
|
85
|
+
const { positionals, options } = parseCommandArgs(args, {
|
|
86
|
+
positionals: { min: 2, max: 2 },
|
|
87
|
+
options: { repo: { value: true } },
|
|
88
|
+
});
|
|
89
|
+
const [kind, skillName] = positionals;
|
|
90
|
+
if (kind !== "skill" || skillName !== "update-ddduck-specs") {
|
|
91
|
+
throw new CliUsageError("install requires skill update-ddduck-specs");
|
|
92
|
+
}
|
|
93
|
+
const packageVersion = JSON.parse(readFileSync(path.join(frameworkRoot, "package.json"), "utf8")).version;
|
|
94
|
+
const repository = path.resolve(options.repo ?? process.cwd());
|
|
95
|
+
const result = installSkill({
|
|
96
|
+
repository,
|
|
97
|
+
skillName,
|
|
98
|
+
skillPath: path.join(frameworkRoot, "skills", skillName, "SKILL.md"),
|
|
99
|
+
packageVersion,
|
|
100
|
+
});
|
|
101
|
+
const actionLabels = { create: "created", upgrade: "upgraded", "no-op": "no-op" };
|
|
102
|
+
process.stdout.write(
|
|
103
|
+
`install skill ${skillName} (${actionLabels[result.action]}) in ${repository}; canonical: ${result.canonicalPath}; lock: ${result.lockPath}\n`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function initialize(args) {
|
|
108
|
+
const { positionals, options } = parseCommandArgs(args, {
|
|
109
|
+
positionals: { min: 0, max: 1 },
|
|
110
|
+
options: { id: { value: true }, json: { value: false } },
|
|
111
|
+
});
|
|
112
|
+
const destination = resolveInitDestination({ explicitDestination: positionals[0] });
|
|
113
|
+
const productId = requiredOption(options, "id", "init requires --id model:<product-id>");
|
|
114
|
+
if (!/^model:[a-z0-9][a-z0-9-]*$/.test(productId)) {
|
|
115
|
+
throw new Error(`Invalid product ID ${JSON.stringify(productId)}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
mkdirSync(path.dirname(destination), { recursive: true });
|
|
119
|
+
if (existsSync(destination) && readdirSync(destination).length > 0) {
|
|
120
|
+
throw new Error(`Refusing to initialize non-empty directory ${destination}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Stage names embed the destination so concurrent inits to sibling
|
|
124
|
+
// destinations never sweep each other's live staging directories.
|
|
125
|
+
const destinationStagePrefix = `${initStagingPrefix}${path.basename(destination)}-`;
|
|
126
|
+
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
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const stagingRoot = mkdtempSync(path.join(path.dirname(destination), destinationStagePrefix));
|
|
132
|
+
try {
|
|
133
|
+
for (const directory of ["domains", "concepts", "relationships", "use-cases", "interfaces", "guarantees"]) {
|
|
134
|
+
mkdirSync(resolveContainedOutput(stagingRoot, path.join("model", directory)), { recursive: true });
|
|
135
|
+
}
|
|
136
|
+
mkdirSync(resolveContainedOutput(stagingRoot, "decisions"), { recursive: true });
|
|
137
|
+
writeYaml(resolveContainedOutput(stagingRoot, "product.yaml"), {
|
|
138
|
+
schemaVersion: "1",
|
|
139
|
+
kind: "Model",
|
|
140
|
+
id: productId,
|
|
141
|
+
name: productId.slice("model:".length),
|
|
142
|
+
purpose: "Define this product.",
|
|
143
|
+
domains: [],
|
|
144
|
+
useCases: [],
|
|
145
|
+
decisions: [],
|
|
146
|
+
});
|
|
147
|
+
refreshDerivedOutput(stagingRoot);
|
|
148
|
+
if (existsSync(destination)) rmdirSync(destination);
|
|
149
|
+
renameSync(stagingRoot, destination);
|
|
150
|
+
try {
|
|
151
|
+
writeConfigIfAbsent(destination);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
// Writing the config is the final publish step. If it fails (for example a
|
|
154
|
+
// regular file already occupies the .ddduck config path), roll back the
|
|
155
|
+
// freshly published product directory so a corrected retry is not refused
|
|
156
|
+
// by the non-empty-directory guard above.
|
|
157
|
+
rmSync(destination, { recursive: true, force: true });
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
} finally {
|
|
161
|
+
rmSync(stagingRoot, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
json: options.json,
|
|
165
|
+
result: {
|
|
166
|
+
operation: "init",
|
|
167
|
+
root: realpathSync(destination),
|
|
168
|
+
affectedIds: [productId],
|
|
169
|
+
canonicalPaths: ["product.yaml"],
|
|
170
|
+
generatedPaths: [
|
|
171
|
+
"generated/docs/model-overview.md",
|
|
172
|
+
"generated/graph/model-graph.json",
|
|
173
|
+
"generated/graph/model-graph.ndjson",
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function executeChecker(args) {
|
|
180
|
+
const { options } = parseCommandArgs(args, {
|
|
181
|
+
options: {
|
|
182
|
+
root: { value: true },
|
|
183
|
+
base: { value: true },
|
|
184
|
+
"docs-root": { value: true, repeatable: true },
|
|
185
|
+
"source-only": { value: false },
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
const root = resolveProductRoot({ explicitRoot: options.root });
|
|
189
|
+
assertProductNotBusy(root);
|
|
190
|
+
const checkerArgs = [path.join(frameworkRoot, "scripts", "check-model.mjs"), "--root", root];
|
|
191
|
+
if (options.base) checkerArgs.push("--base", path.resolve(options.base));
|
|
192
|
+
for (const docsRoot of options["docs-root"]) checkerArgs.push("--docs-root", path.resolve(docsRoot));
|
|
193
|
+
if (options["source-only"]) checkerArgs.push("--source-only");
|
|
194
|
+
const result = spawnSync(process.execPath, checkerArgs, {
|
|
195
|
+
encoding: "utf8",
|
|
196
|
+
});
|
|
197
|
+
if (result.error) {
|
|
198
|
+
throw new Error(`Failed to run the model checker for ${root}: ${result.error.message}`);
|
|
199
|
+
}
|
|
200
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
201
|
+
if (result.status !== 0) {
|
|
202
|
+
const diagnostic = result.stderr.trim();
|
|
203
|
+
throw new CliUsageError(`Validation failed for ${root}${diagnostic ? `:\n${diagnostic}` : ""}`);
|
|
204
|
+
}
|
|
205
|
+
if (options["source-only"]) return;
|
|
206
|
+
try {
|
|
207
|
+
checkGeneratedDocs(root);
|
|
208
|
+
checkGeneratedGraph(root);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
throw new CliUsageError(`Validation failed for ${root}: ${error.message}`, {
|
|
211
|
+
nextAction: `Run ddduck generate --root ${root}, then re-run ddduck check.`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
// The SVG check runs in a child process because rendering is async (WASM).
|
|
215
|
+
const svgCheck = spawnSync(
|
|
216
|
+
process.execPath,
|
|
217
|
+
[path.join(frameworkRoot, "scripts", "check-generated-graph-svg.mjs"), "--root", root],
|
|
218
|
+
{ encoding: "utf8" },
|
|
219
|
+
);
|
|
220
|
+
if (svgCheck.error) {
|
|
221
|
+
// The child never started (e.g. EMFILE/ENOMEM); this is not a stale artifact.
|
|
222
|
+
throw new Error(`Failed to run the model graph SVG check for ${root}: ${svgCheck.error.message}`);
|
|
223
|
+
}
|
|
224
|
+
if (svgCheck.status !== 0) {
|
|
225
|
+
const diagnostic = (svgCheck.stderr || "").trim() || "generated/graph/model-graph.svg is missing or stale";
|
|
226
|
+
throw new CliUsageError(`Validation failed for ${root}: ${diagnostic}`, {
|
|
227
|
+
nextAction: `Run ddduck generate --root ${root}, then re-run ddduck check.`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
const leftover = findLeftoverOperationState(root);
|
|
231
|
+
if (leftover) {
|
|
232
|
+
throw new CliUsageError(
|
|
233
|
+
`Validation failed for ${root}: an interrupted ddduck operation left ${leftover.entries.join(", ")}`,
|
|
234
|
+
{
|
|
235
|
+
nextAction: `Run ddduck generate --root ${root} to reclaim the interrupted operation state, then re-run ddduck check.`,
|
|
236
|
+
},
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function generate(args) {
|
|
242
|
+
const { options } = parseCommandArgs(args, { options: { root: { value: true }, json: { value: false } } });
|
|
243
|
+
const root = resolveProductRoot({ explicitRoot: options.root });
|
|
244
|
+
const result = runProductOperation({
|
|
245
|
+
root,
|
|
246
|
+
transform: () => ({ operation: "generate", affectedIds: [], replacements: [] }),
|
|
247
|
+
});
|
|
248
|
+
writeProductOperationResult(result, options.json);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function transitionGuarantee(command, args) {
|
|
252
|
+
const optionDefinitions = {
|
|
253
|
+
create: {
|
|
254
|
+
origin: { value: true },
|
|
255
|
+
classification: { value: true },
|
|
256
|
+
owner: { value: true },
|
|
257
|
+
statement: { value: true },
|
|
258
|
+
root: { value: true },
|
|
259
|
+
json: { value: false },
|
|
260
|
+
},
|
|
261
|
+
move: { to: { value: true }, root: { value: true }, json: { value: false } },
|
|
262
|
+
split: { into: { value: true }, decision: { value: true }, root: { value: true }, json: { value: false } },
|
|
263
|
+
retire: { decision: { value: true }, root: { value: true }, json: { value: false } },
|
|
264
|
+
};
|
|
265
|
+
const positionalSyntax = {
|
|
266
|
+
create:
|
|
267
|
+
"ddduck create guarantee --origin <origin> --classification <invariant|acceptance-criterion> --owner <domain-id> --statement <text>",
|
|
268
|
+
move: "ddduck move guarantee <guarantee-id> --to <domain-id>",
|
|
269
|
+
split: "ddduck split guarantee <guarantee-id> --into <successor-id,successor-id> --decision ADR-NNN",
|
|
270
|
+
retire: "ddduck retire guarantee <guarantee-id> --decision ADR-NNN",
|
|
271
|
+
};
|
|
272
|
+
const { positionals, options } = parseCommandArgs(args, {
|
|
273
|
+
positionals: {
|
|
274
|
+
min: command === "create" ? 1 : 2,
|
|
275
|
+
max: command === "create" ? 1 : 2,
|
|
276
|
+
syntax: positionalSyntax[command],
|
|
277
|
+
},
|
|
278
|
+
options: optionDefinitions[command],
|
|
279
|
+
});
|
|
280
|
+
const [kind, id] = positionals;
|
|
281
|
+
if (kind !== "guarantee") throw new Error(`${command} requires the entity kind guarantee`);
|
|
282
|
+
const root = resolveProductRoot({ explicitRoot: options.root });
|
|
283
|
+
if (command === "split" || command === "retire") {
|
|
284
|
+
requireRegisteredDecision(root, requiredDecision(options, command), command);
|
|
285
|
+
}
|
|
286
|
+
const result = runProductOperation({
|
|
287
|
+
root,
|
|
288
|
+
transform: (snapshot) => buildGuaranteePlan(command, snapshot, id, options),
|
|
289
|
+
});
|
|
290
|
+
writeProductOperationResult(result, options.json);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function requireRegisteredDecision(root, decision, command) {
|
|
294
|
+
let files;
|
|
295
|
+
try {
|
|
296
|
+
files = readdirSync(path.join(root, "decisions"));
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (error.code !== "ENOENT") throw error;
|
|
299
|
+
files = [];
|
|
300
|
+
}
|
|
301
|
+
if (files.some((file) => file.startsWith(`${decision}-`) && file.endsWith(".md"))) return;
|
|
302
|
+
throw new CliUsageError(
|
|
303
|
+
`${command} requires a registered decision ${decision}: no decisions/${decision}-*.md found in ${root}`,
|
|
304
|
+
{
|
|
305
|
+
nextAction: `Create ${path.join(root, "decisions", `${decision}-<slug>.md`)} recording the decision, then retry.`,
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function writeProductOperationResult(result, json) {
|
|
311
|
+
if (json) {
|
|
312
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const affected = result.affectedIds.length > 0 ? ` ${result.affectedIds.join(", ")}` : "";
|
|
316
|
+
const canonical = result.canonicalPaths.length > 0 ? result.canonicalPaths.join(", ") : "none";
|
|
317
|
+
process.stdout.write(
|
|
318
|
+
`${result.operation}${affected} in ${result.root}; canonical: ${canonical}; generated: ${result.generatedPaths.join(", ")}\n`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function buildGuaranteePlan(command, snapshot, id, options) {
|
|
323
|
+
if (command === "create") return createGuarantee(snapshot, options);
|
|
324
|
+
const guarantee = requireGuarantee(snapshot, id);
|
|
325
|
+
if (guarantee.status !== "active") {
|
|
326
|
+
throw new Error(`${command} requires an active guarantee; ${id} is ${guarantee.status}`);
|
|
327
|
+
}
|
|
328
|
+
if (command === "move") {
|
|
329
|
+
return moveGuarantee(snapshot, guarantee, requiredOption(options, "to", "move requires --to domain:<id>"));
|
|
330
|
+
}
|
|
331
|
+
if (command === "split") {
|
|
332
|
+
return splitGuarantee(
|
|
333
|
+
snapshot,
|
|
334
|
+
guarantee,
|
|
335
|
+
requiredOption(options, "into", "split requires --into <id,id>"),
|
|
336
|
+
requiredDecision(options, "split"),
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
return retireGuarantee(snapshot, guarantee, requiredDecision(options, "retire"));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function createGuarantee(snapshot, options) {
|
|
343
|
+
const origin = requiredOption(options, "origin", "create guarantee requires --origin <origin>").toUpperCase();
|
|
344
|
+
const classification = requiredOption(
|
|
345
|
+
options,
|
|
346
|
+
"classification",
|
|
347
|
+
"create guarantee requires --classification invariant|acceptance-criterion",
|
|
348
|
+
);
|
|
349
|
+
const ownerDomain = requiredOption(options, "owner", "create guarantee requires --owner domain:<id>");
|
|
350
|
+
const statement = requiredOption(options, "statement", "create guarantee requires --statement <text>");
|
|
351
|
+
if (!/^[A-Z][A-Z0-9-]+$/.test(origin)) {
|
|
352
|
+
throw new Error(
|
|
353
|
+
`Invalid guarantee origin ${JSON.stringify(origin)}; --origin requires two or more characters matching [A-Z][A-Z0-9-]+`,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
const classificationPrefix =
|
|
357
|
+
classification === "invariant" ? "INV" : classification === "acceptance-criterion" ? "AC" : null;
|
|
358
|
+
if (!classificationPrefix) throw new Error(`Invalid guarantee classification ${JSON.stringify(classification)}`);
|
|
359
|
+
requireDomain(snapshot, ownerDomain);
|
|
360
|
+
|
|
361
|
+
const prefix = `${origin}-${classificationPrefix}-`;
|
|
362
|
+
const serial =
|
|
363
|
+
Math.max(
|
|
364
|
+
0,
|
|
365
|
+
...snapshot.nodes.flatMap(({ id }) => {
|
|
366
|
+
const match = id.match(new RegExp(`^${prefix}(\\d+)$`));
|
|
367
|
+
return match ? [Number(match[1])] : [];
|
|
368
|
+
}),
|
|
369
|
+
) + 1;
|
|
370
|
+
const id = `${prefix}${String(serial).padStart(2, "0")}`;
|
|
371
|
+
const fileName = `${id.toLowerCase()}.yaml`;
|
|
372
|
+
const model = requireModel(snapshot);
|
|
373
|
+
const guarantee = {
|
|
374
|
+
schemaVersion: "1",
|
|
375
|
+
kind: "Guarantee",
|
|
376
|
+
id,
|
|
377
|
+
model: model.id,
|
|
378
|
+
ownerDomain,
|
|
379
|
+
classification,
|
|
380
|
+
statement,
|
|
381
|
+
status: "active",
|
|
382
|
+
};
|
|
383
|
+
return {
|
|
384
|
+
operation: "create guarantee",
|
|
385
|
+
affectedIds: [id],
|
|
386
|
+
replacements: [
|
|
387
|
+
{ path: `model/guarantees/${fileName}`, value: guarantee },
|
|
388
|
+
updateDomainGuarantees(snapshot, ownerDomain, (ids) => [...ids, id]),
|
|
389
|
+
],
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function moveGuarantee(snapshot, guarantee, destinationDomain) {
|
|
394
|
+
requireDomain(snapshot, destinationDomain);
|
|
395
|
+
if (guarantee.ownerDomain === destinationDomain)
|
|
396
|
+
throw new Error(`${guarantee.id} is already owned by ${destinationDomain}`);
|
|
397
|
+
const previousOwner = guarantee.ownerDomain;
|
|
398
|
+
const updated = {
|
|
399
|
+
...guarantee,
|
|
400
|
+
ownerDomain: destinationDomain,
|
|
401
|
+
ownershipHistory: [...new Set([...(guarantee.ownershipHistory ?? []), previousOwner])],
|
|
402
|
+
};
|
|
403
|
+
return {
|
|
404
|
+
operation: "move guarantee",
|
|
405
|
+
affectedIds: [guarantee.id],
|
|
406
|
+
replacements: [
|
|
407
|
+
{ path: snapshot.canonicalPaths[guarantee.id], value: updated },
|
|
408
|
+
updateDomainGuarantees(snapshot, previousOwner, (ids) => ids.filter((id) => id !== guarantee.id)),
|
|
409
|
+
updateDomainGuarantees(snapshot, destinationDomain, (ids) => [...ids, guarantee.id]),
|
|
410
|
+
],
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function splitGuarantee(snapshot, guarantee, into, lifecycleDecision) {
|
|
415
|
+
const successors = into
|
|
416
|
+
.split(",")
|
|
417
|
+
.map((id) => id.trim())
|
|
418
|
+
.filter(Boolean);
|
|
419
|
+
if (successors.length === 0 || new Set(successors).size !== successors.length) {
|
|
420
|
+
throw new Error("split requires one or more distinct successor guarantee IDs");
|
|
421
|
+
}
|
|
422
|
+
for (const successorId of successors) {
|
|
423
|
+
if (successorId === guarantee.id) throw new Error("split successor cannot be the source guarantee");
|
|
424
|
+
const successor = requireGuarantee(snapshot, successorId);
|
|
425
|
+
if (successor.status !== "active") throw new Error(`split successor must be active: ${successorId}`);
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
operation: "split guarantee",
|
|
429
|
+
affectedIds: [guarantee.id, ...successors],
|
|
430
|
+
replacements: [
|
|
431
|
+
{
|
|
432
|
+
path: snapshot.canonicalPaths[guarantee.id],
|
|
433
|
+
value: { ...guarantee, status: "split", successors, lifecycleDecision },
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function retireGuarantee(snapshot, guarantee, lifecycleDecision) {
|
|
440
|
+
return {
|
|
441
|
+
operation: "retire guarantee",
|
|
442
|
+
affectedIds: [guarantee.id],
|
|
443
|
+
replacements: [
|
|
444
|
+
{
|
|
445
|
+
path: snapshot.canonicalPaths[guarantee.id],
|
|
446
|
+
value: { ...guarantee, status: "retired", lifecycleDecision },
|
|
447
|
+
},
|
|
448
|
+
],
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function updateDomainGuarantees(snapshot, domainId, update) {
|
|
453
|
+
const domain = requireDomain(snapshot, domainId);
|
|
454
|
+
return {
|
|
455
|
+
path: snapshot.canonicalPaths[domainId],
|
|
456
|
+
value: { ...domain, guarantees: update(domain.guarantees ?? []) },
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function requireModel(snapshot) {
|
|
461
|
+
const models = snapshot.nodes.filter((node) => node.kind === "Model");
|
|
462
|
+
if (models.length !== 1) throw new Error(`Expected one Model node, found ${models.length}`);
|
|
463
|
+
return models[0];
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function requireGuarantee(snapshot, id) {
|
|
467
|
+
const node = snapshot.nodes.find((candidate) => candidate.id === id);
|
|
468
|
+
if (!node || node.kind !== "Guarantee") throw new Error(`Unknown guarantee ${id}`);
|
|
469
|
+
return node;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function requireDomain(snapshot, id) {
|
|
473
|
+
const node = snapshot.nodes.find((candidate) => candidate.id === id);
|
|
474
|
+
if (!node || node.kind !== "Domain") {
|
|
475
|
+
const known = snapshot.nodes
|
|
476
|
+
.filter((candidate) => candidate.kind === "Domain")
|
|
477
|
+
.map((candidate) => candidate.id)
|
|
478
|
+
.sort();
|
|
479
|
+
throw new Error(
|
|
480
|
+
`Unknown domain ${id}; domain IDs use the domain: prefix${known.length > 0 ? `; known domains: ${known.join(", ")}` : ""}`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
return node;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function requiredOption(options, name, message) {
|
|
487
|
+
if (!options[name]) throw new Error(message);
|
|
488
|
+
return options[name];
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function requiredDecision(options, command) {
|
|
492
|
+
const decision = requiredOption(options, "decision", `${command} requires --decision ADR-NNN`);
|
|
493
|
+
if (!/^ADR-[0-9]{3}$/.test(decision)) throw new Error(`${command} requires --decision ADR-NNN`);
|
|
494
|
+
return decision;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function refreshDerivedOutput(root) {
|
|
498
|
+
writeModelOverview(root);
|
|
499
|
+
writeModelGraph(root);
|
|
500
|
+
renderModelGraphSvg(root);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Render the canonical SVG via a child process (the WASM renderer is async;
|
|
504
|
+
// this keeps the init path synchronous, mirroring the operation runner).
|
|
505
|
+
function renderModelGraphSvg(root) {
|
|
506
|
+
const result = spawnSync(
|
|
507
|
+
process.execPath,
|
|
508
|
+
[path.join(frameworkRoot, "scripts", "generate-graph-svg.mjs"), "--root", root],
|
|
509
|
+
{ encoding: "utf8" },
|
|
510
|
+
);
|
|
511
|
+
if (result.status !== 0) {
|
|
512
|
+
throw new Error(`Failed to render the model graph SVG: ${(result.stderr || result.error?.message || "").trim()}`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function writeYaml(filePath, value) {
|
|
517
|
+
writeFileSync(filePath, stringify(value));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function writeConfigIfAbsent(destination) {
|
|
521
|
+
const repositoryRoot = findRepositoryRoot(destination);
|
|
522
|
+
const configPath = path.join(repositoryRoot, ".ddduck", "config.json");
|
|
523
|
+
if (existsSync(configPath)) return;
|
|
524
|
+
const relativeProductRoot = path.relative(repositoryRoot, destination).split(path.sep).join("/");
|
|
525
|
+
// findRepositoryRoot falls back to the destination itself when no enclosing
|
|
526
|
+
// .git exists, which makes the relative path empty; "." keeps productRoot
|
|
527
|
+
// a valid non-empty string that still resolves to the same directory.
|
|
528
|
+
const productRoot = relativeProductRoot === "" ? "." : relativeProductRoot;
|
|
529
|
+
mkdirSync(path.dirname(configPath), { recursive: true });
|
|
530
|
+
writeFileSync(
|
|
531
|
+
configPath,
|
|
532
|
+
`${JSON.stringify({ schemaVersion: "1", productRoot, ignore: [...defaultConfigIgnore] }, null, 2)}\n`,
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function nextActionFor(args) {
|
|
537
|
+
const command = args[0];
|
|
538
|
+
if (["init", "check", "generate", "query", "install", "create", "move", "split", "retire"].includes(command)) {
|
|
539
|
+
return `Run ddduck ${command} --help, correct the input, and retry.`;
|
|
540
|
+
}
|
|
541
|
+
return "Run ddduck --help, choose a command, and retry.";
|
|
542
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { generateAgentReadinessReport } from "./lib/agent-readiness-report.mjs";
|
|
5
|
+
|
|
6
|
+
try {
|
|
7
|
+
const rootPath = parseArgs(process.argv.slice(2));
|
|
8
|
+
const report = generateAgentReadinessReport(rootPath);
|
|
9
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
10
|
+
if (Object.keys(report).length === 1 && Object.hasOwn(report, "unresolvedReferences")) {
|
|
11
|
+
process.exitCode = 1;
|
|
12
|
+
}
|
|
13
|
+
} catch (error) {
|
|
14
|
+
process.stderr.write(`${error.message}\n`);
|
|
15
|
+
process.exitCode = 1;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseArgs(args) {
|
|
19
|
+
if (args.length !== 2 || args[0] !== "--root" || !args[1] || args[1].startsWith("--")) {
|
|
20
|
+
throw new Error("usage: node scripts/generate-agent-readiness-report.mjs --root <product-root>");
|
|
21
|
+
}
|
|
22
|
+
return path.resolve(args[1]);
|
|
23
|
+
}
|