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,488 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import Ajv2020 from "ajv/dist/2020.js";
|
|
7
|
+
import { unified } from "unified";
|
|
8
|
+
import remarkParse from "remark-parse";
|
|
9
|
+
import { parseDocument } from "yaml";
|
|
10
|
+
import { detectProductLayout, loadProductNodes } from "./lib/product-layout.mjs";
|
|
11
|
+
import { shouldIgnoreScanEntry } from "./lib/scan-ignore.mjs";
|
|
12
|
+
import { findRepositoryRoot, resolveConfiguredIgnores } from "./lib/ddduck-config.mjs";
|
|
13
|
+
|
|
14
|
+
const executableRuleChecks = new Set([
|
|
15
|
+
"concept-owner-domain",
|
|
16
|
+
"documentation-model-reference-resolution",
|
|
17
|
+
"no-dangling-model-reference",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const markdownReferencePattern =
|
|
21
|
+
/\b(?:(?:model|domain|concept|rel|rule|use-case|interface):[a-z0-9][a-z0-9-]*|[A-Z][A-Z0-9-]+-(?:INV|AC)-[0-9]+|ADR-[0-9]{3})\b/g;
|
|
22
|
+
|
|
23
|
+
const defaultFrameworkRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
24
|
+
|
|
25
|
+
function parseArgs(args) {
|
|
26
|
+
const parsed = {};
|
|
27
|
+
|
|
28
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
29
|
+
const arg = args[index];
|
|
30
|
+
if (arg === "--root") {
|
|
31
|
+
parsed.root = args[index + 1];
|
|
32
|
+
index += 1;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (arg === "--base") {
|
|
36
|
+
parsed.base = args[index + 1];
|
|
37
|
+
index += 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (arg === "--docs-root") {
|
|
41
|
+
if (!parsed.docsRoots) parsed.docsRoots = [];
|
|
42
|
+
parsed.docsRoots.push(args[index + 1]);
|
|
43
|
+
index += 1;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (arg === "--source-only") {
|
|
47
|
+
parsed.sourceOnly = true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (arg === "--verbose") {
|
|
51
|
+
parsed.verbose = true;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return parsed;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class ProductCheck {
|
|
62
|
+
constructor(rootPath, frameworkPath, basePath, documentationRoots, { sourceOnly = false } = {}) {
|
|
63
|
+
this.root = rootPath;
|
|
64
|
+
this.frameworkRoot = frameworkPath;
|
|
65
|
+
this.baseRoot = basePath;
|
|
66
|
+
this.documentationRoots = documentationRoots;
|
|
67
|
+
this.sourceOnly = sourceOnly;
|
|
68
|
+
this.layout = detectProductLayout(rootPath);
|
|
69
|
+
this.nodes = new Map();
|
|
70
|
+
this.nodeFiles = new Map();
|
|
71
|
+
this.decisions = new Set();
|
|
72
|
+
this.errors = [];
|
|
73
|
+
this.referenceCount = 0;
|
|
74
|
+
this.policyCount = 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
run({ includeDocumentation = true } = {}) {
|
|
78
|
+
this.loadDecisions();
|
|
79
|
+
this.loadNodes();
|
|
80
|
+
this.validateSchemas();
|
|
81
|
+
this.validateEvidenceAnchors();
|
|
82
|
+
this.validateReferences();
|
|
83
|
+
this.validateGuaranteeLifecycle();
|
|
84
|
+
this.validateGuaranteeHistory();
|
|
85
|
+
this.loadPolicies();
|
|
86
|
+
executePolicyChecks(this, this.policyChecks, { includeDocumentation });
|
|
87
|
+
return this;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
get nodeCount() {
|
|
91
|
+
return this.nodes.size;
|
|
92
|
+
}
|
|
93
|
+
get decisionCount() {
|
|
94
|
+
return this.decisions.size;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
loadDecisions() {
|
|
98
|
+
for (const file of listFiles(this.layout.decisionsDirectory, /^ADR-[0-9]{3}-.*\.md$/)) {
|
|
99
|
+
this.decisions.add(file.match(/^ADR-[0-9]{3}/)[0]);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
loadNodes() {
|
|
104
|
+
try {
|
|
105
|
+
const loaded = loadProductNodes(this.layout);
|
|
106
|
+
this.nodes = loaded.nodes;
|
|
107
|
+
this.nodeFiles = loaded.nodeFiles;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
this.errors.push(error.message);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
validateSchemas() {
|
|
114
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
115
|
+
const schemas = new Map();
|
|
116
|
+
const schemaNames = new Map([
|
|
117
|
+
["Model", "model.schema.json"],
|
|
118
|
+
["Domain", "domain.schema.json"],
|
|
119
|
+
["Concept", "concept.schema.json"],
|
|
120
|
+
["Relationship", "relationship.schema.json"],
|
|
121
|
+
["UseCase", "use-case.schema.json"],
|
|
122
|
+
["DomainInterface", "domain-interface.schema.json"],
|
|
123
|
+
["Guarantee", "guarantee.schema.json"],
|
|
124
|
+
]);
|
|
125
|
+
try {
|
|
126
|
+
for (const file of listFiles(path.join(this.frameworkRoot, "schemas", "product"), /\.schema\.json$/)) {
|
|
127
|
+
const schema = JSON.parse(readFileSync(path.join(this.frameworkRoot, "schemas", "product", file), "utf8"));
|
|
128
|
+
ajv.addSchema(schema);
|
|
129
|
+
}
|
|
130
|
+
for (const [kind, file] of schemaNames) {
|
|
131
|
+
schemas.set(kind, ajv.getSchema(`https://ddduck.local/schemas/product/${file}`));
|
|
132
|
+
}
|
|
133
|
+
} catch (error) {
|
|
134
|
+
this.errors.push(`schemas/product: invalid schema: ${error.message}`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
for (const node of this.nodes.values()) {
|
|
138
|
+
const validator = schemas.get(node.kind);
|
|
139
|
+
if (!validator) {
|
|
140
|
+
this.addError(node.id, `unknown product kind ${JSON.stringify(node.kind)}`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!validator(node)) {
|
|
144
|
+
for (const error of validator.errors ?? [])
|
|
145
|
+
this.addError(node.id, `schema validation failed at ${error.instancePath || "/"}: ${error.message}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
validateReferences() {
|
|
151
|
+
const models = [...this.nodes.values()].filter((node) => node.kind === "Model");
|
|
152
|
+
if (models.length !== 1) {
|
|
153
|
+
this.errors.push(`product.yaml: expected exactly one Model node, found ${models.length}`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const [model] = models;
|
|
157
|
+
for (const domainId of asArray(model.domains)) this.requireNode(model, domainId, "domains");
|
|
158
|
+
for (const useCaseId of asArray(model.useCases)) this.requireNode(model, useCaseId, "useCases");
|
|
159
|
+
for (const relationshipId of asArray(model.relationships)) this.requireNode(model, relationshipId, "relationships");
|
|
160
|
+
for (const decision of asArray(model.decisions)) this.requireDecision(model, decision, "decisions");
|
|
161
|
+
const listedRelationships = new Set(asArray(model.relationships));
|
|
162
|
+
const listedUseCases = new Set(asArray(model.useCases));
|
|
163
|
+
for (const node of this.nodes.values()) {
|
|
164
|
+
if (node.kind !== "Model" && node.model !== model.id) this.addError(node.id, `model must be ${model.id}`);
|
|
165
|
+
if (node.kind === "Relationship" && !listedRelationships.has(node.id))
|
|
166
|
+
this.addError(node.id, `model does not list node: ${node.id} in relationships`);
|
|
167
|
+
if (node.kind === "UseCase" && !listedUseCases.has(node.id))
|
|
168
|
+
this.addError(node.id, `model does not list node: ${node.id} in useCases`);
|
|
169
|
+
if (["Concept", "DomainInterface", "Guarantee"].includes(node.kind))
|
|
170
|
+
this.requireNode(node, node.ownerDomain, "ownerDomain");
|
|
171
|
+
if (node.kind === "Domain") {
|
|
172
|
+
for (const id of [...asArray(node.concepts), ...asArray(node.interfaces), ...asArray(node.guarantees)])
|
|
173
|
+
this.requireNode(node, id, "owned nodes");
|
|
174
|
+
}
|
|
175
|
+
if (node.kind === "UseCase") {
|
|
176
|
+
for (const id of asArray(node.interfaces)) this.requireNode(node, id, "interfaces");
|
|
177
|
+
}
|
|
178
|
+
for (const decision of asArray(node.decisions)) this.requireDecision(node, decision, "decisions");
|
|
179
|
+
if (node.kind === "Guarantee" && node.lifecycleDecision !== undefined)
|
|
180
|
+
this.requireDecision(node, node.lifecycleDecision, "lifecycleDecision");
|
|
181
|
+
if (node.kind === "Relationship") {
|
|
182
|
+
this.requireNode(node, node.from, "from");
|
|
183
|
+
this.requireNode(node, node.to, "to");
|
|
184
|
+
this.requireNode(node, node.ownedBy, "ownedBy");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
validateEvidenceAnchors() {
|
|
190
|
+
for (const node of this.nodes.values()) {
|
|
191
|
+
for (const evidence of asArray(node.evidence)) {
|
|
192
|
+
if (!isPlainObject(evidence) || typeof evidence.path !== "string") continue;
|
|
193
|
+
if (!isExistingRegularFileBelowRoot(this.root, evidence.path)) {
|
|
194
|
+
this.addError(
|
|
195
|
+
node.id,
|
|
196
|
+
`evidence anchor path must resolve to an existing regular file below product root: ${evidence.path}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
validateGuaranteeLifecycle() {
|
|
204
|
+
for (const node of this.nodes.values()) {
|
|
205
|
+
if (node.kind === "Guarantee" && node.status === "split") {
|
|
206
|
+
const successors = asArray(node.successors);
|
|
207
|
+
if (successors.length === 0) this.addError(node.id, "split guarantee requires active successors");
|
|
208
|
+
for (const id of successors) {
|
|
209
|
+
const successor = this.nodes.get(id);
|
|
210
|
+
if (!successor || successor.kind !== "Guarantee" || successor.status !== "active")
|
|
211
|
+
this.addError(node.id, `split successor must be an active guarantee ${id}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (node.kind === "DomainInterface" || node.kind === "UseCase") {
|
|
215
|
+
const refs = [
|
|
216
|
+
...(node.kind === "DomainInterface" ? asArray(node.guarantees) : []),
|
|
217
|
+
...(node.kind === "UseCase" ? asArray(node.preconditions?.requires) : []),
|
|
218
|
+
...(node.kind === "UseCase" ? asArray(node.success?.preserves) : []),
|
|
219
|
+
...(node.kind === "UseCase" ? asArray(node.success?.establishes) : []),
|
|
220
|
+
];
|
|
221
|
+
for (const id of refs) {
|
|
222
|
+
const guarantee = this.nodes.get(id);
|
|
223
|
+
if (!guarantee) this.addError(node.id, `missing guarantee ${id}`);
|
|
224
|
+
else if (guarantee.kind !== "Guarantee" || guarantee.status !== "active")
|
|
225
|
+
this.addError(node.id, `non-effective guarantee ${id}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
validateNoDanglingModelReferences() {
|
|
232
|
+
// Reference resolution runs while each node is validated; this check is the policy hook for that behavior.
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
validateDocumentationReferences() {
|
|
236
|
+
const generatedPrefix = path.join(this.root, "generated") + path.sep;
|
|
237
|
+
const ignoredEntryNames = resolveConfiguredIgnores(findRepositoryRoot(this.root));
|
|
238
|
+
const filePaths = new Set(
|
|
239
|
+
this.documentationRoots
|
|
240
|
+
.flatMap((root) => listMarkdownFiles(root, ignoredEntryNames))
|
|
241
|
+
.filter((filePath) => !this.sourceOnly || !filePath.startsWith(generatedPrefix)),
|
|
242
|
+
);
|
|
243
|
+
for (const filePath of [...filePaths].sort()) {
|
|
244
|
+
const source = readFileSync(filePath, "utf8");
|
|
245
|
+
const tree = unified().use(remarkParse).parse(source);
|
|
246
|
+
|
|
247
|
+
visitMarkdownValues(tree, (value, line) => {
|
|
248
|
+
for (const match of value.matchAll(markdownReferencePattern)) {
|
|
249
|
+
const ref = match[0];
|
|
250
|
+
this.referenceCount += 1;
|
|
251
|
+
if (this.nodes.has(ref) || this.decisions.has(ref)) continue;
|
|
252
|
+
this.addError(filePath, `missing documentation reference ${ref} on line ${line}`);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
validateGuaranteeHistory() {
|
|
259
|
+
if (!this.baseRoot) return;
|
|
260
|
+
const baseLayout = detectProductLayout(this.baseRoot);
|
|
261
|
+
if (!existsSync(baseLayout.productPath)) {
|
|
262
|
+
this.errors.push(`base: expected a product root at ${baseLayout.root}`);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
let baseNodes;
|
|
266
|
+
try {
|
|
267
|
+
baseNodes = loadProductNodes(baseLayout).nodes;
|
|
268
|
+
} catch (error) {
|
|
269
|
+
this.errors.push(`base: ${error.message}`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
for (const baseNode of baseNodes.values()) {
|
|
273
|
+
if (baseNode.kind === "Guarantee" && !this.nodes.has(baseNode.id)) {
|
|
274
|
+
this.errors.push(`guarantee disappeared from the product: ${baseNode.id}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
loadPolicies() {
|
|
280
|
+
this.policyChecks = new Set();
|
|
281
|
+
let validate;
|
|
282
|
+
try {
|
|
283
|
+
const schemaPath = path.join(this.frameworkRoot, "policies", "policy-spec.schema.json");
|
|
284
|
+
validate = new Ajv2020({ allErrors: true, strict: true }).compile(JSON.parse(readFileSync(schemaPath, "utf8")));
|
|
285
|
+
} catch (error) {
|
|
286
|
+
this.errors.push(`policies: invalid PolicySpec schema: ${error.message}`);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
for (const file of listFiles(path.join(this.frameworkRoot, "policies"), /\.yaml$/)) {
|
|
290
|
+
const document = parseDocument(readFileSync(path.join(this.frameworkRoot, "policies", file), "utf8"), {
|
|
291
|
+
strict: true,
|
|
292
|
+
uniqueKeys: true,
|
|
293
|
+
});
|
|
294
|
+
const policy = document.toJSON();
|
|
295
|
+
if (document.errors.length || !validate(policy)) {
|
|
296
|
+
this.errors.push(`policies/${file}: invalid PolicySpec`);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
this.policyCount += 1;
|
|
300
|
+
const check = policy.implementation.check;
|
|
301
|
+
if (!executableRuleChecks.has(check)) {
|
|
302
|
+
this.errors.push(`policies/${file}: unknown executable rule check ${check}`);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
this.policyChecks.add(check);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
validateOwnership() {
|
|
310
|
+
const ownedNodeKinds = new Map([
|
|
311
|
+
["Concept", "concepts"],
|
|
312
|
+
["DomainInterface", "interfaces"],
|
|
313
|
+
["Guarantee", "guarantees"],
|
|
314
|
+
]);
|
|
315
|
+
const owningDomainsByNode = new Map();
|
|
316
|
+
const domains = [...this.nodes.values()].filter((node) => node.kind === "Domain");
|
|
317
|
+
const ownedNodes = [...this.nodes.values()].filter((node) => ownedNodeKinds.has(node.kind));
|
|
318
|
+
|
|
319
|
+
for (const domain of domains) {
|
|
320
|
+
for (const [kind, field] of ownedNodeKinds) {
|
|
321
|
+
for (const nodeId of asArray(domain[field])) {
|
|
322
|
+
const node = this.nodes.get(nodeId);
|
|
323
|
+
if (node?.kind !== kind) continue;
|
|
324
|
+
if (!owningDomainsByNode.has(nodeId)) owningDomainsByNode.set(nodeId, []);
|
|
325
|
+
owningDomainsByNode.get(nodeId).push(domain.id);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
for (const [nodeId, domainIds] of owningDomainsByNode) {
|
|
331
|
+
if (domainIds.length > 1) {
|
|
332
|
+
this.addError(nodeId, `node owned by multiple domains: ${nodeId} listed by ${domainIds.join(", ")}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
for (const node of ownedNodes) {
|
|
337
|
+
const domainIds = owningDomainsByNode.get(node.id) ?? [];
|
|
338
|
+
if (!domainIds.includes(node.ownerDomain)) {
|
|
339
|
+
this.addError(node.id, `owner domain does not list node: ${node.id} ownerDomain ${node.ownerDomain}`);
|
|
340
|
+
}
|
|
341
|
+
for (const domainId of domainIds) {
|
|
342
|
+
if (domainId !== node.ownerDomain) {
|
|
343
|
+
this.addError(
|
|
344
|
+
node.id,
|
|
345
|
+
`node ownership mismatch: ${node.id} listed by ${domainId} but ownerDomain is ${node.ownerDomain}`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
requireNode(node, id, field) {
|
|
353
|
+
this.referenceCount += 1;
|
|
354
|
+
if (!this.nodes.has(id)) this.addError(node.id, `missing reference ${id} in ${field}`);
|
|
355
|
+
}
|
|
356
|
+
requireDecision(node, id, field) {
|
|
357
|
+
this.referenceCount += 1;
|
|
358
|
+
if (!this.decisions.has(id)) this.addError(node.id, `missing decision ${id} in ${field}`);
|
|
359
|
+
}
|
|
360
|
+
addError(id, message) {
|
|
361
|
+
this.errors.push(`${this.nodeFiles.get(id) ? path.relative(this.root, this.nodeFiles.get(id)) : id}: ${message}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function listFiles(directory, pattern) {
|
|
366
|
+
try {
|
|
367
|
+
return readdirSync(directory)
|
|
368
|
+
.filter((file) => pattern.test(file))
|
|
369
|
+
.sort();
|
|
370
|
+
} catch (error) {
|
|
371
|
+
if (error.code === "ENOENT") return [];
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function listMarkdownFiles(rootPath, ignoredEntryNames = []) {
|
|
377
|
+
const files = [];
|
|
378
|
+
|
|
379
|
+
function walk(directory) {
|
|
380
|
+
if (directory !== rootPath && existsSync(path.join(directory, "product.yaml"))) return;
|
|
381
|
+
let entries;
|
|
382
|
+
try {
|
|
383
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
384
|
+
} catch (error) {
|
|
385
|
+
if (error.code === "ENOENT") return;
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
for (const entry of entries) {
|
|
389
|
+
const entryPath = path.join(directory, entry.name);
|
|
390
|
+
if (entry.isDirectory()) {
|
|
391
|
+
const relativePath = path.relative(rootPath, entryPath).split(path.sep).join("/");
|
|
392
|
+
if (
|
|
393
|
+
!shouldIgnoreScanEntry(entry.name, ignoredEntryNames) &&
|
|
394
|
+
!["docs/audits", "docs/superpowers"].includes(relativePath) &&
|
|
395
|
+
!existsSync(path.join(entryPath, ".git"))
|
|
396
|
+
) {
|
|
397
|
+
walk(entryPath);
|
|
398
|
+
}
|
|
399
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
400
|
+
files.push(entryPath);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
walk(rootPath);
|
|
406
|
+
return files.sort();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function visitMarkdownValues(node, visitor) {
|
|
410
|
+
if (node.type === "code") return;
|
|
411
|
+
if (typeof node.value === "string") visitor(node.value, node.position?.start?.line ?? 1);
|
|
412
|
+
for (const child of node.children ?? []) visitMarkdownValues(child, visitor);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function asArray(value) {
|
|
416
|
+
if (Array.isArray(value)) return value;
|
|
417
|
+
if (value === undefined || value === null) return [];
|
|
418
|
+
return [value];
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function isPlainObject(value) {
|
|
422
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function isExistingRegularFileBelowRoot(rootPath, relativePath) {
|
|
426
|
+
if (path.isAbsolute(relativePath)) return false;
|
|
427
|
+
const candidatePath = path.resolve(rootPath, relativePath);
|
|
428
|
+
if (!isPathBelow(rootPath, candidatePath)) return false;
|
|
429
|
+
try {
|
|
430
|
+
const realRoot = realpathSync(rootPath);
|
|
431
|
+
const realCandidate = realpathSync(candidatePath);
|
|
432
|
+
return isPathBelow(realRoot, realCandidate) && statSync(realCandidate).isFile();
|
|
433
|
+
} catch (error) {
|
|
434
|
+
if (error.code === "ENOENT") return false;
|
|
435
|
+
throw error;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function isPathBelow(rootPath, candidatePath) {
|
|
440
|
+
const relative = path.relative(rootPath, candidatePath);
|
|
441
|
+
return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const policyExecutors = new Map([
|
|
445
|
+
["concept-owner-domain", (check) => check.validateOwnership()],
|
|
446
|
+
["documentation-model-reference-resolution", (check) => check.validateDocumentationReferences()],
|
|
447
|
+
["no-dangling-model-reference", (check) => check.validateNoDanglingModelReferences()],
|
|
448
|
+
]);
|
|
449
|
+
|
|
450
|
+
function executePolicyChecks(check, checksToRun, { includeDocumentation = true } = {}) {
|
|
451
|
+
for (const policyCheck of checksToRun) {
|
|
452
|
+
if (!includeDocumentation && policyCheck === "documentation-model-reference-resolution") continue;
|
|
453
|
+
policyExecutors.get(policyCheck)(check);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export function validateProduct(
|
|
458
|
+
rootPath,
|
|
459
|
+
{ baseRoot, includeDocumentation = true, documentationRoots, sourceOnly = false } = {},
|
|
460
|
+
) {
|
|
461
|
+
const root = path.resolve(rootPath);
|
|
462
|
+
const basePath = baseRoot ? path.resolve(baseRoot) : undefined;
|
|
463
|
+
const docsRoots = documentationRoots?.map((docsRoot) => path.resolve(docsRoot)) ?? [root];
|
|
464
|
+
return new ProductCheck(root, defaultFrameworkRoot, basePath, docsRoots, { sourceOnly }).run({
|
|
465
|
+
includeDocumentation,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
470
|
+
const options = parseArgs(process.argv.slice(2));
|
|
471
|
+
const check = validateProduct(options.root ?? process.cwd(), {
|
|
472
|
+
baseRoot: options.base,
|
|
473
|
+
documentationRoots: options.docsRoots,
|
|
474
|
+
sourceOnly: options.sourceOnly ?? false,
|
|
475
|
+
});
|
|
476
|
+
if (check.errors.length === 0) {
|
|
477
|
+
if (options.verbose) {
|
|
478
|
+
const policies = check.policyCount === undefined ? "" : ` policies=${check.policyCount}`;
|
|
479
|
+
console.log(
|
|
480
|
+
`nodes=${check.nodeCount} decisions=${check.decisionCount} refs=${check.referenceCount}${policies} ok`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
process.exit(0);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
console.error(check.errors.join("\n"));
|
|
487
|
+
process.exit(1);
|
|
488
|
+
}
|