agent-doc-system 0.0.0-stage → 1.0.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/INSTALL_FOR_AGENTS.md +60 -0
- package/LICENSE +21 -0
- package/README.md +151 -2
- package/adapters/cache.mjs +27 -0
- package/adapters/contract-consumers.mjs +56 -0
- package/adapters/database.mjs +116 -0
- package/adapters/deploy-config.mjs +34 -0
- package/adapters/dockerfile.mjs +26 -0
- package/adapters/events.mjs +145 -0
- package/adapters/generic.mjs +86 -0
- package/adapters/github-actions.mjs +106 -0
- package/adapters/go.mjs +91 -0
- package/adapters/graphql.mjs +28 -0
- package/adapters/index.mjs +55 -0
- package/adapters/node.mjs +118 -0
- package/adapters/object-store.mjs +64 -0
- package/adapters/oidc.mjs +56 -0
- package/adapters/openapi.mjs +50 -0
- package/adapters/pnpm.mjs +42 -0
- package/adapters/protobuf.mjs +35 -0
- package/adapters/registry.mjs +27 -0
- package/adapters/resource-client.mjs +61 -0
- package/adapters/turborepo.mjs +135 -0
- package/adapters/typescript.mjs +85 -0
- package/adapters/wrangler.mjs +206 -0
- package/bin/agentdoc.mjs +478 -0
- package/bin/usage.txt +26 -0
- package/core/acceptances.mjs +69 -0
- package/core/audit.mjs +407 -0
- package/core/authority/classes.mjs +39 -0
- package/core/authority/engine.mjs +243 -0
- package/core/authority/facts.mjs +41 -0
- package/core/codes.mjs +119 -0
- package/core/compile.mjs +881 -0
- package/core/contracts.mjs +121 -0
- package/core/derive.mjs +102 -0
- package/core/descriptors.mjs +486 -0
- package/core/determinism.mjs +95 -0
- package/core/discover.mjs +147 -0
- package/core/facts.mjs +53 -0
- package/core/fsx.mjs +342 -0
- package/core/graph.mjs +260 -0
- package/core/impact.mjs +175 -0
- package/core/indexes.mjs +165 -0
- package/core/jsonschema.mjs +298 -0
- package/core/observations.mjs +129 -0
- package/core/provenance.mjs +83 -0
- package/core/query.mjs +384 -0
- package/core/relations.mjs +286 -0
- package/core/scaffold.mjs +322 -0
- package/core/secrets.mjs +92 -0
- package/core/sourcescan.mjs +88 -0
- package/core/toml.mjs +163 -0
- package/core/yaml.mjs +475 -0
- package/docs/ADAPTERS.md +172 -0
- package/docs/AUTHORITY.md +260 -0
- package/docs/CI.md +136 -0
- package/docs/COMPARISON-KODA.md +164 -0
- package/docs/EVALS.md +166 -0
- package/docs/MIGRATION.md +193 -0
- package/docs/PROVENANCE.md +133 -0
- package/docs/QUERY.md +180 -0
- package/docs/SPEC.md +339 -0
- package/docs/VERIFICATION.md +28 -0
- package/docs/ci/generic-ci.sh +28 -0
- package/docs/ci/github-actions.yml +35 -0
- package/evals/harness.mjs +426 -0
- package/evals/scenarios/fixture-a-01-change-api.json +9 -0
- package/evals/scenarios/fixture-a-02-change-db.json +9 -0
- package/evals/scenarios/fixture-a-03-enforce-constraint.json +9 -0
- package/evals/scenarios/fixture-b-01-change-proto.json +9 -0
- package/evals/scenarios/fixture-b-02-change-write-path.json +9 -0
- package/evals/scenarios/fixture-b-03-change-shared-lib.json +9 -0
- package/evals/scenarios/fixture-b-04-journey.json +9 -0
- package/evals/scenarios/fixture-c-01-mixed-runtime-edge.json +19 -0
- package/evals/scenarios/fixture-c-02-stale-doc-contradiction.json +9 -0
- package/evals/scenarios/fixture-c-03-shared-vocabulary.json +9 -0
- package/evals/scenarios/fixture-c-04-contract-change.json +9 -0
- package/evals/scenarios/koda-01-modify-api-consumer.json +26 -0
- package/evals/scenarios/koda-02-change-db-schema.json +20 -0
- package/evals/scenarios/koda-03-change-auth.json +21 -0
- package/evals/scenarios/koda-04-add-event-producer.json +19 -0
- package/evals/scenarios/koda-05-modify-worker-binding.json +25 -0
- package/evals/scenarios/koda-06-change-cross-component-endpoint.json +22 -0
- package/evals/scenarios/koda-07-change-shared-package.json +16 -0
- package/evals/scenarios/koda-08-change-runtime-cron.json +23 -0
- package/evals/scenarios/koda-09-debug-production-mismatch.json +26 -0
- package/evals/thresholds.json +11 -0
- package/package.json +46 -4
- package/schemas/api.schema.json +84 -0
- package/schemas/common.schema.json +107 -0
- package/schemas/component.schema.json +40 -0
- package/schemas/config.schema.json +685 -0
- package/schemas/domain.schema.json +14 -0
- package/schemas/graph.schema.json +1084 -0
- package/schemas/journeys.schema.json +47 -0
- package/schemas/observation.schema.json +143 -0
- package/schemas/resource.schema.json +28 -0
- package/schemas/system.schema.json +26 -0
- package/skills/agent-doc-system/SKILL.md +113 -0
- package/skills/agent-doc-system/references/authority-and-conflicts.md +70 -0
- package/skills/agent-doc-system/references/cli.md +89 -0
- package/skills/agent-doc-system/references/create-migrate.md +101 -0
- package/skills/agent-doc-system/references/maintenance.md +52 -0
- package/templates/ADR.md +26 -0
- package/templates/ARCHITECTURE.md +19 -0
- package/templates/CONSTRAINTS.md +17 -0
- package/templates/PRODUCT.md +17 -0
- package/templates/api.yaml +10 -0
- package/templates/ci.yml +31 -0
- package/templates/component.yaml +14 -0
- package/templates/domain.yaml +6 -0
- package/templates/journey.yaml +7 -0
- package/templates/observation.yaml +19 -0
- package/templates/resource.yaml +8 -0
- package/templates/system.yaml +10 -0
- package/templates/warning-acceptance.yaml +14 -0
package/core/graph.mjs
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// Graph assembly, deterministic serialization, subject resolution, freshness
|
|
2
|
+
// validation, and atomic replacement.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { AgentDocError, CODES } from "./codes.mjs";
|
|
7
|
+
import { assertNoSecretsInText } from "./secrets.mjs";
|
|
8
|
+
import { assertDeterministic, structuralProjection } from "./determinism.mjs";
|
|
9
|
+
import { sha256Hex } from "./fsx.mjs";
|
|
10
|
+
|
|
11
|
+
export const COMPILER_NAME = "agentdoc-compiler";
|
|
12
|
+
export const COMPILER_VERSION = "1.0.0";
|
|
13
|
+
export const GRAPH_SCHEMA_VERSION = "agentdoc.dev/graph/v1";
|
|
14
|
+
|
|
15
|
+
// Resolve symbolic provenance subjects to RFC 6901 pointers into the graph.
|
|
16
|
+
export function subjectResolver(graph) {
|
|
17
|
+
const entIdx = new Map(graph.entities.map((e, i) => [e.ref, i]));
|
|
18
|
+
const relIdx = new Map(graph.relations.map((r, i) => [r.type + "|" + r.sourceRef + "|" + r.targetRef + "|" + JSON.stringify(r.attributes), i]));
|
|
19
|
+
const evtIdx = new Map(graph.interfaces.events.map((e, i) => [e.pattern, i]));
|
|
20
|
+
const extIdx = new Map(graph.externalDependencies.map((e, i) => [e.componentRef + "|" + e.name, i]));
|
|
21
|
+
const capIdx = new Map(graph.capabilities.map((e, i) => [e.componentRef + "|" + e.name, i]));
|
|
22
|
+
const verIdx = new Map(graph.verification.map((e, i) => [e.id, i]));
|
|
23
|
+
const jouIdx = new Map(graph.journeys.map((e, i) => [e.id, i]));
|
|
24
|
+
const assIdx = new Map(graph.assertions.map((a, i) => [a.id, i]));
|
|
25
|
+
const conIdx = new Map(graph.conflicts.map((c, i) => [c.id, i]));
|
|
26
|
+
const obsIdx = new Map(graph.observations.map((o, i) => [o.id, i]));
|
|
27
|
+
const ctrIdx = new Map(graph.contracts.map((c, i) => [c.apiRef, i]));
|
|
28
|
+
const gateIdx = new Map(graph.gates.map((g, i) => [g.id, i]));
|
|
29
|
+
|
|
30
|
+
return (sym) => {
|
|
31
|
+
const splitAt = sym.indexOf(":/");
|
|
32
|
+
const head = splitAt >= 0 ? sym.slice(0, splitAt) : sym;
|
|
33
|
+
const tail = splitAt >= 0 ? sym.slice(splitAt + 1) : null;
|
|
34
|
+
const idx = (m, key, what) => {
|
|
35
|
+
const i = m.get(key);
|
|
36
|
+
if (i === undefined) throw new AgentDocError(CODES.PROVENANCE, "dangling provenance subject " + sym + " (" + what + ")");
|
|
37
|
+
return i;
|
|
38
|
+
};
|
|
39
|
+
if (sym.startsWith("entf:")) return "/entities/" + idx(entIdx, sym.slice(5, splitAt), "entity") + "/entity" + tail;
|
|
40
|
+
if (sym.startsWith("ent:")) return "/entities/" + idx(entIdx, sym.slice(4), "entity");
|
|
41
|
+
if (sym.startsWith("der:")) return "/entities/" + idx(entIdx, sym.slice(4, splitAt), "entity") + "/derived" + tail;
|
|
42
|
+
if (sym.startsWith("rel:")) return "/relations/" + idx(relIdx, sym.slice(4), "relation");
|
|
43
|
+
if (sym.startsWith("evt:")) return "/interfaces/events/" + idx(evtIdx, sym.slice(4), "event");
|
|
44
|
+
if (sym.startsWith("ext:")) return "/externalDependencies/" + idx(extIdx, sym.slice(4), "external dependency");
|
|
45
|
+
if (sym.startsWith("cap:")) return "/capabilities/" + idx(capIdx, sym.slice(4), "capability");
|
|
46
|
+
if (sym.startsWith("ver:")) return "/verification/" + idx(verIdx, sym.slice(4), "verification");
|
|
47
|
+
if (sym.startsWith("jou:")) return "/journeys/" + idx(jouIdx, sym.slice(4), "journey");
|
|
48
|
+
if (sym.startsWith("ass:")) return "/assertions/" + idx(assIdx, sym.slice(4), "assertion");
|
|
49
|
+
if (sym.startsWith("con:")) return "/conflicts/" + idx(conIdx, sym.slice("con:".length), "conflict");
|
|
50
|
+
if (sym.startsWith("ctr:")) return "/contracts/" + idx(ctrIdx, sym.slice("ctr:".length), "contract");
|
|
51
|
+
if (sym.startsWith("obs:")) return "/observations/" + idx(obsIdx, sym.slice(4), "observation");
|
|
52
|
+
if (sym.startsWith("gate:")) return "/gates/" + idx(gateIdx, sym.slice("gate:".length), "gate");
|
|
53
|
+
throw new AgentDocError(CODES.PROVENANCE, "unknown provenance subject " + sym);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function assembleGraph(sources, rel, indexes, derivedMap, facts, conflicts, observations, provFinal, diagnostics) {
|
|
58
|
+
const entities = sources.entities
|
|
59
|
+
.map((e) => {
|
|
60
|
+
const rec = {
|
|
61
|
+
ref: e.ref,
|
|
62
|
+
kind: e.kind,
|
|
63
|
+
name: e.name,
|
|
64
|
+
entity: e.doc,
|
|
65
|
+
source: "authored",
|
|
66
|
+
sourcePaths: [e.file],
|
|
67
|
+
};
|
|
68
|
+
const d = derivedMap.get(e.ref);
|
|
69
|
+
if (d && Object.keys(d).length) rec.derived = d;
|
|
70
|
+
return rec;
|
|
71
|
+
})
|
|
72
|
+
.sort((a, b) => (a.ref < b.ref ? -1 : 1));
|
|
73
|
+
|
|
74
|
+
const relations = rel.relations.map((r) => ({
|
|
75
|
+
type: r.type,
|
|
76
|
+
sourceRef: r.sourceRef,
|
|
77
|
+
targetRef: r.targetRef,
|
|
78
|
+
evidenceClass: r.evidenceClass,
|
|
79
|
+
attributes: r.attributes || {},
|
|
80
|
+
provenanceIds: [...r.provs].map((p) => provFinal.idOf.get(p)).filter(Boolean).sort(),
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
const strip = (arr, extra) =>
|
|
84
|
+
arr.map((r) => {
|
|
85
|
+
const { provs, _provs, ...rest } = r;
|
|
86
|
+
const add = extra ? extra(r) : {};
|
|
87
|
+
const out = { ...add, ...rest };
|
|
88
|
+
if (add && add._drop) {
|
|
89
|
+
delete out._drop;
|
|
90
|
+
delete out.provenanceIds;
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
out.provenanceIds = [...(provs || _provs || [])].map((p) => provFinal.idOf.get(p)).filter(Boolean).sort();
|
|
94
|
+
if (Array.isArray(out.provenanceIds) && out.provenanceIds.length === 0) delete out.provenanceIds;
|
|
95
|
+
for (const k of Object.keys(out)) if (out[k] === null) delete out[k];
|
|
96
|
+
return out;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const assertions = facts.facts
|
|
100
|
+
.slice()
|
|
101
|
+
.sort((a, b) => (a.id < b.id ? -1 : 1))
|
|
102
|
+
.map((a) => {
|
|
103
|
+
const out = {
|
|
104
|
+
id: a.id,
|
|
105
|
+
subject: a.subject,
|
|
106
|
+
key: a.key,
|
|
107
|
+
value: a.value,
|
|
108
|
+
evidenceClass: a.evidenceClass,
|
|
109
|
+
confidence: a.confidence,
|
|
110
|
+
status: a.status,
|
|
111
|
+
provenanceIds: [...a.provRecs].map((p) => provFinal.idOf.get(p)).filter(Boolean).sort(),
|
|
112
|
+
};
|
|
113
|
+
if (a.semantics) out.semantics = a.semantics;
|
|
114
|
+
if (a.observed) out.observed = a.observed;
|
|
115
|
+
if (a.review) out.review = a.review;
|
|
116
|
+
return out;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// A conflict is corroborated by the evidence of the assertions it holds apart:
|
|
120
|
+
// that is exactly the evidence a reviewer needs in order to settle it.
|
|
121
|
+
const provByAssertion = new Map(
|
|
122
|
+
facts.facts.map((a) => [a.id, [...a.provRecs].map((p) => provFinal.idOf.get(p)).filter(Boolean).sort()])
|
|
123
|
+
);
|
|
124
|
+
const idsBySubjectKey = new Map();
|
|
125
|
+
for (const a of facts.facts) {
|
|
126
|
+
const k = a.subject + "|" + a.key;
|
|
127
|
+
if (!idsBySubjectKey.has(k)) idsBySubjectKey.set(k, []);
|
|
128
|
+
idsBySubjectKey.get(k).push(a.id);
|
|
129
|
+
}
|
|
130
|
+
const conflictOut = conflicts.map((c, i) => {
|
|
131
|
+
const out = {
|
|
132
|
+
id: "c-" + String(i + 1).padStart(4, "0"),
|
|
133
|
+
subject: c.subject,
|
|
134
|
+
key: c.key,
|
|
135
|
+
kind: c.kind,
|
|
136
|
+
status: c.status,
|
|
137
|
+
assertionIds: c.assertionIds,
|
|
138
|
+
election: {
|
|
139
|
+
electedAssertionId: c.election.elected ? c.election.elected.id : null,
|
|
140
|
+
basis: c.election.basis,
|
|
141
|
+
ruleId: c.election.ruleId,
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
if (c.election.rationale) out.election.rationale = c.election.rationale;
|
|
145
|
+
// Carried explicitly, including when it is null, so a consumer can tell
|
|
146
|
+
// "this decision has a review condition" from "no rule governs this key".
|
|
147
|
+
out.election.reviewWhen = c.election.reviewWhen ?? null;
|
|
148
|
+
if (c.election.contradicted && c.election.contradicted.length) out.election.contradictedAssertionIds = c.election.contradicted;
|
|
149
|
+
if (c.detail) out.detail = c.detail;
|
|
150
|
+
if (c.acceptance) out.acceptance = c.acceptance;
|
|
151
|
+
out.provenanceIds = (idsBySubjectKey.get(c.subject + "|" + c.key) || [])
|
|
152
|
+
.flatMap((id) => provByAssertion.get(id) || [])
|
|
153
|
+
.filter(Boolean)
|
|
154
|
+
.sort();
|
|
155
|
+
return out;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
schemaVersion: GRAPH_SCHEMA_VERSION,
|
|
160
|
+
source: { commit: "0000000", dirty: false, inputHash: "sha256:" + "0".repeat(64) },
|
|
161
|
+
compiler: { name: COMPILER_NAME, version: COMPILER_VERSION, adapters: [] },
|
|
162
|
+
entities,
|
|
163
|
+
relations,
|
|
164
|
+
interfaces: { events: strip(indexes.events) },
|
|
165
|
+
externalDependencies: strip(indexes.externalDependencies),
|
|
166
|
+
capabilities: strip(indexes.capabilities),
|
|
167
|
+
verification: strip(indexes.verification, () => ({})),
|
|
168
|
+
journeys: strip(indexes.journeys, () => ({})),
|
|
169
|
+
gates: strip(indexes.gates || [], () => ({})),
|
|
170
|
+
contracts: strip(indexes.contracts || []),
|
|
171
|
+
assertions,
|
|
172
|
+
conflicts: conflictOut,
|
|
173
|
+
observations: strip(observations),
|
|
174
|
+
provenance: provFinal.list,
|
|
175
|
+
diagnostics,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function serializeGraph(graph) {
|
|
180
|
+
// Objects are constructed in schema order, arrays are pre-sorted, and no
|
|
181
|
+
// wall-clock value, host path, username or random id is introduced anywhere.
|
|
182
|
+
return JSON.stringify(graph, null, 2) + "\n";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function finalizeSerialization(text, graph, { allowedTimestamps = [] } = {}) {
|
|
186
|
+
assertNoSecretsInText(text, "compiled graph");
|
|
187
|
+
assertDeterministic(text, { allowedTimestamps, what: "graph", structural: structuralProjection(graph) });
|
|
188
|
+
return text;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function writeGraphAtomic(repo, relPath, content) {
|
|
192
|
+
const abs = repo.abs(relPath);
|
|
193
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
194
|
+
const tmp = abs + ".tmp-" + process.pid;
|
|
195
|
+
fs.writeFileSync(tmp, content);
|
|
196
|
+
fs.renameSync(tmp, abs);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Freshness: a consumer MUST refuse a graph whose provenance does not match the
|
|
200
|
+
// current checkout. No silent fallback, ever.
|
|
201
|
+
// Freshness deliberately does NOT compare the recorded commit with HEAD.
|
|
202
|
+
//
|
|
203
|
+
// The graph is often committed alongside the change that produced it, and
|
|
204
|
+
// committing a file changes HEAD — so a commit-equality gate can never be
|
|
205
|
+
// satisfied, and a graph produced by an agent mid-task would always look stale.
|
|
206
|
+
// What actually determines whether the graph still describes the repository is
|
|
207
|
+
// the input hash (every byte the compiler read) and the dirty flag (whether a
|
|
208
|
+
// compiler input differs from HEAD). The commit is kept as provenance so a
|
|
209
|
+
// reader can see which checkout produced the graph, not as a gate.
|
|
210
|
+
export function assertFresh(graph, { inputHash, dirty, compilerVersion = COMPILER_VERSION, schemaVersion = GRAPH_SCHEMA_VERSION }) {
|
|
211
|
+
if (graph.schemaVersion !== schemaVersion) {
|
|
212
|
+
throw new AgentDocError(CODES.GRAPH_SCHEMA, "unsupported graph schemaVersion '" + graph.schemaVersion + "' (expected " + schemaVersion + ")");
|
|
213
|
+
}
|
|
214
|
+
if (graph.compiler?.name !== COMPILER_NAME || graph.compiler?.version !== compilerVersion) {
|
|
215
|
+
throw new AgentDocError(
|
|
216
|
+
CODES.COMPILER_MISMATCH,
|
|
217
|
+
"graph was compiled by " + JSON.stringify(graph.compiler) + ", this compiler is " + COMPILER_NAME + "@" + compilerVersion + " — recompile"
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (graph.source?.inputHash !== inputHash) {
|
|
221
|
+
throw new AgentDocError(
|
|
222
|
+
CODES.GRAPH_FRESHNESS,
|
|
223
|
+
"graph inputHash differs from current inputs — rebuild the graph"
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (graph.source?.dirty !== dirty) {
|
|
227
|
+
throw new AgentDocError(
|
|
228
|
+
CODES.GRAPH_FRESHNESS,
|
|
229
|
+
"graph dirty flag (" + graph.source?.dirty + ") differs from the current input state (" + dirty + ") — rebuild the graph"
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function readGraph(repo, relPath) {
|
|
235
|
+
const abs = repo.abs(relPath);
|
|
236
|
+
if (!fs.existsSync(abs)) {
|
|
237
|
+
throw new AgentDocError(
|
|
238
|
+
CODES.GRAPH_MISSING,
|
|
239
|
+
"no compiled graph at " + relPath + " — run `agentdoc compile` first",
|
|
240
|
+
{ path: relPath }
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
let text;
|
|
244
|
+
try {
|
|
245
|
+
text = fs.readFileSync(abs, "utf8");
|
|
246
|
+
} catch {
|
|
247
|
+
throw new AgentDocError(CODES.GRAPH_SCHEMA, "compiled graph is not readable — rebuild it");
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
return { graph: JSON.parse(text), text };
|
|
251
|
+
} catch {
|
|
252
|
+
throw new AgentDocError(CODES.GRAPH_SCHEMA, "compiled graph is not valid JSON — rebuild the graph", { path: relPath });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function hashText(t) {
|
|
257
|
+
return "sha256:" + sha256Hex(Buffer.from(t, "utf8"));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export { createHash };
|
package/core/impact.mjs
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Change impact.
|
|
2
|
+
//
|
|
3
|
+
// When an agent edits a file, the question is not "what does this file do" but
|
|
4
|
+
// "what must I keep consistent, and which gate proves it". This answers that
|
|
5
|
+
// from the graph alone, so the answer is available before any exploration.
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { ContextIndex } from "./query.mjs";
|
|
8
|
+
|
|
9
|
+
// The affected-file classification is derived from the compiled graph, not from
|
|
10
|
+
// a hand-written list of technology filenames. A list would go stale for every
|
|
11
|
+
// repository that is not Node-and-Go, and `impact` would silently under-report
|
|
12
|
+
// there — which is worse than not answering.
|
|
13
|
+
//
|
|
14
|
+
// Sources of truth, all from the graph:
|
|
15
|
+
// - the canonical contract of every API entity -> contract
|
|
16
|
+
// - every file the compiler read -> input
|
|
17
|
+
// - every extractor identity, which names the technology -> dependency /
|
|
18
|
+
// verification
|
|
19
|
+
// - every documentation and constraint path on an entity -> documentation
|
|
20
|
+
function classify(graph, p, idx) {
|
|
21
|
+
for (const c of graph.contracts) if (c.ref === p) return "contract";
|
|
22
|
+
for (const e of graph.entities) {
|
|
23
|
+
const d = e.derived || {};
|
|
24
|
+
for (const x of d.docs || []) if (x === p) return "documentation";
|
|
25
|
+
for (const x of d.constraints || []) if (x === p) return "documentation";
|
|
26
|
+
for (const x of d.runbooks || []) if (x === p) return "documentation";
|
|
27
|
+
}
|
|
28
|
+
if (/\.(md|mdx|adoc|rst|txt)$/.test(p)) return "documentation";
|
|
29
|
+
if (/(^|\/)migrations?\//.test(p)) return "resource";
|
|
30
|
+
// A Resource named directly — by ref, or by the descriptor file that declares
|
|
31
|
+
// it — is a resource change. Without this, `impact resource:default/x` reported
|
|
32
|
+
// nothing at all, and a change to a dataset's owner was invisible. The loop
|
|
33
|
+
// below that walks `usesResource` was written for exactly this case and could
|
|
34
|
+
// never run, because nothing ever populated `resources` for a Resource entity.
|
|
35
|
+
const entity = idx && idx.resolve(p);
|
|
36
|
+
if (entity && entity.kind === "Resource") return "resource";
|
|
37
|
+
if (/\.ya?ml$/.test(p) && /\.github\/workflows\//.test(p)) return "verification";
|
|
38
|
+
const inputs = new Set(graph.provenance.map((x) => x.path));
|
|
39
|
+
if (inputs.has(p)) return "input";
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function impact(graph, changedPaths) {
|
|
44
|
+
const idx = new ContextIndex(graph);
|
|
45
|
+
const affectedComponents = new Map(); // ref -> Set(reason)
|
|
46
|
+
const add = (ref, reason) => {
|
|
47
|
+
if (!idx.byRef.has(ref)) return;
|
|
48
|
+
if (!affectedComponents.has(ref)) affectedComponents.set(ref, new Set());
|
|
49
|
+
affectedComponents.get(ref).add(reason);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const contracts = new Set();
|
|
53
|
+
const docs = new Set();
|
|
54
|
+
const resources = new Set();
|
|
55
|
+
const verificationPaths = new Set();
|
|
56
|
+
const dependencyPaths = new Set();
|
|
57
|
+
const catalogPaths = new Set();
|
|
58
|
+
const unmatched = [];
|
|
59
|
+
|
|
60
|
+
for (const raw of changedPaths) {
|
|
61
|
+
const p = raw.replace(/^\.\//, "");
|
|
62
|
+
const entity = idx.resolve(p);
|
|
63
|
+
// A Resource is not a component. Adding it to `affectedComponents` put a
|
|
64
|
+
// `resource:` ref in a list an agent reads as "components you must check",
|
|
65
|
+
// while the components actually bound to it went unmentioned.
|
|
66
|
+
if (entity && entity.kind === "Component") add(entity.ref, "direct file change inside " + entity.ref);
|
|
67
|
+
else unmatched.push(p);
|
|
68
|
+
const kind = classify(graph, p, idx);
|
|
69
|
+
if (kind === "contract") contracts.add(p);
|
|
70
|
+
if (kind === "documentation") docs.add(p);
|
|
71
|
+
if (kind === "resource") resources.add(p);
|
|
72
|
+
if (kind === "verification") verificationPaths.add(p);
|
|
73
|
+
if (kind === "input") dependencyPaths.add(p);
|
|
74
|
+
if (p.startsWith("agentdoc/")) catalogPaths.add(p);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A contract change reaches its provider and every consumer.
|
|
78
|
+
for (const c of contracts) {
|
|
79
|
+
for (const api of graph.entities.filter((e) => e.kind === "API")) {
|
|
80
|
+
if (api.entity.spec.contract?.ref !== c) continue;
|
|
81
|
+
for (const r of graph.relations.filter((r) => (r.type === "providesApi" && r.targetRef === api.ref) || (r.type === "consumesApi" && r.targetRef === api.ref))) {
|
|
82
|
+
add(r.sourceRef, "affected by contract " + c);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Changing a component reaches the components that depend on it, and the
|
|
88
|
+
// components it schedules or is scheduled by. Two hops: a direct dependent
|
|
89
|
+
// and a dependent-of-a-dependent is the practical blast radius; beyond that
|
|
90
|
+
// the graph stops being a routing aid and becomes a dump.
|
|
91
|
+
for (const start of [...affectedComponents.keys()]) {
|
|
92
|
+
for (let hop = 0; hop < 2; hop++) {
|
|
93
|
+
for (const r of graph.relations) {
|
|
94
|
+
if (r.type === "buildDependencyOf" || r.type === "testDependencyOf" || r.type === "buildDependsOn" || r.type === "testDependsOn") {
|
|
95
|
+
if (r.targetRef === start) add(r.sourceRef, "depends on a changed component");
|
|
96
|
+
if (r.sourceRef === start) add(r.targetRef, "a dependent of a changed component");
|
|
97
|
+
}
|
|
98
|
+
if (r.type === "schedules" || r.type === "scheduledBy" || r.type === "runtimeCalls" || r.type === "runtimeCalledBy") {
|
|
99
|
+
if (r.sourceRef === start) add(r.targetRef, "connected to a changed component by " + r.type);
|
|
100
|
+
if (r.targetRef === start) add(r.sourceRef, "connected to a changed component by " + r.type);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A dependency manifest change reaches both directions of the edge.
|
|
107
|
+
for (const d of dependencyPaths) {
|
|
108
|
+
for (const r of graph.relations) {
|
|
109
|
+
if (r.type !== "buildDependsOn" && r.type !== "testDependsOn") continue;
|
|
110
|
+
if (r.attributes?.package === path.basename(d)) {
|
|
111
|
+
add(r.sourceRef, "dependency manifest " + d + " changed");
|
|
112
|
+
add(r.targetRef, "depended on by a component whose manifest changed");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A documentation change reaches the component that owns it.
|
|
118
|
+
for (const d of docs) {
|
|
119
|
+
for (const e of graph.entities) {
|
|
120
|
+
const d2 = e.derived || {};
|
|
121
|
+
if ((d2.docs || []).includes(d) || (d2.constraints || []).includes(d) || (d2.runbooks || []).includes(d)) {
|
|
122
|
+
add(e.ref, "owns documentation " + d);
|
|
123
|
+
}
|
|
124
|
+
if (e.entity.spec?.context && Object.values(e.entity.spec.context).flat().includes(d)) add(e.ref, "declared context link " + d);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A resource change reaches every component bound to it. Matched on the full
|
|
129
|
+
// ref or on a path that lies under the Resource's own source root, so a
|
|
130
|
+
// Resource ref and its migration directory both reach the same components.
|
|
131
|
+
for (const r of resources) {
|
|
132
|
+
const refMatch = r.match(/^[a-z]+:[^/]+\/(.+)$/);
|
|
133
|
+
const refName = refMatch ? refMatch[1] : null;
|
|
134
|
+
for (const rel of graph.relations) {
|
|
135
|
+
if (rel.type !== "usesResource" && rel.type !== "resourceUsedBy") continue;
|
|
136
|
+
const bound = rel.type === "usesResource" ? rel.targetRef : rel.sourceRef;
|
|
137
|
+
const user = rel.type === "usesResource" ? rel.sourceRef : rel.targetRef;
|
|
138
|
+
const name = bound.split("/").slice(1).join("/");
|
|
139
|
+
if ((refName && name === refName) || r.includes(name)) {
|
|
140
|
+
add(user, "resource change touches " + bound);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const refs = [...affectedComponents.keys()].sort();
|
|
146
|
+
const verification = graph.verification.filter((v) => v.componentRefs.some((r) => refs.includes(r)));
|
|
147
|
+
const conflicts = graph.conflicts.filter((c) => refs.includes(c.subject));
|
|
148
|
+
const journeys = graph.journeys.filter((j) => j.components.some((r) => refs.includes(r)));
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
changedFiles: changedPaths.slice().sort(),
|
|
152
|
+
unmatchedFiles: unmatched.sort(),
|
|
153
|
+
affectedComponents: refs.map((ref) => ({ ref, reasons: [...affectedComponents.get(ref)].sort() })),
|
|
154
|
+
affectedContracts: [...contracts].sort(),
|
|
155
|
+
affectedDocs: [...docs].sort(),
|
|
156
|
+
affectedResources: [...resources].sort(),
|
|
157
|
+
affectedInputs: [...dependencyPaths].sort(),
|
|
158
|
+
affectedVerification: [...verificationPaths].sort(),
|
|
159
|
+
affectedCatalog: [...catalogPaths].sort(),
|
|
160
|
+
catalogSourceChanged: catalogPaths.size > 0,
|
|
161
|
+
// Any file the compiler read is a graph input: changing it invalidates the
|
|
162
|
+
// committed graph even when no descriptor changed.
|
|
163
|
+
graphWillBeStale: graphAffected(graph, changedPaths),
|
|
164
|
+
recommendedVerification: verification.map((v) => ({ id: v.id, tier: v.tier, command: v.command })).slice(0, 25),
|
|
165
|
+
openConflicts: conflicts.map((c) => ({ id: c.id, subject: c.subject, key: c.key, status: c.status, kind: c.kind })),
|
|
166
|
+
relatedJourneys: journeys.map((j) => ({ id: j.id, description: j.description })),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function graphAffected(graph, changedPaths) {
|
|
171
|
+
const inputs = new Set(graph.provenance.map((p) => p.path));
|
|
172
|
+
for (const p of changedPaths) if (inputs.has(p)) return true;
|
|
173
|
+
const idx = new ContextIndex(graph);
|
|
174
|
+
return changedPaths.some((p) => idx.resolve(p));
|
|
175
|
+
}
|
package/core/indexes.mjs
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Typed non-entity indexes: interfaces.events, externalDependencies,
|
|
2
|
+
// capabilities, verification, journeys. Nothing here is an entity and nothing
|
|
3
|
+
// here may appear as a relation endpoint.
|
|
4
|
+
import { CODES, AgentDocError } from "./codes.mjs";
|
|
5
|
+
import { contractDefRegex } from "./contracts.mjs";
|
|
6
|
+
|
|
7
|
+
const classifyContractDef = contractDefRegex;
|
|
8
|
+
|
|
9
|
+
// A literal like "orders.created.v1" folds into a declared family
|
|
10
|
+
// "orders.created.*" when that family is also declared. Folding is
|
|
11
|
+
// deterministic and its provenance is rebound to the merged record.
|
|
12
|
+
function familyOf(pattern, families) {
|
|
13
|
+
for (const fam of families) {
|
|
14
|
+
const prefix = fam.replace(/\*$/, "").replace(/>$/, "");
|
|
15
|
+
if (prefix && pattern.startsWith(prefix)) return fam;
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function plausibilityCheck(ctx) {
|
|
21
|
+
const prefixes = ctx.cfg.discovery.eventPatternPrefixes;
|
|
22
|
+
const ok = (p) => {
|
|
23
|
+
if (typeof p !== "string" || p.length === 0 || p.length > 200) return false;
|
|
24
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._>\*-]*$/.test(p)) return false;
|
|
25
|
+
if (!prefixes || prefixes.length === 0) return true;
|
|
26
|
+
return prefixes.some((pre) => p.startsWith(pre));
|
|
27
|
+
};
|
|
28
|
+
return ok;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildIndexes(ctx) {
|
|
32
|
+
const { prov, diagnostics } = ctx;
|
|
33
|
+
const plausible = plausibilityCheck(ctx);
|
|
34
|
+
const events = [];
|
|
35
|
+
const merged = new Map();
|
|
36
|
+
const mergeInto = (pattern, rec) => {
|
|
37
|
+
if (!merged.has(pattern)) merged.set(pattern, { producers: new Set(), consumers: new Set(), provs: new Set(), contract: rec.contract });
|
|
38
|
+
const m = merged.get(pattern);
|
|
39
|
+
if (rec.contract) m.contract = rec.contract;
|
|
40
|
+
for (const x of rec.producers) m.producers.add(x);
|
|
41
|
+
for (const x of rec.consumers) m.consumers.add(x);
|
|
42
|
+
for (const p of rec.provs) m.provs.add(p);
|
|
43
|
+
};
|
|
44
|
+
const raw = [...ctx.events.keys()].filter((p) => plausible(p));
|
|
45
|
+
for (const [p, rec] of ctx.events) {
|
|
46
|
+
if (plausible(p)) continue;
|
|
47
|
+
diagnostics.push({
|
|
48
|
+
severity: "warning",
|
|
49
|
+
code: "AGENTDOC_EVENT_UNRESOLVED",
|
|
50
|
+
subject: p,
|
|
51
|
+
message: "event subject '" + p + "' does not match the configured event subject shape; left out of the interface index",
|
|
52
|
+
});
|
|
53
|
+
// The subject is not in the index, so its evidence must stop claiming to
|
|
54
|
+
// prove it. The warning still surfaces it to a reviewer.
|
|
55
|
+
for (const pr of rec.provs) pr.subjects.delete("evt:" + p);
|
|
56
|
+
}
|
|
57
|
+
const families = new Set(raw.filter((p) => p.endsWith(".*") || p.endsWith(".>")));
|
|
58
|
+
for (const [pattern, rec] of ctx.events) {
|
|
59
|
+
if (!plausible(pattern)) continue;
|
|
60
|
+
const fam = families.has(pattern) ? null : familyOf(pattern, families);
|
|
61
|
+
mergeInto(fam || pattern, rec);
|
|
62
|
+
if (fam) {
|
|
63
|
+
for (const p of rec.provs) {
|
|
64
|
+
if (p.subjects.has("evt:" + pattern)) {
|
|
65
|
+
p.subjects.delete("evt:" + pattern);
|
|
66
|
+
p.subjects.add("evt:" + fam);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const [pattern, rec] of [...merged.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
73
|
+
const contract = rec.contract;
|
|
74
|
+
if (!contract || !ctx.repo.exists(contract.ref)) {
|
|
75
|
+
diagnostics.push({
|
|
76
|
+
severity: "error",
|
|
77
|
+
code: CODES.EVENT_CONTRACT,
|
|
78
|
+
subject: pattern,
|
|
79
|
+
message: "event pattern '" + pattern + "' has no resolvable canonical contract" + (contract ? ": " + contract.ref : ""),
|
|
80
|
+
paths: contract ? [contract.ref] : [],
|
|
81
|
+
});
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const resolution = rec.producers.size > 0 ? "resolved" : "unresolved";
|
|
85
|
+
if (resolution === "unresolved") {
|
|
86
|
+
diagnostics.push({
|
|
87
|
+
severity: "warning",
|
|
88
|
+
code: "AGENTDOC_EVENT_UNRESOLVED",
|
|
89
|
+
subject: pattern,
|
|
90
|
+
refs: [...rec.consumers].sort(),
|
|
91
|
+
message: "event pattern '" + pattern + "' has " + rec.consumers.size + " consumer(s) but no observed producer",
|
|
92
|
+
paths: [contract.ref],
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
events.push({
|
|
96
|
+
pattern,
|
|
97
|
+
producers: [...rec.producers].sort(),
|
|
98
|
+
consumers: [...rec.consumers].sort(),
|
|
99
|
+
contract,
|
|
100
|
+
provs: rec.provs,
|
|
101
|
+
resolution,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const externalDependencies = [...ctx.externals.values()]
|
|
106
|
+
.sort((a, b) => (a.componentRef + "|" + a.name < b.componentRef + "|" + b.name ? -1 : 1))
|
|
107
|
+
.map((r) => ({ componentRef: r.componentRef, name: r.name, role: r.role, mechanism: r.mechanism, evidenceClass: r.evidenceClass, provs: r.provs }));
|
|
108
|
+
|
|
109
|
+
const capabilities = [...ctx.capabilities.values()]
|
|
110
|
+
.sort((a, b) => (a.componentRef + "|" + a.name < b.componentRef + "|" + b.name ? -1 : 1))
|
|
111
|
+
.map((r) => ({ componentRef: r.componentRef, name: r.name, evidenceClass: r.evidenceClass, provs: r.provs }));
|
|
112
|
+
|
|
113
|
+
const verification = [...ctx.verification.values()]
|
|
114
|
+
.sort((a, b) => (a.id < b.id ? -1 : 1))
|
|
115
|
+
.map((v) => ({
|
|
116
|
+
id: v.id,
|
|
117
|
+
componentRefs: [...v.componentRefs].sort(),
|
|
118
|
+
tier: v.tier,
|
|
119
|
+
command: v.command,
|
|
120
|
+
configPaths: [...(v.configPaths || [])].sort(),
|
|
121
|
+
provs: v.provs,
|
|
122
|
+
}));
|
|
123
|
+
|
|
124
|
+
const journeys = (ctx.sources.journeys || []).map((j) => ({
|
|
125
|
+
id: j.id,
|
|
126
|
+
description: j.description,
|
|
127
|
+
components: j.components,
|
|
128
|
+
notes: j.notes || null,
|
|
129
|
+
provs: new Set([prov.add("declared", ctx.journeysPath, "journey-extractor", ["jou:" + j.id])]),
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
return { events, externalDependencies, capabilities, verification, journeys };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Every contract file under a configured contract root must be claimed by
|
|
136
|
+
// exactly one API descriptor, and no contract may be claimed twice.
|
|
137
|
+
export function checkContractCoverage(ctx) {
|
|
138
|
+
const errors = [];
|
|
139
|
+
const claimed = new Map();
|
|
140
|
+
for (const api of ctx.sources.entities.filter((e) => e.kind === "API" && e.doc.spec.contract?.ref)) {
|
|
141
|
+
const cref = api.doc.spec.contract.ref;
|
|
142
|
+
if (claimed.has(cref)) {
|
|
143
|
+
errors.push(new AgentDocError(
|
|
144
|
+
CODES.CONTRACT_SHARED,
|
|
145
|
+
"contract.ref '" + cref + "' is claimed by two APIs: " + claimed.get(cref) + " and " + api.ref,
|
|
146
|
+
{ path: api.file, ref: api.ref }
|
|
147
|
+
));
|
|
148
|
+
}
|
|
149
|
+
claimed.set(cref, api.ref);
|
|
150
|
+
}
|
|
151
|
+
const defRe = classifyContractDef();
|
|
152
|
+
for (const rootDir of ctx.cfg.discovery.contractRoots || []) {
|
|
153
|
+
if (!ctx.repo.isDir(rootDir)) continue;
|
|
154
|
+
for (const f of ctx.repo.walk(rootDir)) {
|
|
155
|
+
if (defRe.test(f) && !claimed.has(f)) {
|
|
156
|
+
errors.push(new AgentDocError(
|
|
157
|
+
CODES.CONTRACT_UNCLAIMED,
|
|
158
|
+
"canonical contract definition '" + f + "' is not referenced by any API contract.ref",
|
|
159
|
+
{ path: f }
|
|
160
|
+
));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return errors;
|
|
165
|
+
}
|