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.
Files changed (117) hide show
  1. package/INSTALL_FOR_AGENTS.md +60 -0
  2. package/LICENSE +21 -0
  3. package/README.md +151 -2
  4. package/adapters/cache.mjs +27 -0
  5. package/adapters/contract-consumers.mjs +56 -0
  6. package/adapters/database.mjs +116 -0
  7. package/adapters/deploy-config.mjs +34 -0
  8. package/adapters/dockerfile.mjs +26 -0
  9. package/adapters/events.mjs +145 -0
  10. package/adapters/generic.mjs +86 -0
  11. package/adapters/github-actions.mjs +106 -0
  12. package/adapters/go.mjs +91 -0
  13. package/adapters/graphql.mjs +28 -0
  14. package/adapters/index.mjs +55 -0
  15. package/adapters/node.mjs +118 -0
  16. package/adapters/object-store.mjs +64 -0
  17. package/adapters/oidc.mjs +56 -0
  18. package/adapters/openapi.mjs +50 -0
  19. package/adapters/pnpm.mjs +42 -0
  20. package/adapters/protobuf.mjs +35 -0
  21. package/adapters/registry.mjs +27 -0
  22. package/adapters/resource-client.mjs +61 -0
  23. package/adapters/turborepo.mjs +135 -0
  24. package/adapters/typescript.mjs +85 -0
  25. package/adapters/wrangler.mjs +206 -0
  26. package/bin/agentdoc.mjs +478 -0
  27. package/bin/usage.txt +26 -0
  28. package/core/acceptances.mjs +69 -0
  29. package/core/audit.mjs +407 -0
  30. package/core/authority/classes.mjs +39 -0
  31. package/core/authority/engine.mjs +243 -0
  32. package/core/authority/facts.mjs +41 -0
  33. package/core/codes.mjs +119 -0
  34. package/core/compile.mjs +881 -0
  35. package/core/contracts.mjs +121 -0
  36. package/core/derive.mjs +102 -0
  37. package/core/descriptors.mjs +486 -0
  38. package/core/determinism.mjs +95 -0
  39. package/core/discover.mjs +147 -0
  40. package/core/facts.mjs +53 -0
  41. package/core/fsx.mjs +342 -0
  42. package/core/graph.mjs +260 -0
  43. package/core/impact.mjs +175 -0
  44. package/core/indexes.mjs +165 -0
  45. package/core/jsonschema.mjs +298 -0
  46. package/core/observations.mjs +129 -0
  47. package/core/provenance.mjs +83 -0
  48. package/core/query.mjs +384 -0
  49. package/core/relations.mjs +286 -0
  50. package/core/scaffold.mjs +322 -0
  51. package/core/secrets.mjs +92 -0
  52. package/core/sourcescan.mjs +88 -0
  53. package/core/toml.mjs +163 -0
  54. package/core/yaml.mjs +475 -0
  55. package/docs/ADAPTERS.md +172 -0
  56. package/docs/AUTHORITY.md +260 -0
  57. package/docs/CI.md +136 -0
  58. package/docs/COMPARISON-KODA.md +164 -0
  59. package/docs/EVALS.md +166 -0
  60. package/docs/MIGRATION.md +193 -0
  61. package/docs/PROVENANCE.md +133 -0
  62. package/docs/QUERY.md +180 -0
  63. package/docs/SPEC.md +339 -0
  64. package/docs/VERIFICATION.md +28 -0
  65. package/docs/ci/generic-ci.sh +28 -0
  66. package/docs/ci/github-actions.yml +35 -0
  67. package/evals/harness.mjs +426 -0
  68. package/evals/scenarios/fixture-a-01-change-api.json +9 -0
  69. package/evals/scenarios/fixture-a-02-change-db.json +9 -0
  70. package/evals/scenarios/fixture-a-03-enforce-constraint.json +9 -0
  71. package/evals/scenarios/fixture-b-01-change-proto.json +9 -0
  72. package/evals/scenarios/fixture-b-02-change-write-path.json +9 -0
  73. package/evals/scenarios/fixture-b-03-change-shared-lib.json +9 -0
  74. package/evals/scenarios/fixture-b-04-journey.json +9 -0
  75. package/evals/scenarios/fixture-c-01-mixed-runtime-edge.json +19 -0
  76. package/evals/scenarios/fixture-c-02-stale-doc-contradiction.json +9 -0
  77. package/evals/scenarios/fixture-c-03-shared-vocabulary.json +9 -0
  78. package/evals/scenarios/fixture-c-04-contract-change.json +9 -0
  79. package/evals/scenarios/koda-01-modify-api-consumer.json +26 -0
  80. package/evals/scenarios/koda-02-change-db-schema.json +20 -0
  81. package/evals/scenarios/koda-03-change-auth.json +21 -0
  82. package/evals/scenarios/koda-04-add-event-producer.json +19 -0
  83. package/evals/scenarios/koda-05-modify-worker-binding.json +25 -0
  84. package/evals/scenarios/koda-06-change-cross-component-endpoint.json +22 -0
  85. package/evals/scenarios/koda-07-change-shared-package.json +16 -0
  86. package/evals/scenarios/koda-08-change-runtime-cron.json +23 -0
  87. package/evals/scenarios/koda-09-debug-production-mismatch.json +26 -0
  88. package/evals/thresholds.json +11 -0
  89. package/package.json +46 -4
  90. package/schemas/api.schema.json +84 -0
  91. package/schemas/common.schema.json +107 -0
  92. package/schemas/component.schema.json +40 -0
  93. package/schemas/config.schema.json +685 -0
  94. package/schemas/domain.schema.json +14 -0
  95. package/schemas/graph.schema.json +1084 -0
  96. package/schemas/journeys.schema.json +47 -0
  97. package/schemas/observation.schema.json +143 -0
  98. package/schemas/resource.schema.json +28 -0
  99. package/schemas/system.schema.json +26 -0
  100. package/skills/agent-doc-system/SKILL.md +113 -0
  101. package/skills/agent-doc-system/references/authority-and-conflicts.md +70 -0
  102. package/skills/agent-doc-system/references/cli.md +89 -0
  103. package/skills/agent-doc-system/references/create-migrate.md +101 -0
  104. package/skills/agent-doc-system/references/maintenance.md +52 -0
  105. package/templates/ADR.md +26 -0
  106. package/templates/ARCHITECTURE.md +19 -0
  107. package/templates/CONSTRAINTS.md +17 -0
  108. package/templates/PRODUCT.md +17 -0
  109. package/templates/api.yaml +10 -0
  110. package/templates/ci.yml +31 -0
  111. package/templates/component.yaml +14 -0
  112. package/templates/domain.yaml +6 -0
  113. package/templates/journey.yaml +7 -0
  114. package/templates/observation.yaml +19 -0
  115. package/templates/resource.yaml +8 -0
  116. package/templates/system.yaml +10 -0
  117. 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 };
@@ -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
+ }
@@ -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
+ }