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,284 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { validateProduct } from "../check-model.mjs";
|
|
4
|
+
import { buildModelOverview } from "../generate-docs.mjs";
|
|
5
|
+
import { buildModelGraph, buildModelGraphOutputs } from "../generate-graph.mjs";
|
|
6
|
+
import { sourceDigestFromSources } from "./context-pack.mjs";
|
|
7
|
+
import { detectProductLayout, loadProductNodes } from "./product-layout.mjs";
|
|
8
|
+
import { assertProductNotBusy } from "./product-operation.mjs";
|
|
9
|
+
|
|
10
|
+
const impactEdgeKinds = new Set(["owns", "requires", "preserves", "establishes", "uses", "guarantees"]);
|
|
11
|
+
// Only these kinds accept an `evidence` field in their schemas, so source and
|
|
12
|
+
// verification anchors may only be demanded of them.
|
|
13
|
+
const evidenceAnchorKinds = new Set(["DomainInterface", "UseCase", "Guarantee"]);
|
|
14
|
+
const generatedViewPaths = [
|
|
15
|
+
"generated/docs/model-overview.md",
|
|
16
|
+
"generated/graph/model-graph.json",
|
|
17
|
+
"generated/graph/model-graph.ndjson",
|
|
18
|
+
];
|
|
19
|
+
function verificationCommandsFor(product) {
|
|
20
|
+
const root = shellQuote(product.root);
|
|
21
|
+
return [`ddduck check --root ${root}`, `ddduck generate --root ${root}`];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Emitted commands are copied into shells by humans and agents; roots with
|
|
25
|
+
// spaces or metacharacters must survive that round trip.
|
|
26
|
+
function shellQuote(value) {
|
|
27
|
+
if (/^[A-Za-z0-9_\-./]+$/.test(value)) return value;
|
|
28
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function loadQueryProduct(rootPath, { history = false } = {}) {
|
|
32
|
+
const layout = detectProductLayout(rootPath);
|
|
33
|
+
assertProductNotBusy(layout.root);
|
|
34
|
+
const check = validateProduct(layout.root, { includeDocumentation: false });
|
|
35
|
+
if (check.errors.length > 0) throw new Error(check.errors.join("\n"));
|
|
36
|
+
const loaded = loadProductNodes(layout);
|
|
37
|
+
const graph = buildModelGraph(layout.root, { history });
|
|
38
|
+
const sourcePaths = new Map(
|
|
39
|
+
[...loaded.nodeFiles].map(([id, filePath]) => [id, productRelativePath(layout.root, filePath)]),
|
|
40
|
+
);
|
|
41
|
+
const sourceFiles = new Map([...loaded.nodeSources].map(([id, source]) => [sourcePaths.get(id), source]));
|
|
42
|
+
const nodes = new Map(
|
|
43
|
+
[...loaded.nodes].filter(([, node]) => history || node.kind !== "Guarantee" || node.status === "active"),
|
|
44
|
+
);
|
|
45
|
+
const lifecycleRedirects = new Map(
|
|
46
|
+
[...loaded.nodes]
|
|
47
|
+
.filter(([, node]) => node.kind === "Guarantee" && node.status !== "active")
|
|
48
|
+
.map(([id, node]) => [
|
|
49
|
+
id,
|
|
50
|
+
{
|
|
51
|
+
id,
|
|
52
|
+
status: node.status,
|
|
53
|
+
lifecycleDecision: node.lifecycleDecision,
|
|
54
|
+
successors: [...(node.successors ?? [])].sort(),
|
|
55
|
+
},
|
|
56
|
+
]),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
root: layout.root,
|
|
61
|
+
rootModelId: graph.modelId,
|
|
62
|
+
nodes,
|
|
63
|
+
sourcePaths,
|
|
64
|
+
sourceDigest: sourceDigestFromSources(sourceFiles),
|
|
65
|
+
edges: graph.edges,
|
|
66
|
+
lifecycleRedirects,
|
|
67
|
+
decisions: loadDecisionPaths(layout),
|
|
68
|
+
policies: [...(check.policyChecks ?? [])].sort(),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function queryNode(product, id, { history = false } = {}) {
|
|
73
|
+
const node = product.nodes.get(id);
|
|
74
|
+
if (node) {
|
|
75
|
+
return envelope(product, "node", id, history, { node: renderNode(node, product.sourcePaths) });
|
|
76
|
+
}
|
|
77
|
+
const lifecycleRedirect = product.lifecycleRedirects.get(id);
|
|
78
|
+
if (lifecycleRedirect) {
|
|
79
|
+
return envelope(product, "node", id, history, { lifecycleRedirect });
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`Unknown model node ${id}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function queryNeighbors(product, id, { history = false } = {}) {
|
|
85
|
+
const node = product.nodes.get(id);
|
|
86
|
+
if (!node) {
|
|
87
|
+
const lifecycleRedirect = product.lifecycleRedirects.get(id);
|
|
88
|
+
if (lifecycleRedirect) return envelope(product, "neighbors", id, history, { lifecycleRedirect });
|
|
89
|
+
throw new Error(`Unknown model node ${id}`);
|
|
90
|
+
}
|
|
91
|
+
const incoming = product.edges
|
|
92
|
+
.filter((edge) => edge.to === id)
|
|
93
|
+
.sort(byId)
|
|
94
|
+
.map((edge) => ({ edge, node: summarizeNode(product.nodes.get(edge.from), product.sourcePaths) }));
|
|
95
|
+
const outgoing = product.edges
|
|
96
|
+
.filter((edge) => edge.from === id)
|
|
97
|
+
.sort(byId)
|
|
98
|
+
.map((edge) => ({ edge, node: summarizeNode(product.nodes.get(edge.to), product.sourcePaths) }));
|
|
99
|
+
return envelope(product, "neighbors", id, history, {
|
|
100
|
+
node: summarizeNode(node, product.sourcePaths),
|
|
101
|
+
incoming,
|
|
102
|
+
outgoing,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function queryImpact(product, id, { history = false } = {}) {
|
|
107
|
+
const node = product.nodes.get(id);
|
|
108
|
+
if (!node) {
|
|
109
|
+
const lifecycleRedirect = product.lifecycleRedirects.get(id);
|
|
110
|
+
if (lifecycleRedirect) return envelope(product, "impact", id, history, { lifecycleRedirect });
|
|
111
|
+
throw new Error(`Unknown model node ${id}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const visited = new Set([id]);
|
|
115
|
+
const impacts = [];
|
|
116
|
+
let frontier = [id];
|
|
117
|
+
let depth = 0;
|
|
118
|
+
while (frontier.length > 0) {
|
|
119
|
+
depth += 1;
|
|
120
|
+
const candidates = frontier
|
|
121
|
+
.flatMap((targetId) => product.edges.filter((edge) => edge.to === targetId && impactEdgeKinds.has(edge.kind)))
|
|
122
|
+
.sort(byId);
|
|
123
|
+
const next = [];
|
|
124
|
+
for (const edge of candidates) {
|
|
125
|
+
if (visited.has(edge.from)) continue;
|
|
126
|
+
visited.add(edge.from);
|
|
127
|
+
impacts.push({ depth, edge, node: summarizeNode(product.nodes.get(edge.from), product.sourcePaths) });
|
|
128
|
+
next.push(edge.from);
|
|
129
|
+
}
|
|
130
|
+
frontier = next.sort();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return envelope(product, "impact", id, history, {
|
|
134
|
+
source: summarizeNode(node, product.sourcePaths),
|
|
135
|
+
impacts,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function queryAnchors(product, id, { history = false } = {}) {
|
|
140
|
+
const node = product.nodes.get(id);
|
|
141
|
+
if (!node) {
|
|
142
|
+
const lifecycleRedirect = product.lifecycleRedirects.get(id);
|
|
143
|
+
if (lifecycleRedirect) return envelope(product, "anchors", id, history, { lifecycleRedirect });
|
|
144
|
+
throw new Error(`Unknown model node ${id}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const declaredAnchors = [...(node.evidence ?? [])].sort(byAnchor);
|
|
148
|
+
const decisions = reachableDecisionIds(product, id)
|
|
149
|
+
.sort()
|
|
150
|
+
.map((decisionId) => ({ id: decisionId, path: product.decisions.get(decisionId) }));
|
|
151
|
+
const declaredRoles = new Set(declaredAnchors.map((anchor) => anchor.role));
|
|
152
|
+
const expectsAnchors = evidenceAnchorKinds.has(node.kind);
|
|
153
|
+
const missingEvidence = [
|
|
154
|
+
...(!expectsAnchors || declaredRoles.has("source") ? [] : ["source"]),
|
|
155
|
+
...(declaredRoles.has("decision") || decisions.length > 0 ? [] : ["decision"]),
|
|
156
|
+
...(!expectsAnchors || declaredRoles.has("verification") ? [] : ["verification"]),
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
return envelope(product, "anchors", id, history, {
|
|
160
|
+
node: summarizeNode(node, product.sourcePaths),
|
|
161
|
+
declaredAnchors,
|
|
162
|
+
decisions,
|
|
163
|
+
policies: product.policies,
|
|
164
|
+
generatedViews: generatedViews(product),
|
|
165
|
+
verificationCommands: verificationCommandsFor(product),
|
|
166
|
+
missingEvidence,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function querySpec(product, { history = false } = {}) {
|
|
171
|
+
const root = product.nodes.get(product.rootModelId);
|
|
172
|
+
if (!root) throw new Error(`Unknown model node ${product.rootModelId}`);
|
|
173
|
+
const ownedDomains = product.edges
|
|
174
|
+
.filter((edge) => edge.kind === "owns" && edge.from === root.id && product.nodes.get(edge.to)?.kind === "Domain")
|
|
175
|
+
.sort(byId)
|
|
176
|
+
.map((edge) => summarizeNode(product.nodes.get(edge.to), product.sourcePaths));
|
|
177
|
+
|
|
178
|
+
return envelope(product, "spec", root.id, history, {
|
|
179
|
+
root: renderNode(root, product.sourcePaths),
|
|
180
|
+
ownedDomains,
|
|
181
|
+
generatedViews: generatedViews(product),
|
|
182
|
+
verificationCommands: verificationCommandsFor(product),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function envelope(product, operation, id, history, result) {
|
|
187
|
+
return {
|
|
188
|
+
schemaVersion: "1",
|
|
189
|
+
query: { operation, id, history },
|
|
190
|
+
rootModelId: product.rootModelId,
|
|
191
|
+
result,
|
|
192
|
+
diagnostics: [],
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function renderNode(node, sourcePaths) {
|
|
197
|
+
return { ...node, sourcePath: sourcePaths.get(node.id) };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function summarizeNode(node, sourcePaths) {
|
|
201
|
+
if (!node) throw new Error("derived graph edge endpoint is missing from the query product");
|
|
202
|
+
return {
|
|
203
|
+
id: node.id,
|
|
204
|
+
kind: node.kind,
|
|
205
|
+
name: node.name ?? null,
|
|
206
|
+
sourcePath: sourcePaths.get(node.id),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function loadDecisionPaths(layout) {
|
|
211
|
+
const decisions = new Map();
|
|
212
|
+
try {
|
|
213
|
+
for (const file of readdirSync(layout.decisionsDirectory).sort()) {
|
|
214
|
+
const match = file.match(/^(ADR-[0-9]{3})-.*\.md$/);
|
|
215
|
+
if (match) decisions.set(match[1], productRelativePath(layout.root, path.join(layout.decisionsDirectory, file)));
|
|
216
|
+
}
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (error.code !== "ENOENT") throw error;
|
|
219
|
+
}
|
|
220
|
+
return decisions;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function reachableDecisionIds(product, id) {
|
|
224
|
+
const decisions = new Set();
|
|
225
|
+
const visited = new Set([id]);
|
|
226
|
+
let frontier = [id];
|
|
227
|
+
while (frontier.length > 0) {
|
|
228
|
+
const next = [];
|
|
229
|
+
for (const currentId of frontier) {
|
|
230
|
+
const current = product.nodes.get(currentId);
|
|
231
|
+
for (const decisionId of [
|
|
232
|
+
...(current.decisions ?? []),
|
|
233
|
+
...(current.lifecycleDecision ? [current.lifecycleDecision] : []),
|
|
234
|
+
]) {
|
|
235
|
+
decisions.add(decisionId);
|
|
236
|
+
}
|
|
237
|
+
for (const edge of product.edges.filter((edge) => edge.kind === "owns" && edge.to === currentId).sort(byId)) {
|
|
238
|
+
if (visited.has(edge.from)) continue;
|
|
239
|
+
visited.add(edge.from);
|
|
240
|
+
next.push(edge.from);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
frontier = next.sort();
|
|
244
|
+
}
|
|
245
|
+
return [...decisions];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function generatedViews(product) {
|
|
249
|
+
const outputs = buildModelGraphOutputs(product.root);
|
|
250
|
+
const expectedByPath = new Map([
|
|
251
|
+
["generated/docs/model-overview.md", buildModelOverview(product.root)],
|
|
252
|
+
["generated/graph/model-graph.json", outputs.json],
|
|
253
|
+
["generated/graph/model-graph.ndjson", outputs.ndjson],
|
|
254
|
+
]);
|
|
255
|
+
return generatedViewPaths.map((relativePath) => ({
|
|
256
|
+
path: relativePath,
|
|
257
|
+
freshness: readGeneratedView(product.root, relativePath) === expectedByPath.get(relativePath) ? "fresh" : "stale",
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function readGeneratedView(root, relativePath) {
|
|
262
|
+
try {
|
|
263
|
+
return readFileSync(path.join(root, relativePath), "utf8");
|
|
264
|
+
} catch (error) {
|
|
265
|
+
if (error.code === "ENOENT") return undefined;
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function productRelativePath(root, filePath) {
|
|
271
|
+
return path.relative(root, filePath).split(path.sep).join("/");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function byId(left, right) {
|
|
275
|
+
return left.id.localeCompare(right.id);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function byAnchor(left, right) {
|
|
279
|
+
return (
|
|
280
|
+
left.role.localeCompare(right.role) ||
|
|
281
|
+
left.path.localeCompare(right.path) ||
|
|
282
|
+
left.anchor.localeCompare(right.anchor)
|
|
283
|
+
);
|
|
284
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseDocument } from "yaml";
|
|
4
|
+
import { validateProduct } from "../check-model.mjs";
|
|
5
|
+
import { findRepositoryRoot, loadDdduckConfig } from "./ddduck-config.mjs";
|
|
6
|
+
import { shouldIgnoreScanEntry } from "./scan-ignore.mjs";
|
|
7
|
+
|
|
8
|
+
export { findRepositoryRoot, loadDdduckConfig } from "./ddduck-config.mjs";
|
|
9
|
+
|
|
10
|
+
export const initStagingPrefix = ".ddduck-init-stage-";
|
|
11
|
+
export const nonProductSourceEntries = Object.freeze([
|
|
12
|
+
".git",
|
|
13
|
+
".ddduck",
|
|
14
|
+
"node_modules",
|
|
15
|
+
"generated",
|
|
16
|
+
".superpowers",
|
|
17
|
+
".worktrees",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export function resolveProductRoot({ cwd = process.cwd(), explicitRoot } = {}) {
|
|
21
|
+
if (explicitRoot) return validateSelectedRoot(path.resolve(cwd, explicitRoot), "Explicit product root");
|
|
22
|
+
|
|
23
|
+
const repositoryRoot = findRepositoryRoot(cwd);
|
|
24
|
+
const enclosingRoot = findEnclosingProductRoot(cwd, repositoryRoot);
|
|
25
|
+
if (enclosingRoot) return enclosingRoot;
|
|
26
|
+
|
|
27
|
+
const config = loadDdduckConfig(repositoryRoot);
|
|
28
|
+
if (config) {
|
|
29
|
+
const configuredRoot = path.resolve(repositoryRoot, config.productRoot);
|
|
30
|
+
return validateSelectedRoot(configuredRoot, "Configured product root", config.productRoot, repositoryRoot);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const candidates = discoverProductRoots(repositoryRoot);
|
|
34
|
+
const primary = candidates.filter((candidate) => candidate.classification === "primary");
|
|
35
|
+
if (primary.length === 1) return primary[0].root;
|
|
36
|
+
if (primary.length > 1) throw ambiguousRoots(primary);
|
|
37
|
+
|
|
38
|
+
const relevantExamples = candidates.filter(
|
|
39
|
+
(candidate) => candidate.classification === "example" && isSameOrDescendant(cwd, candidate.root),
|
|
40
|
+
);
|
|
41
|
+
if (relevantExamples.length === 1) return relevantExamples[0].root;
|
|
42
|
+
|
|
43
|
+
if (candidates.length > 1) throw ambiguousRoots(candidates);
|
|
44
|
+
throw new Error("No ddduck product root found; pass --root <product-root>");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resolveInitDestination({ cwd = process.cwd(), explicitDestination } = {}) {
|
|
48
|
+
if (explicitDestination) return path.resolve(cwd, explicitDestination);
|
|
49
|
+
const repositoryRoot = findRepositoryRoot(cwd);
|
|
50
|
+
const config = loadDdduckConfig(repositoryRoot);
|
|
51
|
+
if (config) return path.resolve(repositoryRoot, config.productRoot);
|
|
52
|
+
return path.resolve(cwd, "ddd");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function findEnclosingProductRoot(cwd, repositoryRoot) {
|
|
56
|
+
let current = path.resolve(cwd);
|
|
57
|
+
while (isSameOrDescendant(current, repositoryRoot)) {
|
|
58
|
+
if (!path.basename(current).startsWith(initStagingPrefix) && hasProductRootShape(current)) {
|
|
59
|
+
return realpathSync(current);
|
|
60
|
+
}
|
|
61
|
+
if (current === repositoryRoot) break;
|
|
62
|
+
current = path.dirname(current);
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function discoverProductRoots(repositoryRoot) {
|
|
68
|
+
const roots = [];
|
|
69
|
+
visitDirectory(
|
|
70
|
+
repositoryRoot,
|
|
71
|
+
(directory) => {
|
|
72
|
+
if (hasProductRootShape(directory)) {
|
|
73
|
+
const classification = classifyCandidate(repositoryRoot, directory);
|
|
74
|
+
if (classification !== "ignored") {
|
|
75
|
+
roots.push({ root: realpathSync(directory), classification, valid: passesProductValidation(directory) });
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
},
|
|
81
|
+
true,
|
|
82
|
+
);
|
|
83
|
+
return roots.sort((left, right) => left.root.localeCompare(right.root));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The scan-ignore predicate skips dot-entries, node_modules, generated output,
|
|
87
|
+
// and staging debris. It applies only to child entries as the scan descends, not
|
|
88
|
+
// to the explicitly chosen traversal root: a repository checked out under a
|
|
89
|
+
// dot-prefixed directory (for example `/tmp/.scratch`) must still be scanned.
|
|
90
|
+
function visitDirectory(directory, visitor, isRoot = false) {
|
|
91
|
+
const metadata = lstatSync(directory);
|
|
92
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) return;
|
|
93
|
+
const baseName = path.basename(directory);
|
|
94
|
+
if (
|
|
95
|
+
!isRoot &&
|
|
96
|
+
(shouldIgnoreScanEntry(baseName) || baseName === "generated" || baseName.startsWith(initStagingPrefix))
|
|
97
|
+
)
|
|
98
|
+
return;
|
|
99
|
+
if (visitor(directory) === false) return;
|
|
100
|
+
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) =>
|
|
101
|
+
left.name.localeCompare(right.name),
|
|
102
|
+
)) {
|
|
103
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) visitDirectory(path.join(directory, entry.name), visitor);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function classifyCandidate(repositoryRoot, root) {
|
|
108
|
+
const relativePath = path.relative(repositoryRoot, root).split(path.sep).join("/");
|
|
109
|
+
if (relativePath === "") return "primary";
|
|
110
|
+
if (relativePath.startsWith("test/") || relativePath.includes("/fixtures/")) return "ignored";
|
|
111
|
+
if (relativePath.startsWith("examples/")) return "example";
|
|
112
|
+
return "primary";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function validateSelectedRoot(root, label, displayPath = root, containingRoot) {
|
|
116
|
+
if (hasProductRootShape(root)) {
|
|
117
|
+
const realRoot = realpathSync(root);
|
|
118
|
+
if (containingRoot) {
|
|
119
|
+
const realContainingRoot = realpathSync(containingRoot);
|
|
120
|
+
if (!isSameOrDescendant(realRoot, realContainingRoot)) {
|
|
121
|
+
throw new Error(`${label} is invalid: ${displayPath}; productRoot must stay inside the repository`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return realRoot;
|
|
125
|
+
}
|
|
126
|
+
throw new Error(`${label} is invalid: ${displayPath}; pass --root <product-root>`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function passesProductValidation(root) {
|
|
130
|
+
try {
|
|
131
|
+
return validateProduct(root, { includeDocumentation: false }).errors.length === 0;
|
|
132
|
+
} catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function hasProductRootShape(root) {
|
|
138
|
+
try {
|
|
139
|
+
const productPath = path.join(root, "product.yaml");
|
|
140
|
+
if (!existsSync(productPath) || !lstatSync(productPath).isFile()) return false;
|
|
141
|
+
if (!existsSync(path.join(root, "model")) || !lstatSync(path.join(root, "model")).isDirectory()) return false;
|
|
142
|
+
const document = parseDocument(readFileSync(productPath, "utf8"), {
|
|
143
|
+
strict: true,
|
|
144
|
+
uniqueKeys: true,
|
|
145
|
+
});
|
|
146
|
+
if (document.errors.length > 0) return false;
|
|
147
|
+
const product = document.toJSON();
|
|
148
|
+
if (!product || typeof product !== "object" || Array.isArray(product)) return false;
|
|
149
|
+
return product.schemaVersion === "1" && product.kind === "Model" && /^model:[a-z0-9][a-z0-9-]*$/.test(product.id);
|
|
150
|
+
} catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function ambiguousRoots(candidates) {
|
|
156
|
+
const roots = candidates
|
|
157
|
+
.map((candidate) => `${candidate.classification}: ${candidate.root}${candidate.valid ? "" : " (fails validation)"}`)
|
|
158
|
+
.join("; ");
|
|
159
|
+
return new Error(`Multiple ddduck product roots found; pass --root <product-root>. Candidates: ${roots}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isSameOrDescendant(candidatePath, rootPath) {
|
|
163
|
+
const candidate = path.resolve(candidatePath);
|
|
164
|
+
const root = path.resolve(rootPath);
|
|
165
|
+
const relative = path.relative(root, candidate);
|
|
166
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
167
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const defaultIgnoredEntryNames = Object.freeze(["node_modules"]);
|
|
2
|
+
|
|
3
|
+
// A directory/file entry is skipped by every ddduck repo scanner when its
|
|
4
|
+
// basename is a dot-entry, a known dependency directory, or a product-declared
|
|
5
|
+
// extra ignore. Name-based and deterministic; it never reads .gitignore.
|
|
6
|
+
export function shouldIgnoreScanEntry(name, extraNames = []) {
|
|
7
|
+
return name.startsWith(".") || defaultIgnoredEntryNames.includes(name) || extraNames.includes(name);
|
|
8
|
+
}
|