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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/docs/architecture.md +77 -0
  4. package/docs/cli.md +239 -0
  5. package/docs/getting-started.md +98 -0
  6. package/docs/model-reference.md +92 -0
  7. package/docs/model.md +75 -0
  8. package/package.json +76 -0
  9. package/policies/concept-owner-domain.yaml +8 -0
  10. package/policies/documentation-model-reference-resolution.yaml +8 -0
  11. package/policies/no-dangling-model-reference.yaml +8 -0
  12. package/policies/policy-spec.schema.json +23 -0
  13. package/schemas/context-pack.schema.json +83 -0
  14. package/schemas/fr-to-code-audit.schema.json +85 -0
  15. package/schemas/product/concept.schema.json +17 -0
  16. package/schemas/product/domain-interface.schema.json +18 -0
  17. package/schemas/product/domain.schema.json +24 -0
  18. package/schemas/product/evidence-anchor.schema.json +30 -0
  19. package/schemas/product/guarantee.schema.json +35 -0
  20. package/schemas/product/model.schema.json +20 -0
  21. package/schemas/product/relationship.schema.json +21 -0
  22. package/schemas/product/use-case.schema.json +27 -0
  23. package/scripts/audit-fr-to-code.mjs +162 -0
  24. package/scripts/check-generated-docs.mjs +58 -0
  25. package/scripts/check-generated-graph-svg.mjs +60 -0
  26. package/scripts/check-generated-graph.mjs +66 -0
  27. package/scripts/check-model.mjs +488 -0
  28. package/scripts/ddduck.mjs +542 -0
  29. package/scripts/generate-agent-readiness-report.mjs +23 -0
  30. package/scripts/generate-docs.mjs +205 -0
  31. package/scripts/generate-graph-svg.mjs +359 -0
  32. package/scripts/generate-graph.mjs +268 -0
  33. package/scripts/lib/agent-readiness-evals.mjs +433 -0
  34. package/scripts/lib/agent-readiness-report.mjs +79 -0
  35. package/scripts/lib/cli-contract.mjs +162 -0
  36. package/scripts/lib/context-pack.mjs +107 -0
  37. package/scripts/lib/ddduck-config.mjs +57 -0
  38. package/scripts/lib/fr-to-code-audit.mjs +144 -0
  39. package/scripts/lib/product-layout.mjs +93 -0
  40. package/scripts/lib/product-operation.mjs +431 -0
  41. package/scripts/lib/product-paths.mjs +43 -0
  42. package/scripts/lib/product-query.mjs +284 -0
  43. package/scripts/lib/product-root-resolver.mjs +167 -0
  44. package/scripts/lib/scan-ignore.mjs +8 -0
  45. package/scripts/lib/skill-installer.mjs +410 -0
  46. package/scripts/query-model.mjs +64 -0
  47. package/scripts/run-agent-readiness-evals.mjs +57 -0
  48. package/skills/update-ddduck-specs/SKILL.md +98 -0
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { mkdirSync, writeFileSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { detectProductLayout, loadProductNodes } from "./lib/product-layout.mjs";
7
+ import { resolveContainedOutput } from "./lib/product-paths.mjs";
8
+ import { resolveProductRoot } from "./lib/product-root-resolver.mjs";
9
+
10
+ export const jsonOutputPath = path.join("generated", "graph", "model-graph.json");
11
+ export const ndjsonOutputPath = path.join("generated", "graph", "model-graph.ndjson");
12
+
13
+ export function buildModelGraphOutputs(rootPath) {
14
+ const modelGraph = buildModelGraph(rootPath);
15
+ return {
16
+ json: `${JSON.stringify(modelGraph, null, 2)}\n`,
17
+ ndjson: renderNdjson(modelGraph),
18
+ };
19
+ }
20
+
21
+ export function buildModelGraph(rootPath, { history = false } = {}) {
22
+ const layout = detectProductLayout(rootPath);
23
+ const graph = loadGraph(layout, { history });
24
+ return assembleModelGraph(graph, findModelId(graph));
25
+ }
26
+
27
+ export function writeModelGraph(rootPath) {
28
+ const outputs = buildModelGraphOutputs(rootPath);
29
+ const writtenPaths = [];
30
+ for (const [relativePath, output] of Object.entries({
31
+ [jsonOutputPath]: outputs.json,
32
+ [ndjsonOutputPath]: outputs.ndjson,
33
+ })) {
34
+ const absoluteOutputPath = resolveContainedOutput(rootPath, relativePath);
35
+ mkdirSync(path.dirname(absoluteOutputPath), { recursive: true });
36
+ writeFileSync(absoluteOutputPath, output);
37
+ writtenPaths.push(relativePath);
38
+ }
39
+ return writtenPaths;
40
+ }
41
+
42
+ function loadGraph(layout, { history }) {
43
+ const loaded = loadProductNodes(layout);
44
+ return {
45
+ nodes: new Map(
46
+ [...loaded.nodes].filter(([, node]) => history || node.kind !== "Guarantee" || node.status === "active"),
47
+ ),
48
+ };
49
+ }
50
+
51
+ function assembleModelGraph(graph, modelId) {
52
+ const model = resolveNode(graph, modelId);
53
+ const nodes = [...graph.nodes.values()].map(renderNode).sort(byId);
54
+ const edges = [
55
+ ...buildModelOwnershipEdges(graph, model),
56
+ ...buildDomainOwnershipEdges(graph, model),
57
+ ...buildRelationshipEdges(graph, model),
58
+ ...buildReferenceEdges(graph, model),
59
+ ].sort(byId);
60
+
61
+ return {
62
+ schemaVersion: "1",
63
+ formatVersion: "final",
64
+ modelId: model.id,
65
+ modelName: requireField(model, "name"),
66
+ nameStatus: model.nameStatus ?? "stable",
67
+ generatedBy: "scripts/generate-graph.mjs",
68
+ nodes,
69
+ edges,
70
+ };
71
+ }
72
+
73
+ function renderNode(node) {
74
+ return {
75
+ id: requireField(node, "id"),
76
+ kind: requireField(node, "kind"),
77
+ name: node.name ?? null,
78
+ ownershipKind: node.ownershipKind ?? null,
79
+ purpose: nodePurpose(node),
80
+ model: node.model ?? null,
81
+ ownerDomain: node.ownerDomain ?? node.ownedBy ?? null,
82
+ };
83
+ }
84
+
85
+ function nodePurpose(node) {
86
+ if (node.kind === "DomainInterface") {
87
+ return `${requireField(node, "name")} (${requireField(node, "operationKind")})`;
88
+ }
89
+ return node.purpose ?? node.statement ?? node.goal ?? node.description ?? "Unspecified";
90
+ }
91
+
92
+ function buildModelOwnershipEdges(graph, model) {
93
+ return asArray(model.domains).map((domainId) => {
94
+ resolveNode(graph, domainId);
95
+ return {
96
+ id: `edge:owns|${model.id}|${domainId}`,
97
+ from: model.id,
98
+ to: domainId,
99
+ kind: "owns",
100
+ label: "owns",
101
+ source: model.id,
102
+ relationshipType: null,
103
+ mode: null,
104
+ };
105
+ });
106
+ }
107
+
108
+ function buildDomainOwnershipEdges(graph, model) {
109
+ return asArray(model.domains).flatMap((domainId) => {
110
+ const domain = resolveNode(graph, domainId);
111
+ return [...asArray(domain.concepts), ...asArray(domain.interfaces), ...asArray(domain.guarantees)]
112
+ .filter((conceptId) => graph.nodes.has(conceptId))
113
+ .map((conceptId) => {
114
+ resolveNode(graph, conceptId);
115
+ return {
116
+ id: `edge:owns|${domain.id}|${conceptId}`,
117
+ from: domain.id,
118
+ to: conceptId,
119
+ kind: "owns",
120
+ label: "owns",
121
+ source: domain.id,
122
+ relationshipType: null,
123
+ mode: null,
124
+ };
125
+ });
126
+ });
127
+ }
128
+
129
+ function buildRelationshipEdges(graph, model) {
130
+ return asArray(model.relationships).map((relationshipId) => {
131
+ const relationship = resolveNode(graph, relationshipId);
132
+ resolveNode(graph, relationship.from);
133
+ resolveNode(graph, relationship.to);
134
+ return {
135
+ id: relationship.id,
136
+ from: requireField(relationship, "from"),
137
+ to: requireField(relationship, "to"),
138
+ kind: "relationship",
139
+ label: requireField(relationship, "relationshipType"),
140
+ source: relationship.id,
141
+ relationshipType: relationship.relationshipType,
142
+ mode: requireField(relationship, "mode"),
143
+ };
144
+ });
145
+ }
146
+
147
+ function buildReferenceEdges(graph, model) {
148
+ const useCases = asArray(model.useCases).map((useCaseId) => resolveNode(graph, useCaseId));
149
+ const interfaces = [...graph.nodes.values()].filter((node) => node.kind === "DomainInterface");
150
+
151
+ return [
152
+ ...useCases.flatMap((useCase) => [
153
+ ...buildReferenceEdgesFor(graph, useCase, useCase.preconditions?.requires, "requires"),
154
+ ...buildReferenceEdgesFor(graph, useCase, useCase.success?.preserves, "preserves"),
155
+ ...buildReferenceEdgesFor(graph, useCase, useCase.success?.establishes, "establishes"),
156
+ ...buildReferenceEdgesFor(graph, useCase, useCase.interfaces, "uses"),
157
+ ]),
158
+ ...interfaces.flatMap((domainInterface) =>
159
+ buildReferenceEdgesFor(graph, domainInterface, domainInterface.guarantees, "guarantees"),
160
+ ),
161
+ ];
162
+ }
163
+
164
+ function buildReferenceEdgesFor(graph, fromNode, referenceIds, kind) {
165
+ return asArray(referenceIds).map((toId) => {
166
+ resolveNode(graph, toId);
167
+ return {
168
+ id: `edge:${kind}|${fromNode.id}|${toId}`,
169
+ from: fromNode.id,
170
+ to: toId,
171
+ kind,
172
+ label: kind,
173
+ source: fromNode.id,
174
+ relationshipType: null,
175
+ mode: null,
176
+ };
177
+ });
178
+ }
179
+
180
+ function renderNdjson(modelGraph) {
181
+ const metadata = {
182
+ type: "metadata",
183
+ schemaVersion: modelGraph.schemaVersion,
184
+ modelId: modelGraph.modelId,
185
+ modelName: modelGraph.modelName,
186
+ nameStatus: modelGraph.nameStatus,
187
+ generatedBy: modelGraph.generatedBy,
188
+ ...(modelGraph.formatVersion ? { formatVersion: modelGraph.formatVersion } : {}),
189
+ };
190
+ const records = [
191
+ metadata,
192
+ ...modelGraph.nodes.map((node) => ({ type: "node", ...node })),
193
+ ...modelGraph.edges.map((edge) => ({ type: "edge", ...edge })),
194
+ ];
195
+ return `${records.map((record) => JSON.stringify(record)).join("\n")}\n`;
196
+ }
197
+
198
+ function findModelId(graph) {
199
+ const models = [...graph.nodes.values()].filter((node) => node.kind === "Model");
200
+ if (models.length !== 1) throw new Error(`expected one Model node, found ${models.length}`);
201
+ return models[0].id;
202
+ }
203
+
204
+ function resolveNode(graph, id) {
205
+ const node = graph.nodes.get(id);
206
+ if (!node) {
207
+ throw new Error(`missing model node ${id}`);
208
+ }
209
+ return node;
210
+ }
211
+
212
+ function requireField(node, field) {
213
+ const value = node[field];
214
+ if (value === undefined || value === null || value === "") {
215
+ throw new Error(`missing required field ${field} on ${node.id ?? "model node"}`);
216
+ }
217
+ return value;
218
+ }
219
+
220
+ function byId(left, right) {
221
+ return left.id.localeCompare(right.id);
222
+ }
223
+
224
+ function asArray(value) {
225
+ if (Array.isArray(value)) {
226
+ return value;
227
+ }
228
+ if (value === undefined || value === null) {
229
+ return [];
230
+ }
231
+ return [value];
232
+ }
233
+
234
+ function parseArgs(args) {
235
+ const parsed = {};
236
+
237
+ for (let index = 0; index < args.length; index += 1) {
238
+ const arg = args[index];
239
+ if (arg === "--root") {
240
+ parsed.root = args[index + 1];
241
+ index += 1;
242
+ continue;
243
+ }
244
+ if (arg === "--verbose") {
245
+ parsed.verbose = true;
246
+ continue;
247
+ }
248
+
249
+ throw new Error(`Unknown argument: ${arg}`);
250
+ }
251
+
252
+ return parsed;
253
+ }
254
+
255
+ function isMainModule() {
256
+ return process.argv[1] === fileURLToPath(import.meta.url);
257
+ }
258
+
259
+ if (isMainModule()) {
260
+ const options = parseArgs(process.argv.slice(2));
261
+ const root = resolveProductRoot({ explicitRoot: options.root });
262
+ const writtenPaths = writeModelGraph(root);
263
+ if (options.verbose) {
264
+ for (const writtenPath of writtenPaths) {
265
+ console.log(`wrote ${writtenPath}`);
266
+ }
267
+ }
268
+ }
@@ -0,0 +1,433 @@
1
+ import { readFileSync, realpathSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { runQuery } from "../query-model.mjs";
4
+ import { loadQueryProduct } from "./product-query.mjs";
5
+
6
+ const supportedOperations = new Set(["node", "neighbors", "impact", "anchors", "spec", "context"]);
7
+ const shellText = /[\n\r;&|`$()<>\\]/;
8
+ const recordKeys = new Set(["schemaVersion", "id", "root", "query", "candidateEvidence"]);
9
+ const queryKeys = new Set(["operation", "args"]);
10
+ const candidateEvidenceKeys = new Set(["origin", "citations", "facts"]);
11
+ const citationKeys = new Set(["id", "sourcePath"]);
12
+ const factKeys = new Set(["pointer", "equals"]);
13
+
14
+ export function runAgentReadinessEvals({ inputPath, repoRoot }) {
15
+ const resolvedRepoRoot = path.resolve(repoRoot);
16
+ const records = readRecords(inputPath);
17
+ const duplicateIds = duplicateRecordIds(records);
18
+ const cases = records
19
+ .map((record) => evaluateRecord(record, { repoRoot: resolvedRepoRoot, duplicateIds }))
20
+ .sort(compareCases)
21
+ .map(stripLine);
22
+
23
+ return {
24
+ schemaVersion: "1",
25
+ passed: cases.every((entry) => entry.passed),
26
+ cases,
27
+ };
28
+ }
29
+
30
+ export function validateCandidateEvidence(document, candidateEvidence, { productRoot } = {}) {
31
+ const diagnostics = [];
32
+ if (!isObject(candidateEvidence)) {
33
+ return ["candidateEvidence must be an object"];
34
+ }
35
+ diagnostics.push(...unknownKeyDiagnostics(candidateEvidence, "candidateEvidence", candidateEvidenceKeys));
36
+ if (candidateEvidence.origin !== "query") {
37
+ diagnostics.push('candidate evidence origin must be "query"');
38
+ }
39
+
40
+ const availableCitations = collectCitations(document);
41
+ if (!Array.isArray(candidateEvidence.citations)) {
42
+ diagnostics.push("candidate evidence citations must be an array");
43
+ } else {
44
+ for (const citation of [...candidateEvidence.citations].sort(compareCitations)) {
45
+ if (isObject(citation)) {
46
+ diagnostics.push(...unknownKeyDiagnostics(citation, "citation", citationKeys));
47
+ }
48
+ if (!isCitation(citation)) {
49
+ diagnostics.push("candidate evidence citation must contain string id and sourcePath");
50
+ continue;
51
+ }
52
+ if (!availableCitations.some((available) => sameCitation(available, citation))) {
53
+ diagnostics.push(`citation does not occur in query document: ${citation.id} at ${citation.sourcePath}`);
54
+ }
55
+ }
56
+ }
57
+
58
+ if (!Array.isArray(candidateEvidence.facts)) {
59
+ diagnostics.push("candidate evidence facts must be an array");
60
+ } else {
61
+ for (const fact of [...candidateEvidence.facts].sort(compareFacts)) {
62
+ if (isObject(fact)) {
63
+ diagnostics.push(...unknownKeyDiagnostics(fact, "fact", factKeys));
64
+ }
65
+ if (!isObject(fact) || typeof fact.pointer !== "string" || !Object.hasOwn(fact, "equals")) {
66
+ diagnostics.push("candidate evidence fact must contain pointer and equals");
67
+ continue;
68
+ }
69
+ const resolved = resolveJsonPointer(document, fact.pointer);
70
+ if (!resolved.ok) {
71
+ diagnostics.push(`fact JSON Pointer is invalid: ${fact.pointer} (${resolved.error})`);
72
+ continue;
73
+ }
74
+ if (!deepJsonEqual(resolved.value, resolveFactExpectation(fact.equals, productRoot))) {
75
+ diagnostics.push(`fact does not strictly equal query value at ${fact.pointer}`);
76
+ }
77
+ }
78
+ }
79
+
80
+ return diagnostics.sort(compareStrings);
81
+ }
82
+
83
+ function readRecords(inputPath) {
84
+ let input;
85
+ try {
86
+ input = readFileSync(inputPath, "utf8");
87
+ } catch (error) {
88
+ return [invalidRecord(1, `cannot read eval input: ${error.message}`)];
89
+ }
90
+
91
+ const records = input
92
+ .split(/\r?\n/)
93
+ .map((line, index) => ({ line, lineNumber: index + 1 }))
94
+ .filter(({ line }) => line.trim() !== "")
95
+ .map(({ line, lineNumber }) => {
96
+ try {
97
+ return { lineNumber, value: JSON.parse(line) };
98
+ } catch (error) {
99
+ return invalidRecord(lineNumber, `invalid JSONL record: ${error.message}`);
100
+ }
101
+ });
102
+ return records.length > 0
103
+ ? records
104
+ : [invalidRecord(1, "eval input must contain at least one non-empty JSONL record")];
105
+ }
106
+
107
+ function invalidRecord(lineNumber, diagnostic) {
108
+ return { lineNumber, diagnostic };
109
+ }
110
+
111
+ function duplicateRecordIds(records) {
112
+ const counts = new Map();
113
+ for (const record of records) {
114
+ if (typeof record.value?.id === "string") {
115
+ counts.set(record.value.id, (counts.get(record.value.id) ?? 0) + 1);
116
+ }
117
+ }
118
+ return new Set([...counts].filter(([, count]) => count > 1).map(([id]) => id));
119
+ }
120
+
121
+ function evaluateRecord(record, { repoRoot, duplicateIds }) {
122
+ const id = typeof record.value?.id === "string" ? record.value.id : `line:${record.lineNumber}`;
123
+ const recordDiagnostics = record.diagnostic
124
+ ? [record.diagnostic]
125
+ : validateRecord(record.value, { repoRoot, duplicateIds });
126
+ const evidenceDiagnostics = record.diagnostic ? [] : validateCandidateEvidenceShape(record.value?.candidateEvidence);
127
+ const entry = {
128
+ id,
129
+ root: typeof record.value?.root === "string" ? normalizePath(record.value.root) : null,
130
+ query: null,
131
+ sourceDigest: null,
132
+ evidence: normalizeEvidence(record.value?.candidateEvidence),
133
+ passed: false,
134
+ diagnostics: [...recordDiagnostics, ...evidenceDiagnostics],
135
+ line: record.lineNumber,
136
+ };
137
+ if (recordDiagnostics.length > 0) return finalizeCase(entry);
138
+
139
+ const root = resolveProductRoot(repoRoot, record.value.root);
140
+ const query = normalizeQuery(record.value.query);
141
+ entry.query = query;
142
+ try {
143
+ const document = runCapturedQuery(query, record.value.root, repoRoot);
144
+ entry.sourceDigest = loadQueryProduct(root).sourceDigest;
145
+ if (evidenceDiagnostics.length === 0) {
146
+ entry.diagnostics.push(
147
+ ...validateCandidateEvidence(document, record.value.candidateEvidence, {
148
+ productRoot: realpathSync(root),
149
+ }),
150
+ );
151
+ }
152
+ } catch (error) {
153
+ entry.diagnostics.push(`query failed: ${error.message}`);
154
+ }
155
+ return finalizeCase(entry);
156
+ }
157
+
158
+ function validateRecord(record, { repoRoot, duplicateIds }) {
159
+ const diagnostics = [];
160
+ if (!isObject(record)) return ["eval record must be an object"];
161
+ diagnostics.push(...unknownKeyDiagnostics(record, "record", recordKeys));
162
+ if (record.schemaVersion !== "1") diagnostics.push('schemaVersion must be "1"');
163
+ if (typeof record.id !== "string" || record.id === "") {
164
+ diagnostics.push("id must be a non-empty string");
165
+ } else if (duplicateIds.has(record.id)) {
166
+ diagnostics.push(`duplicate eval id: ${record.id}`);
167
+ }
168
+ if (typeof record.root !== "string" || !isPathBelow(repoRoot, record.root)) {
169
+ diagnostics.push("root must be a repository-relative path below repoRoot");
170
+ }
171
+ diagnostics.push(...validateQuery(record.query));
172
+ return diagnostics.sort(compareStrings);
173
+ }
174
+
175
+ function validateCandidateEvidenceShape(candidateEvidence) {
176
+ const diagnostics = [];
177
+ if (!isObject(candidateEvidence)) return ["candidateEvidence must be an object"];
178
+ diagnostics.push(...unknownKeyDiagnostics(candidateEvidence, "candidateEvidence", candidateEvidenceKeys));
179
+ if (candidateEvidence.origin !== "query") {
180
+ diagnostics.push('candidate evidence origin must be "query"');
181
+ }
182
+ if (!Array.isArray(candidateEvidence.citations)) {
183
+ diagnostics.push("candidate evidence citations must be an array");
184
+ } else {
185
+ for (const citation of candidateEvidence.citations) {
186
+ if (isObject(citation)) diagnostics.push(...unknownKeyDiagnostics(citation, "citation", citationKeys));
187
+ if (!isCitation(citation)) diagnostics.push("candidate evidence citation must contain string id and sourcePath");
188
+ }
189
+ }
190
+ if (!Array.isArray(candidateEvidence.facts)) {
191
+ diagnostics.push("candidate evidence facts must be an array");
192
+ } else {
193
+ for (const fact of candidateEvidence.facts) {
194
+ if (isObject(fact)) diagnostics.push(...unknownKeyDiagnostics(fact, "fact", factKeys));
195
+ if (!isObject(fact) || typeof fact.pointer !== "string" || !Object.hasOwn(fact, "equals")) {
196
+ diagnostics.push("candidate evidence fact must contain pointer and equals");
197
+ }
198
+ }
199
+ }
200
+ return diagnostics.sort(compareStrings);
201
+ }
202
+
203
+ function validateQuery(query) {
204
+ const diagnostics = [];
205
+ if (!isObject(query)) return ["query must be an object"];
206
+ diagnostics.push(...unknownKeyDiagnostics(query, "query", queryKeys));
207
+ if (!supportedOperations.has(query.operation)) {
208
+ diagnostics.push("query operation must be node, neighbors, impact, anchors, spec, or context");
209
+ }
210
+ if (!Array.isArray(query.args)) {
211
+ diagnostics.push("query args must be an array");
212
+ return diagnostics;
213
+ }
214
+
215
+ for (let index = 0; index < query.args.length; index += 1) {
216
+ const argument = query.args[index];
217
+ if (typeof argument !== "string") {
218
+ diagnostics.push("query args must contain only strings");
219
+ continue;
220
+ }
221
+ if (shellText.test(argument)) diagnostics.push(`query args must not contain shell text: ${argument}`);
222
+ if (argument === "--root" || argument === "--json" || argument === "--history") {
223
+ diagnostics.push(`query args must not include ${argument}`);
224
+ }
225
+ if (argument === "--id") {
226
+ const id = query.args[index + 1];
227
+ if (typeof id !== "string" || id === "" || id.startsWith("--") || shellText.test(id)) {
228
+ diagnostics.push("query --id requires one plain ID value");
229
+ } else {
230
+ index += 1;
231
+ }
232
+ continue;
233
+ }
234
+ if (argument.startsWith("--")) diagnostics.push(`unsupported query argument: ${argument}`);
235
+ else if (index === 0 || query.args[index - 1] !== "--id") diagnostics.push(`unsupported query value: ${argument}`);
236
+ }
237
+ return diagnostics.sort(compareStrings);
238
+ }
239
+
240
+ function normalizeQuery(query) {
241
+ const ids = [];
242
+ for (let index = 0; index < query.args.length; index += 2) ids.push(query.args[index + 1]);
243
+ return {
244
+ operation: query.operation,
245
+ args: ids.sort(compareStrings).flatMap((id) => ["--id", id]),
246
+ };
247
+ }
248
+
249
+ function runCapturedQuery(query, root, repoRoot) {
250
+ let output = "";
251
+ runQuery([query.operation, ...query.args, "--root", root, "--json"], {
252
+ cwd: repoRoot,
253
+ stdout: { write: (chunk) => (output += chunk) },
254
+ });
255
+ return JSON.parse(output);
256
+ }
257
+
258
+ function resolveProductRoot(repoRoot, root) {
259
+ return path.resolve(repoRoot, root);
260
+ }
261
+
262
+ // Fact expectations may reference the machine-specific resolved root through the
263
+ // portable <product-root> placeholder, matching the CLI's substituted output.
264
+ function resolveFactExpectation(equals, productRoot) {
265
+ if (typeof productRoot !== "string" || productRoot === "") return equals;
266
+ if (typeof equals === "string") return equals.replaceAll("<product-root>", productRoot);
267
+ if (Array.isArray(equals)) return equals.map((entry) => resolveFactExpectation(entry, productRoot));
268
+ if (isObject(equals)) {
269
+ return Object.fromEntries(
270
+ Object.entries(equals).map(([key, value]) => [key, resolveFactExpectation(value, productRoot)]),
271
+ );
272
+ }
273
+ return equals;
274
+ }
275
+
276
+ function isPathBelow(repoRoot, relativePath) {
277
+ if (path.isAbsolute(relativePath) || relativePath.trim() === "") return false;
278
+ const resolved = path.resolve(repoRoot, relativePath);
279
+ const relative = path.relative(repoRoot, resolved);
280
+ if (relative === "" || relative.startsWith(`..${path.sep}`) || relative === ".." || path.isAbsolute(relative)) {
281
+ return false;
282
+ }
283
+ try {
284
+ const realRepoRoot = realpathSync(repoRoot);
285
+ const realProductRoot = realpathSync(resolved);
286
+ const realRelative = path.relative(realRepoRoot, realProductRoot);
287
+ return (
288
+ realRelative !== "" &&
289
+ !realRelative.startsWith(`..${path.sep}`) &&
290
+ realRelative !== ".." &&
291
+ !path.isAbsolute(realRelative)
292
+ );
293
+ } catch (error) {
294
+ if (error.code === "ENOENT") return true;
295
+ return false;
296
+ }
297
+ }
298
+
299
+ function collectCitations(value, citations = []) {
300
+ if (Array.isArray(value)) {
301
+ for (const entry of value) collectCitations(entry, citations);
302
+ } else if (isObject(value)) {
303
+ if (isCitation(value)) citations.push({ id: value.id, sourcePath: value.sourcePath });
304
+ for (const entry of Object.values(value)) collectCitations(entry, citations);
305
+ }
306
+ return citations.sort(compareCitations);
307
+ }
308
+
309
+ function resolveJsonPointer(document, pointer) {
310
+ if (pointer === "") return { ok: true, value: document };
311
+ if (!pointer.startsWith("/")) return { ok: false, error: "must start with /" };
312
+ const tokens = pointer.slice(1).split("/").map(decodePointerToken);
313
+ if (tokens.some((token) => token === null)) return { ok: false, error: "contains an invalid escape" };
314
+
315
+ let current = document;
316
+ for (const token of tokens) {
317
+ if (Array.isArray(current)) {
318
+ if (!/^(0|[1-9][0-9]*)$/.test(token) || Number(token) >= current.length) {
319
+ return { ok: false, error: "does not resolve" };
320
+ }
321
+ current = current[Number(token)];
322
+ } else if (isObject(current) && Object.hasOwn(current, token)) {
323
+ current = current[token];
324
+ } else {
325
+ return { ok: false, error: "does not resolve" };
326
+ }
327
+ }
328
+ return { ok: true, value: current };
329
+ }
330
+
331
+ function decodePointerToken(token) {
332
+ for (let index = 0; index < token.length; index += 1) {
333
+ if (token[index] !== "~") continue;
334
+ if (token[index + 1] !== "0" && token[index + 1] !== "1") return null;
335
+ index += 1;
336
+ }
337
+ return token.replaceAll("~1", "/").replaceAll("~0", "~");
338
+ }
339
+
340
+ function normalizeEvidence(candidateEvidence) {
341
+ if (!isObject(candidateEvidence)) return null;
342
+ return {
343
+ origin: candidateEvidence.origin ?? null,
344
+ citations: Array.isArray(candidateEvidence.citations)
345
+ ? candidateEvidence.citations.filter(isObject).map(normalizeCitation).sort(compareCitations)
346
+ : [],
347
+ facts: Array.isArray(candidateEvidence.facts)
348
+ ? candidateEvidence.facts.filter(isObject).map(normalizeFact).sort(compareFacts)
349
+ : [],
350
+ };
351
+ }
352
+
353
+ function normalizeCitation(citation) {
354
+ return { id: citation.id ?? null, sourcePath: citation.sourcePath ?? null };
355
+ }
356
+
357
+ function normalizeFact(fact) {
358
+ const normalized = { pointer: fact.pointer ?? null };
359
+ if (Object.hasOwn(fact, "equals")) normalized.equals = fact.equals;
360
+ return normalized;
361
+ }
362
+
363
+ function finalizeCase(entry) {
364
+ return {
365
+ ...entry,
366
+ passed: entry.diagnostics.length === 0,
367
+ diagnostics: [...entry.diagnostics].sort(compareStrings),
368
+ };
369
+ }
370
+
371
+ function stripLine({ line: _line, ...caseResult }) {
372
+ return caseResult;
373
+ }
374
+
375
+ function compareCases(left, right) {
376
+ return compareStrings(left.id, right.id) || left.line - right.line;
377
+ }
378
+
379
+ function compareCitations(left, right) {
380
+ return (
381
+ compareStrings(left?.id ?? "", right?.id ?? "") || compareStrings(left?.sourcePath ?? "", right?.sourcePath ?? "")
382
+ );
383
+ }
384
+
385
+ function compareFacts(left, right) {
386
+ return compareStrings(left?.pointer ?? "", right?.pointer ?? "");
387
+ }
388
+
389
+ function compareStrings(left, right) {
390
+ return left < right ? -1 : left > right ? 1 : 0;
391
+ }
392
+
393
+ function unknownKeyDiagnostics(value, level, allowedKeys) {
394
+ return Object.keys(value)
395
+ .filter((key) => !allowedKeys.has(key))
396
+ .sort(compareStrings)
397
+ .map((key) => `unknown key: ${key} (${level})`);
398
+ }
399
+
400
+ function sameCitation(left, right) {
401
+ return left.id === right.id && left.sourcePath === right.sourcePath;
402
+ }
403
+
404
+ function deepJsonEqual(left, right) {
405
+ if (left === right) return true;
406
+ if (Array.isArray(left) || Array.isArray(right)) {
407
+ return (
408
+ Array.isArray(left) &&
409
+ Array.isArray(right) &&
410
+ left.length === right.length &&
411
+ left.every((value, index) => deepJsonEqual(value, right[index]))
412
+ );
413
+ }
414
+ if (!isObject(left) || !isObject(right)) return false;
415
+ const leftKeys = Object.keys(left).sort(compareStrings);
416
+ const rightKeys = Object.keys(right).sort(compareStrings);
417
+ return (
418
+ leftKeys.length === rightKeys.length &&
419
+ leftKeys.every((key, index) => key === rightKeys[index] && deepJsonEqual(left[key], right[key]))
420
+ );
421
+ }
422
+
423
+ function isCitation(value) {
424
+ return isObject(value) && typeof value.id === "string" && typeof value.sourcePath === "string";
425
+ }
426
+
427
+ function isObject(value) {
428
+ return value !== null && typeof value === "object" && !Array.isArray(value);
429
+ }
430
+
431
+ function normalizePath(value) {
432
+ return value.split(path.sep).join("/");
433
+ }