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
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The locked, staged mutation runner behind every product-changing ddduck
|
|
3
|
+
* command (generate, create/move/split/retire). Holds the invariants: one
|
|
4
|
+
* operation at a time per product root (.ddduck-operation.lock, reclaimable
|
|
5
|
+
* only when its owner PID is dead), all edits validated in a staging copy
|
|
6
|
+
* before any canonical or generated file is published, and canonical YAML
|
|
7
|
+
* rewritten comment-preservingly. Also exports the canonical generatedPaths
|
|
8
|
+
* list and the busy/leftover-state probes used by check and query.
|
|
9
|
+
*/
|
|
10
|
+
|
|
1
11
|
import { spawnSync } from "node:child_process";
|
|
2
12
|
import {
|
|
3
13
|
closeSync,
|
|
@@ -16,10 +26,11 @@ import {
|
|
|
16
26
|
} from "node:fs";
|
|
17
27
|
import path from "node:path";
|
|
18
28
|
import { fileURLToPath } from "node:url";
|
|
19
|
-
import { stringify } from "yaml";
|
|
29
|
+
import { isScalar, parseDocument, stringify } from "yaml";
|
|
20
30
|
import { checkGeneratedDocs } from "../check-generated-docs.mjs";
|
|
21
31
|
import { checkGeneratedGraph } from "../check-generated-graph.mjs";
|
|
22
32
|
import { validateProduct } from "../check-model.mjs";
|
|
33
|
+
import { shellQuote } from "./context-pack.mjs";
|
|
23
34
|
import { writeModelOverview } from "../generate-docs.mjs";
|
|
24
35
|
import { writeModelGraph } from "../generate-graph.mjs";
|
|
25
36
|
import { detectProductLayout, loadProductSnapshot } from "./product-layout.mjs";
|
|
@@ -29,19 +40,23 @@ import { nonProductSourceEntries } from "./product-root-resolver.mjs";
|
|
|
29
40
|
const lockRelativePath = ".ddduck-operation.lock";
|
|
30
41
|
const reclaimRelativePath = ".ddduck-operation.reclaim";
|
|
31
42
|
const stagingPrefix = ".ddduck-operation-stage-";
|
|
32
|
-
|
|
43
|
+
// The canonical generated views: one list consumed by mutation results, query
|
|
44
|
+
// freshness, the check gate, and the docs. The SVG is rendered by the Graphviz
|
|
45
|
+
// WASM engine, which is async, so we produce it in a child process to keep this
|
|
46
|
+
// runner synchronous (mirrors the check subprocess).
|
|
47
|
+
export const generatedPaths = Object.freeze([
|
|
33
48
|
"generated/docs/model-overview.md",
|
|
34
49
|
"generated/graph/model-graph.json",
|
|
35
50
|
"generated/graph/model-graph.ndjson",
|
|
51
|
+
"generated/graph/model-graph.svg",
|
|
36
52
|
]);
|
|
37
|
-
// The canonical SVG is published atomically alongside the reported outputs, but
|
|
38
|
-
// kept off `generatedPaths` so it stays out of the CLI/query freshness contract.
|
|
39
|
-
// It is rendered by the Graphviz WASM engine, which is async, so we produce it in
|
|
40
|
-
// a child process to keep this runner synchronous (mirrors the check subprocess).
|
|
41
|
-
const auxiliaryGeneratedPaths = Object.freeze(["generated/graph/model-graph.svg"]);
|
|
42
53
|
const svgGeneratorScript = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "generate-graph-svg.mjs");
|
|
43
54
|
const excludedSourceEntries = new Set([...nonProductSourceEntries, lockRelativePath, reclaimRelativePath]);
|
|
44
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Raised when a product root's operation lock is held; carries exitCode 2 when
|
|
58
|
+
* the holder is a live process (the one retryable failure).
|
|
59
|
+
*/
|
|
45
60
|
export class ProductBusyError extends Error {
|
|
46
61
|
constructor(root, lockPath, ownerPid) {
|
|
47
62
|
const holder = ownerPid ? ` (held by running process ${ownerPid})` : "";
|
|
@@ -50,9 +65,22 @@ export class ProductBusyError extends Error {
|
|
|
50
65
|
this.nextAction = ownerPid
|
|
51
66
|
? `Wait for process ${ownerPid} to finish and retry, or delete ${lockPath} and retry if that process is not a ddduck operation.`
|
|
52
67
|
: `If no other ddduck operation is running on this product, delete ${lockPath} and retry.`;
|
|
68
|
+
// Contention with a running ddduck operation is the one retryable failure;
|
|
69
|
+
// exit code 2 lets callers retry it without string-matching stderr. Every
|
|
70
|
+
// other failure, including a lock without a live owner, stays exit 1.
|
|
71
|
+
if (ownerPid) this.exitCode = 2;
|
|
53
72
|
}
|
|
54
73
|
}
|
|
55
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Run one mutation against a product root: acquire the lock, sweep dead
|
|
77
|
+
* reclaim claims and orphaned staging, copy the source into staging, apply the
|
|
78
|
+
* transform's replacement plan, validate the staged product, regenerate every
|
|
79
|
+
* generated view (SVG via subprocess), then publish canonical and generated
|
|
80
|
+
* files back file-by-file. The lock and staging are always cleaned up.
|
|
81
|
+
* @param {{root: string, transform: (snapshot: {nodes: object[], canonicalPaths: Record<string, string>}) => {operation: string, affectedIds: string[], replacements: {path: string, value: object}[]}}} params - Product root and plan-building transform.
|
|
82
|
+
* @returns {{operation: string, root: string, affectedIds: string[], canonicalPaths: string[], generatedPaths: string[]}} The published operation result.
|
|
83
|
+
*/
|
|
56
84
|
export function runProductOperation({ root, transform }) {
|
|
57
85
|
if (typeof transform !== "function") throw new TypeError("product operation requires a transform function");
|
|
58
86
|
const normalizedRoot = realpathSync(path.resolve(root));
|
|
@@ -60,9 +88,6 @@ export function runProductOperation({ root, transform }) {
|
|
|
60
88
|
const publishGeneratedTargets = generatedPaths.map((relativePath) =>
|
|
61
89
|
resolveContainedOutput(normalizedRoot, relativePath),
|
|
62
90
|
);
|
|
63
|
-
const publishAuxiliaryTargets = auxiliaryGeneratedPaths.map((relativePath) =>
|
|
64
|
-
resolveContainedOutput(normalizedRoot, relativePath),
|
|
65
|
-
);
|
|
66
91
|
let lockAcquired = false;
|
|
67
92
|
let stagingRoot;
|
|
68
93
|
|
|
@@ -77,7 +102,7 @@ export function runProductOperation({ root, transform }) {
|
|
|
77
102
|
const snapshot = loadProductSnapshot(detectProductLayout(stagingRoot));
|
|
78
103
|
const plan = validatePlan(transform(snapshot));
|
|
79
104
|
const canonicalPaths = applyReplacements(stagingRoot, plan.replacements);
|
|
80
|
-
validateStagedProduct(stagingRoot);
|
|
105
|
+
validateStagedProduct(stagingRoot, normalizedRoot);
|
|
81
106
|
writeModelOverview(stagingRoot);
|
|
82
107
|
writeModelGraph(stagingRoot);
|
|
83
108
|
renderModelGraphSvg(stagingRoot);
|
|
@@ -96,10 +121,6 @@ export function runProductOperation({ root, transform }) {
|
|
|
96
121
|
relativePath,
|
|
97
122
|
targetPath: publishGeneratedTargets[index],
|
|
98
123
|
})),
|
|
99
|
-
...auxiliaryGeneratedPaths.map((relativePath, index) => ({
|
|
100
|
-
relativePath,
|
|
101
|
-
targetPath: publishAuxiliaryTargets[index],
|
|
102
|
-
})),
|
|
103
124
|
]);
|
|
104
125
|
for (let index = 0; index < canonicalPaths.length; index += 1) {
|
|
105
126
|
copyPublishedFile(stagingRoot, canonicalPaths[index], publishCanonicalTargets[index]);
|
|
@@ -107,9 +128,6 @@ export function runProductOperation({ root, transform }) {
|
|
|
107
128
|
for (let index = 0; index < generatedPaths.length; index += 1) {
|
|
108
129
|
copyPublishedFile(stagingRoot, generatedPaths[index], publishGeneratedTargets[index]);
|
|
109
130
|
}
|
|
110
|
-
for (let index = 0; index < auxiliaryGeneratedPaths.length; index += 1) {
|
|
111
|
-
copyPublishedFile(stagingRoot, auxiliaryGeneratedPaths[index], publishAuxiliaryTargets[index]);
|
|
112
|
-
}
|
|
113
131
|
|
|
114
132
|
return {
|
|
115
133
|
operation: plan.operation,
|
|
@@ -278,7 +296,12 @@ function readLockOwner(lockPath) {
|
|
|
278
296
|
return { state: "unknown" };
|
|
279
297
|
}
|
|
280
298
|
|
|
281
|
-
|
|
299
|
+
/**
|
|
300
|
+
* Probe whether a PID belongs to a live process (signal 0; EPERM counts as alive).
|
|
301
|
+
* @param {number} pid - Process ID recorded in a lock, claim, or init stage name.
|
|
302
|
+
* @returns {boolean} True unless the process is definitely gone (ESRCH).
|
|
303
|
+
*/
|
|
304
|
+
export function isProcessAlive(pid) {
|
|
282
305
|
try {
|
|
283
306
|
process.kill(pid, 0);
|
|
284
307
|
return true;
|
|
@@ -310,6 +333,12 @@ function sweepOrphanedStaging(root) {
|
|
|
310
333
|
}
|
|
311
334
|
}
|
|
312
335
|
|
|
336
|
+
/**
|
|
337
|
+
* Throw ProductBusyError if the product's operation lock is held by a live
|
|
338
|
+
* process; used by read paths (check, query) that must not run mid-mutation.
|
|
339
|
+
* @param {string} root - Product root path.
|
|
340
|
+
* @returns {void}
|
|
341
|
+
*/
|
|
313
342
|
export function assertProductNotBusy(root) {
|
|
314
343
|
const normalizedRoot = realpathSync(path.resolve(root));
|
|
315
344
|
const lockPath = path.join(normalizedRoot, lockRelativePath);
|
|
@@ -319,6 +348,13 @@ export function assertProductNotBusy(root) {
|
|
|
319
348
|
}
|
|
320
349
|
}
|
|
321
350
|
|
|
351
|
+
/**
|
|
352
|
+
* List interrupted-operation debris (ownerless lock, dead reclaim claims,
|
|
353
|
+
* staging directories) that a future operation would reclaim; live locks are
|
|
354
|
+
* not leftovers.
|
|
355
|
+
* @param {string} root - Product root path.
|
|
356
|
+
* @returns {{root: string, entries: string[]}|null} The leftover entries, or null when the root is clean.
|
|
357
|
+
*/
|
|
322
358
|
export function findLeftoverOperationState(root) {
|
|
323
359
|
const normalizedRoot = realpathSync(path.resolve(root));
|
|
324
360
|
const lockPath = path.join(normalizedRoot, lockRelativePath);
|
|
@@ -400,13 +436,56 @@ function applyReplacements(stagingRoot, replacements) {
|
|
|
400
436
|
}
|
|
401
437
|
const target = resolveContainedOutput(stagingRoot, relativePath);
|
|
402
438
|
mkdirSync(path.dirname(target), { recursive: true });
|
|
403
|
-
writeFileSync(target,
|
|
439
|
+
writeFileSync(target, serializeReplacement(target, relativePath, replacement.value));
|
|
404
440
|
seenPaths.add(relativePath);
|
|
405
441
|
canonicalPaths.push(relativePath);
|
|
406
442
|
}
|
|
407
443
|
return canonicalPaths.sort();
|
|
408
444
|
}
|
|
409
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Rewrite an existing canonical file by editing its parsed YAML document key by
|
|
448
|
+
* key instead of re-serializing the plan value wholesale, so authored comments
|
|
449
|
+
* and formatting outside the keys a mutation actually changes survive. New
|
|
450
|
+
* files have no authored content to preserve and take the plain serialization.
|
|
451
|
+
* Staged validation still covers the exact published bytes: this runs before
|
|
452
|
+
* validateStagedProduct, and publication copies these staged bytes verbatim.
|
|
453
|
+
* @param {string} target - Absolute staged path of the canonical file.
|
|
454
|
+
* @param {string} relativePath - Root-relative canonical path (for diagnostics).
|
|
455
|
+
* @param {object} value - The replacement YAML mapping from the operation plan.
|
|
456
|
+
* @returns {string} The staged file's new YAML source.
|
|
457
|
+
*/
|
|
458
|
+
function serializeReplacement(target, relativePath, value) {
|
|
459
|
+
let source;
|
|
460
|
+
try {
|
|
461
|
+
source = readFileSync(target, "utf8");
|
|
462
|
+
} catch (error) {
|
|
463
|
+
if (error.code !== "ENOENT") throw error;
|
|
464
|
+
return stringify(value);
|
|
465
|
+
}
|
|
466
|
+
const document = parseDocument(source, { keepSourceTokens: true, strict: true, uniqueKeys: true });
|
|
467
|
+
if (document.errors.length > 0) {
|
|
468
|
+
throw new Error(
|
|
469
|
+
`invalid YAML in canonical replacement ${relativePath}: ${document.errors.map((error) => error.message).join("; ")}`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
const current = document.toJSON();
|
|
473
|
+
if (current === null || typeof current !== "object" || Array.isArray(current)) return stringify(value);
|
|
474
|
+
for (const [key, next] of Object.entries(value)) {
|
|
475
|
+
if (key in current && JSON.stringify(current[key]) === JSON.stringify(next)) continue;
|
|
476
|
+
const existing = document.get(key, true);
|
|
477
|
+
if (isScalar(existing) && (next === null || typeof next !== "object")) {
|
|
478
|
+
existing.value = next;
|
|
479
|
+
} else {
|
|
480
|
+
document.set(key, document.createNode(next));
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
for (const key of Object.keys(current)) {
|
|
484
|
+
if (!(key in value)) document.delete(key);
|
|
485
|
+
}
|
|
486
|
+
return document.toString();
|
|
487
|
+
}
|
|
488
|
+
|
|
410
489
|
function normalizeCanonicalPath(relativePath) {
|
|
411
490
|
if (typeof relativePath !== "string") throw new TypeError("canonical replacement requires path");
|
|
412
491
|
const normalized = relativePath.split(path.sep).join("/");
|
|
@@ -419,9 +498,40 @@ function normalizeCanonicalPath(relativePath) {
|
|
|
419
498
|
return normalized;
|
|
420
499
|
}
|
|
421
500
|
|
|
422
|
-
function validateStagedProduct(stagingRoot) {
|
|
501
|
+
function validateStagedProduct(stagingRoot, root) {
|
|
423
502
|
const check = validateProduct(stagingRoot);
|
|
424
|
-
if (check.errors.length > 0) throw
|
|
503
|
+
if (check.errors.length > 0) throw validationFailureError(root, check.errors);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Build the error for a failed product validation. Validation failures are
|
|
508
|
+
* model failures, not CLI-input failures, so the Next: line must point at the
|
|
509
|
+
* real unblock instead of the per-command usage hint: referential lifecycle
|
|
510
|
+
* blockers name the referencing file and the edit-regenerate-retry path,
|
|
511
|
+
* base-retention failures name the sanctioned remedies, and everything else is
|
|
512
|
+
* a source-file fix.
|
|
513
|
+
* @param {string} root - Product root the validation ran against.
|
|
514
|
+
* @param {string[]} errors - Checker diagnostics, one per line.
|
|
515
|
+
* @returns {Error} Error with a tailored nextAction property.
|
|
516
|
+
*/
|
|
517
|
+
export function validationFailureError(root, errors) {
|
|
518
|
+
const error = new Error(`Validation failed for ${root}:\n${errors.join("\n")}`);
|
|
519
|
+
const referencingFiles = [
|
|
520
|
+
...new Set(
|
|
521
|
+
errors
|
|
522
|
+
.filter((line) => /: (?:non-effective guarantee|split successor must be) /.test(line))
|
|
523
|
+
.map((line) => line.slice(0, line.indexOf(":"))),
|
|
524
|
+
),
|
|
525
|
+
];
|
|
526
|
+
if (referencingFiles.length > 0) {
|
|
527
|
+
error.nextAction = `Edit ${referencingFiles.join(", ")} to remove or replace the blocking guarantee reference, run ddduck generate --root ${shellQuote(root)}, and retry.`;
|
|
528
|
+
} else if (errors.some((line) => line.startsWith("guarantee disappeared from the product"))) {
|
|
529
|
+
error.nextAction =
|
|
530
|
+
"Restore the guarantee record from the base product, or record the transition with ddduck retire or ddduck split, then re-run ddduck check.";
|
|
531
|
+
} else {
|
|
532
|
+
error.nextAction = "Fix the listed source files, then re-run ddduck check.";
|
|
533
|
+
}
|
|
534
|
+
return error;
|
|
425
535
|
}
|
|
426
536
|
|
|
427
537
|
function copyPublishedFile(stagingRoot, relativePath, targetPath) {
|
|
@@ -1,6 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path-containment guard for every write below a product root. Any file
|
|
3
|
+
* ddduck creates or publishes (generated views, staged canonical files, lock
|
|
4
|
+
* and staging paths, eval outputs) resolves its destination through
|
|
5
|
+
* resolveContainedOutput, which rejects absolute paths, `..` traversal,
|
|
6
|
+
* escapes above the root, and symbolic links anywhere on the target path — so
|
|
7
|
+
* no operation can be steered into writing outside the root.
|
|
8
|
+
*/
|
|
9
|
+
|
|
1
10
|
import { lstatSync, realpathSync } from "node:fs";
|
|
2
11
|
import path from "node:path";
|
|
3
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Resolve a root-relative output path to an absolute one, proving it stays
|
|
15
|
+
* below the real root and traverses or targets no symbolic link.
|
|
16
|
+
* @param {string} rootPath - The containing root (product root or repo root).
|
|
17
|
+
* @param {string} relativePath - Root-relative destination path.
|
|
18
|
+
* @returns {string} The absolute, contained target path.
|
|
19
|
+
*/
|
|
4
20
|
export function resolveContainedOutput(rootPath, relativePath) {
|
|
5
21
|
if (path.isAbsolute(relativePath)) {
|
|
6
22
|
throw new Error("output path must be relative to root");
|
|
@@ -1,36 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read-only query engine behind `ddduck query`: loadQueryProduct builds a
|
|
3
|
+
* validated in-memory product (nodes, derived graph edges, source digest,
|
|
4
|
+
* lifecycle redirects for inactive guarantees) after refusing busy roots and
|
|
5
|
+
* interrupted-operation leftovers, and the query* functions implement the
|
|
6
|
+
* node, neighbors, impact, anchors, and spec operations. Anchors and spec
|
|
7
|
+
* also report generated-view freshness (the SVG checked via subprocess) and
|
|
8
|
+
* the check/generate verification commands agents should run.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
1
12
|
import { readFileSync, readdirSync } from "node:fs";
|
|
2
13
|
import path from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
3
15
|
import { validateProduct } from "../check-model.mjs";
|
|
4
16
|
import { buildModelOverview } from "../generate-docs.mjs";
|
|
5
17
|
import { buildModelGraph, buildModelGraphOutputs } from "../generate-graph.mjs";
|
|
6
|
-
import { sourceDigestFromSources } from "./context-pack.mjs";
|
|
18
|
+
import { shellQuote, sourceDigestFromSources, unknownModelNodeError } from "./context-pack.mjs";
|
|
7
19
|
import { detectProductLayout, loadProductNodes } from "./product-layout.mjs";
|
|
8
|
-
import { assertProductNotBusy } from "./product-operation.mjs";
|
|
20
|
+
import { assertProductNotBusy, findLeftoverOperationState, generatedPaths } from "./product-operation.mjs";
|
|
9
21
|
|
|
10
22
|
const impactEdgeKinds = new Set(["owns", "requires", "preserves", "establishes", "uses", "guarantees"]);
|
|
11
23
|
// Only these kinds accept an `evidence` field in their schemas, so source and
|
|
12
24
|
// verification anchors may only be demanded of them.
|
|
13
25
|
const evidenceAnchorKinds = new Set(["DomainInterface", "UseCase", "Guarantee"]);
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
|
|
26
|
+
const svgViewPath = "generated/graph/model-graph.svg";
|
|
27
|
+
const svgCheckerScript = path.resolve(
|
|
28
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
29
|
+
"..",
|
|
30
|
+
"check-generated-graph-svg.mjs",
|
|
31
|
+
);
|
|
19
32
|
function verificationCommandsFor(product) {
|
|
20
33
|
const root = shellQuote(product.root);
|
|
21
34
|
return [`ddduck check --root ${root}`, `ddduck generate --root ${root}`];
|
|
22
35
|
}
|
|
23
36
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Load the queryable product for a root: refuse busy or interrupted state,
|
|
39
|
+
* validate the source (documentation excluded), and assemble nodes, edges,
|
|
40
|
+
* source paths, the sha256 source digest, decisions, and policies.
|
|
41
|
+
* @param {string} rootPath - Product root path.
|
|
42
|
+
* @param {{history?: boolean}} [options] - With history, inactive guarantees stay in `nodes`; otherwise they become lifecycleRedirects.
|
|
43
|
+
* @returns {{root: string, rootModelId: string, nodes: Map<string, object>, sourcePaths: Map<string, string>, sourceDigest: string, edges: object[], lifecycleRedirects: Map<string, object>, decisions: Map<string, string>, policies: string[]}} The loaded query product.
|
|
44
|
+
*/
|
|
31
45
|
export function loadQueryProduct(rootPath, { history = false } = {}) {
|
|
32
46
|
const layout = detectProductLayout(rootPath);
|
|
33
47
|
assertProductNotBusy(layout.root);
|
|
48
|
+
// Leftover state from an interrupted mutation means the snapshot may be
|
|
49
|
+
// partially published; refuse to serve a digest over it, exactly like check.
|
|
50
|
+
const leftover = findLeftoverOperationState(layout.root);
|
|
51
|
+
if (leftover) {
|
|
52
|
+
const error = new Error(`An interrupted ddduck operation left ${leftover.entries.join(", ")} in ${layout.root}`);
|
|
53
|
+
error.nextAction = `Run ddduck generate --root ${shellQuote(layout.root)} to reclaim the interrupted operation state, then retry the query.`;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
34
56
|
const check = validateProduct(layout.root, { includeDocumentation: false });
|
|
35
57
|
if (check.errors.length > 0) throw new Error(check.errors.join("\n"));
|
|
36
58
|
const loaded = loadProductNodes(layout);
|
|
@@ -69,6 +91,14 @@ export function loadQueryProduct(rootPath, { history = false } = {}) {
|
|
|
69
91
|
};
|
|
70
92
|
}
|
|
71
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Answer `query node`: the full node, or a lifecycleRedirect for an inactive
|
|
96
|
+
* guarantee ID.
|
|
97
|
+
* @param {object} product - Product from loadQueryProduct.
|
|
98
|
+
* @param {string} id - Model node ID.
|
|
99
|
+
* @param {{history?: boolean}} [options] - Echoed into the query envelope.
|
|
100
|
+
* @returns {object} The query document.
|
|
101
|
+
*/
|
|
72
102
|
export function queryNode(product, id, { history = false } = {}) {
|
|
73
103
|
const node = product.nodes.get(id);
|
|
74
104
|
if (node) {
|
|
@@ -78,15 +108,23 @@ export function queryNode(product, id, { history = false } = {}) {
|
|
|
78
108
|
if (lifecycleRedirect) {
|
|
79
109
|
return envelope(product, "node", id, history, { lifecycleRedirect });
|
|
80
110
|
}
|
|
81
|
-
throw
|
|
111
|
+
throw unknownModelNodeError(product, id);
|
|
82
112
|
}
|
|
83
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Answer `query neighbors`: every incoming and outgoing edge of a node with a
|
|
116
|
+
* summary of the peer node on each edge.
|
|
117
|
+
* @param {object} product - Product from loadQueryProduct.
|
|
118
|
+
* @param {string} id - Model node ID.
|
|
119
|
+
* @param {{history?: boolean}} [options] - Echoed into the query envelope.
|
|
120
|
+
* @returns {object} The query document.
|
|
121
|
+
*/
|
|
84
122
|
export function queryNeighbors(product, id, { history = false } = {}) {
|
|
85
123
|
const node = product.nodes.get(id);
|
|
86
124
|
if (!node) {
|
|
87
125
|
const lifecycleRedirect = product.lifecycleRedirects.get(id);
|
|
88
126
|
if (lifecycleRedirect) return envelope(product, "neighbors", id, history, { lifecycleRedirect });
|
|
89
|
-
throw
|
|
127
|
+
throw unknownModelNodeError(product, id);
|
|
90
128
|
}
|
|
91
129
|
const incoming = product.edges
|
|
92
130
|
.filter((edge) => edge.to === id)
|
|
@@ -103,12 +141,21 @@ export function queryNeighbors(product, id, { history = false } = {}) {
|
|
|
103
141
|
});
|
|
104
142
|
}
|
|
105
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Answer `query impact`: breadth-first walk of everything that depends on the
|
|
146
|
+
* node, following owns/requires/preserves/establishes/uses/guarantees edges
|
|
147
|
+
* backwards, with each hit tagged by depth.
|
|
148
|
+
* @param {object} product - Product from loadQueryProduct.
|
|
149
|
+
* @param {string} id - Model node ID.
|
|
150
|
+
* @param {{history?: boolean}} [options] - Echoed into the query envelope.
|
|
151
|
+
* @returns {object} The query document.
|
|
152
|
+
*/
|
|
106
153
|
export function queryImpact(product, id, { history = false } = {}) {
|
|
107
154
|
const node = product.nodes.get(id);
|
|
108
155
|
if (!node) {
|
|
109
156
|
const lifecycleRedirect = product.lifecycleRedirects.get(id);
|
|
110
157
|
if (lifecycleRedirect) return envelope(product, "impact", id, history, { lifecycleRedirect });
|
|
111
|
-
throw
|
|
158
|
+
throw unknownModelNodeError(product, id);
|
|
112
159
|
}
|
|
113
160
|
|
|
114
161
|
const visited = new Set([id]);
|
|
@@ -136,12 +183,22 @@ export function queryImpact(product, id, { history = false } = {}) {
|
|
|
136
183
|
});
|
|
137
184
|
}
|
|
138
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Answer `query anchors`: the node's declared evidence anchors, reachable
|
|
188
|
+
* decisions, executed policies, generated-view freshness, verification
|
|
189
|
+
* commands, and which evidence roles (source/decision/verification) are still
|
|
190
|
+
* missing for kinds that expect them.
|
|
191
|
+
* @param {object} product - Product from loadQueryProduct.
|
|
192
|
+
* @param {string} id - Model node ID.
|
|
193
|
+
* @param {{history?: boolean}} [options] - Echoed into the query envelope.
|
|
194
|
+
* @returns {object} The query document.
|
|
195
|
+
*/
|
|
139
196
|
export function queryAnchors(product, id, { history = false } = {}) {
|
|
140
197
|
const node = product.nodes.get(id);
|
|
141
198
|
if (!node) {
|
|
142
199
|
const lifecycleRedirect = product.lifecycleRedirects.get(id);
|
|
143
200
|
if (lifecycleRedirect) return envelope(product, "anchors", id, history, { lifecycleRedirect });
|
|
144
|
-
throw
|
|
201
|
+
throw unknownModelNodeError(product, id);
|
|
145
202
|
}
|
|
146
203
|
|
|
147
204
|
const declaredAnchors = [...(node.evidence ?? [])].sort(byAnchor);
|
|
@@ -167,6 +224,13 @@ export function queryAnchors(product, id, { history = false } = {}) {
|
|
|
167
224
|
});
|
|
168
225
|
}
|
|
169
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Answer `query spec`: the root Model node, its owned Domains, generated-view
|
|
229
|
+
* freshness, and the verification commands.
|
|
230
|
+
* @param {object} product - Product from loadQueryProduct.
|
|
231
|
+
* @param {{history?: boolean}} [options] - Echoed into the query envelope.
|
|
232
|
+
* @returns {object} The query document.
|
|
233
|
+
*/
|
|
170
234
|
export function querySpec(product, { history = false } = {}) {
|
|
171
235
|
const root = product.nodes.get(product.rootModelId);
|
|
172
236
|
if (!root) throw new Error(`Unknown model node ${product.rootModelId}`);
|
|
@@ -252,12 +316,30 @@ function generatedViews(product) {
|
|
|
252
316
|
["generated/graph/model-graph.json", outputs.json],
|
|
253
317
|
["generated/graph/model-graph.ndjson", outputs.ndjson],
|
|
254
318
|
]);
|
|
255
|
-
return
|
|
319
|
+
return generatedPaths.map((relativePath) => ({
|
|
256
320
|
path: relativePath,
|
|
257
|
-
freshness:
|
|
321
|
+
freshness:
|
|
322
|
+
relativePath === svgViewPath
|
|
323
|
+
? svgViewFreshness(product.root)
|
|
324
|
+
: readGeneratedView(product.root, relativePath) === expectedByPath.get(relativePath)
|
|
325
|
+
? "fresh"
|
|
326
|
+
: "stale",
|
|
258
327
|
}));
|
|
259
328
|
}
|
|
260
329
|
|
|
330
|
+
// The canonical SVG is rendered by the async Graphviz WASM engine, so its
|
|
331
|
+
// freshness is checked in a child process to keep queries synchronous
|
|
332
|
+
// (mirrors the check and generate subprocesses).
|
|
333
|
+
function svgViewFreshness(root) {
|
|
334
|
+
const result = spawnSync(process.execPath, [svgCheckerScript, "--root", root], { encoding: "utf8" });
|
|
335
|
+
if (result.error) {
|
|
336
|
+
throw new Error(`Failed to run the model graph SVG check for ${root}: ${result.error.message}`);
|
|
337
|
+
}
|
|
338
|
+
if (result.status === 0) return "fresh";
|
|
339
|
+
if (/missing or stale/.test(result.stderr ?? "")) return "stale";
|
|
340
|
+
throw new Error(`Failed to run the model graph SVG check for ${root}: ${(result.stderr ?? "").trim()}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
261
343
|
function readGeneratedView(root, relativePath) {
|
|
262
344
|
try {
|
|
263
345
|
return readFileSync(path.join(root, relativePath), "utf8");
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves which product root a rootless command addresses, in priority
|
|
3
|
+
* order: explicit --root, the enclosing product root of the cwd, the
|
|
4
|
+
* productRoot pinned in .ddduck/config.json, then repository-wide discovery
|
|
5
|
+
* (a unique primary candidate, or a unique example candidate when run inside
|
|
6
|
+
* it; test/ and fixtures/ candidates are ignored, ambiguity is an error).
|
|
7
|
+
* Also owns resolveInitDestination, the init staging-name prefix, and the
|
|
8
|
+
* nonProductSourceEntries list that mutation staging excludes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
1
11
|
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
2
12
|
import path from "node:path";
|
|
3
13
|
import { parseDocument } from "yaml";
|
|
@@ -17,6 +27,12 @@ export const nonProductSourceEntries = Object.freeze([
|
|
|
17
27
|
".worktrees",
|
|
18
28
|
]);
|
|
19
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the product root for a command: explicit root, enclosing root,
|
|
32
|
+
* configured root, or unique discovery — otherwise throw with candidates.
|
|
33
|
+
* @param {{cwd?: string, explicitRoot?: string}} [options] - Working directory and the --root override.
|
|
34
|
+
* @returns {string} The real (symlink-resolved) product root path.
|
|
35
|
+
*/
|
|
20
36
|
export function resolveProductRoot({ cwd = process.cwd(), explicitRoot } = {}) {
|
|
21
37
|
if (explicitRoot) return validateSelectedRoot(path.resolve(cwd, explicitRoot), "Explicit product root");
|
|
22
38
|
|
|
@@ -41,9 +57,16 @@ export function resolveProductRoot({ cwd = process.cwd(), explicitRoot } = {}) {
|
|
|
41
57
|
if (relevantExamples.length === 1) return relevantExamples[0].root;
|
|
42
58
|
|
|
43
59
|
if (candidates.length > 1) throw ambiguousRoots(candidates);
|
|
60
|
+
if (candidates.length === 1) throw irrelevantCandidate(candidates[0]);
|
|
44
61
|
throw new Error("No ddduck product root found; pass --root <product-root>");
|
|
45
62
|
}
|
|
46
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Resolve where `ddduck init` publishes: the explicit destination, the
|
|
66
|
+
* configured productRoot, or `ddd` under the current directory.
|
|
67
|
+
* @param {{cwd?: string, explicitDestination?: string}} [options] - Working directory and the optional positional destination.
|
|
68
|
+
* @returns {string} Absolute destination path.
|
|
69
|
+
*/
|
|
47
70
|
export function resolveInitDestination({ cwd = process.cwd(), explicitDestination } = {}) {
|
|
48
71
|
if (explicitDestination) return path.resolve(cwd, explicitDestination);
|
|
49
72
|
const repositoryRoot = findRepositoryRoot(cwd);
|
|
@@ -134,6 +157,13 @@ function passesProductValidation(root) {
|
|
|
134
157
|
}
|
|
135
158
|
}
|
|
136
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Test whether a directory has the shape of a product root: a product.yaml
|
|
162
|
+
* that is a valid schemaVersion-1 Model with a model:<slug> ID, plus a model/
|
|
163
|
+
* directory. Shape only; full validation happens elsewhere.
|
|
164
|
+
* @param {string} root - Candidate directory.
|
|
165
|
+
* @returns {boolean} True when the directory looks like a product root.
|
|
166
|
+
*/
|
|
137
167
|
function hasProductRootShape(root) {
|
|
138
168
|
try {
|
|
139
169
|
const productPath = path.join(root, "product.yaml");
|
|
@@ -152,6 +182,16 @@ function hasProductRootShape(root) {
|
|
|
152
182
|
}
|
|
153
183
|
}
|
|
154
184
|
|
|
185
|
+
// A repository whose only candidate is an example resolves implicitly only
|
|
186
|
+
// from inside it. From anywhere else, name the candidate that was found and
|
|
187
|
+
// rejected as irrelevant (mirroring the ambiguity message) instead of claiming
|
|
188
|
+
// no product exists.
|
|
189
|
+
function irrelevantCandidate(candidate) {
|
|
190
|
+
return new Error(
|
|
191
|
+
`No ddduck product root selected; found ${candidate.classification} candidate: ${candidate.root}${candidate.valid ? "" : " (fails validation)"}. Pass --root ${candidate.root} or run inside it.`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
155
195
|
function ambiguousRoots(candidates) {
|
|
156
196
|
const roots = candidates
|
|
157
197
|
.map((candidate) => `${candidate.classification}: ${candidate.root}${candidate.valid ? "" : " (fails validation)"}`)
|
|
@@ -1,8 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single skip predicate shared by every ddduck repository scanner (product
|
|
3
|
+
* root discovery and the documentation reference walk), so both agree on which
|
|
4
|
+
* directory entries are invisible. Name-based and deterministic; it never
|
|
5
|
+
* reads .gitignore.
|
|
6
|
+
*/
|
|
7
|
+
|
|
1
8
|
export const defaultIgnoredEntryNames = Object.freeze(["node_modules"]);
|
|
2
9
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Decide whether a scanner should skip a directory/file entry: dot-entries,
|
|
12
|
+
* known dependency directories, and product-declared extra ignores.
|
|
13
|
+
* @param {string} name - The entry's basename.
|
|
14
|
+
* @param {string[]} [extraNames] - Extra names from .ddduck/config.json ignore.
|
|
15
|
+
* @returns {boolean} True when the entry must be skipped.
|
|
16
|
+
*/
|
|
6
17
|
export function shouldIgnoreScanEntry(name, extraNames = []) {
|
|
7
18
|
return name.startsWith(".") || defaultIgnoredEntryNames.includes(name) || extraNames.includes(name);
|
|
8
19
|
}
|