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/query.mjs ADDED
@@ -0,0 +1,384 @@
1
+ // Context router.
2
+ //
3
+ // The job is routing, not documentation concatenation. Given a source path or
4
+ // an entity ref, return the smallest set of facts an agent needs to work
5
+ // safely on that subject — and refuse to answer from a stale graph.
6
+ //
7
+ // Everything returned is budgeted: relations, interfaces, resources, journeys
8
+ // and verification entries are all capped, with the overflow reported rather
9
+ // than silently dropped, so an agent can see that it is seeing a slice.
10
+ import { AgentDocError, CODES } from "./codes.mjs";
11
+ import { assertNoSecretsInText } from "./secrets.mjs";
12
+
13
+ const DEFAULT_BUDGET = {
14
+ relations: 40,
15
+ events: 20,
16
+ externals: 15,
17
+ capabilities: 10,
18
+ verification: 12,
19
+ journeys: 10,
20
+ conflicts: 20,
21
+ assertions: 25,
22
+ provenance: 20,
23
+ contracts: 10,
24
+ };
25
+
26
+ export class ContextIndex {
27
+ constructor(graph) {
28
+ this.graph = graph;
29
+ this.byRef = new Map(graph.entities.map((e) => [e.ref, e]));
30
+ this.byPath = new Map();
31
+ for (const e of graph.entities) {
32
+ const sp = e.derived && e.derived.sourcePath;
33
+ if (!sp) continue;
34
+ this.byPath.set(sp, e);
35
+ // Longest path first so a nested path resolves to its own component
36
+ // rather than to a parent.
37
+ }
38
+ // Documentation and constraint files a descriptor links resolve to their
39
+ // component, even when they live outside the source root. Following a
40
+ // stale document to the code it describes is exactly the migration case.
41
+ this.byDoc = new Map();
42
+ for (const e of graph.entities) {
43
+ const ctxPaths = [
44
+ ...(((e.entity.spec || {}).context || {}).docs || []),
45
+ ...(((e.entity.spec || {}).context || {}).constraints || []),
46
+ ...(((e.entity.spec || {}).context || {}).runbooks || []),
47
+ ...(e.derived || {}).docs || [],
48
+ ...(e.derived || {}).constraints || [],
49
+ ...(e.derived || {}).runbooks || [],
50
+ ];
51
+ for (const p of ctxPaths) if (!this.byDoc.has(p)) this.byDoc.set(p, e);
52
+ }
53
+ this.paths = [...this.byPath.keys()].sort((a, b) => b.length - a.length);
54
+ this.relationsByRef = new Map();
55
+ for (const r of graph.relations) {
56
+ if (!this.relationsByRef.has(r.sourceRef)) this.relationsByRef.set(r.sourceRef, []);
57
+ if (!this.relationsByRef.has(r.targetRef)) this.relationsByRef.set(r.targetRef, []);
58
+ this.relationsByRef.get(r.sourceRef).push(r);
59
+ this.relationsByRef.get(r.targetRef).push(r);
60
+ }
61
+ }
62
+
63
+ // A contract file resolves to the API entity that claims it, so "change this
64
+ // endpoint" routes to the boundary rather than to nothing.
65
+ contractFor(needle) {
66
+ const norm = needle.replace(/^\.\//, "").replace(/\/+$/, "");
67
+ let best = null;
68
+ for (const c of (this.graph.contracts || [])) {
69
+ if (norm === c.ref || norm.startsWith(c.ref)) {
70
+ if (!best || c.ref.length > best.ref.length) best = c;
71
+ }
72
+ }
73
+ return best;
74
+ }
75
+ resolve(needle) {
76
+ if (this.byRef.has(needle)) return this.byRef.get(needle);
77
+ const norm = needle.replace(/^\.\//, "").replace(/\/+$/, "");
78
+ if (this.byDoc.has(norm)) return this.byDoc.get(norm);
79
+ if (this.byPath.has(norm)) return this.byPath.get(norm);
80
+ for (const p of this.paths) {
81
+ if (norm === p || norm.startsWith(p + "/")) return this.byPath.get(p);
82
+ }
83
+ for (const p of this.paths) {
84
+ if (needle.startsWith(p + "/")) return this.byPath.get(p);
85
+ }
86
+ if (this.byDoc.has(needle)) return this.byDoc.get(needle);
87
+ const c = this.contractFor(needle);
88
+ return c ? this.byRef.get(c.apiRef) || null : null;
89
+ }
90
+ }
91
+
92
+ function contractsOf(graph) {
93
+ return graph.contracts || [];
94
+ }
95
+
96
+ export function queryContext(graph, needle, opts = {}) {
97
+ const budget = { ...DEFAULT_BUDGET, ...(opts.budget || {}) };
98
+ const idx = new ContextIndex(graph);
99
+ const entity = idx.resolve(needle);
100
+ if (!entity) {
101
+ throw new AgentDocError(
102
+ CODES.REF_UNRESOLVED,
103
+ "no entity resolves for '" + needle + "' — run `agentdoc audit` to see what the catalog knows about this repository"
104
+ );
105
+ }
106
+ const ref = entity.ref;
107
+ const g = graph;
108
+ const take = (arr, n) => ({ items: arr.slice(0, n), total: arr.length, truncated: arr.length > n });
109
+
110
+ const relations = idx.relationsByRef.get(ref) || [];
111
+ const neighbourRefs = new Set();
112
+ for (const r of relations) neighbourRefs.add(r.targetRef);
113
+
114
+ const placement = relations
115
+ .filter((r) => (r.type === "partOf" || r.type === "hasPart") && (r.sourceRef === ref || r.targetRef === ref))
116
+ .map((r) => (r.sourceRef === ref ? r.targetRef : r.sourceRef));
117
+ const systems = [...new Set(placement.filter((p) => p.startsWith("system:")))];
118
+ const domains = [...new Set(placement.filter((p) => p.startsWith("domain:")))];
119
+
120
+ // For a component these are the APIs it serves; for an API entity the subject
121
+ // *is* the provided surface.
122
+ const providedApis = entity.kind === "API"
123
+ ? [ref]
124
+ : g.relations.filter((r) => r.type === "providesApi" && r.sourceRef === ref).map((r) => r.targetRef);
125
+ const consumedApis = g.relations.filter((r) => r.type === "consumesApi" && r.sourceRef === ref).map((r) => r.targetRef);
126
+ // When the subject IS an API, the interesting question is who consumes it.
127
+ const apiConsumers = entity.kind === "API"
128
+ ? g.relations.filter((r) => r.type === "consumesApi" && r.targetRef === ref).map((r) => r.sourceRef)
129
+ : [];
130
+
131
+ const providerOf = (apiRef) => {
132
+ const r = g.relations.find((x) => x.type === "apiProvidedBy" && x.sourceRef === apiRef);
133
+ return r ? r.targetRef : null;
134
+ };
135
+ const apiProvider = entity.kind === "API" ? providerOf(ref) : null;
136
+
137
+ const apiRefs = new Set([...providedApis, ...consumedApis]);
138
+ const srcPath = (entity.derived && entity.derived.sourcePath) || null;
139
+ const events = g.interfaces.events.filter(
140
+ (e) =>
141
+ e.producers.includes(ref) ||
142
+ e.consumers.includes(ref) ||
143
+ // A contract library that ships the subject catalog owns those subjects,
144
+ // even when another component is the one that subscribes to them.
145
+ (srcPath && e.contract && e.contract.ref.startsWith(srcPath + "/"))
146
+ );
147
+ const externals = g.externalDependencies.filter((e) => e.componentRef === ref);
148
+ const capabilities = g.capabilities.filter((e) => e.componentRef === ref);
149
+ // For a contract subject, the checks that matter are the provider's and the
150
+ // consumers': changing an endpoint is proved by running their suites.
151
+ const verificationScope = entity.kind === "API"
152
+ ? [ref, ...apiConsumers, ...(apiProvider ? [apiProvider] : [])]
153
+ : [ref];
154
+ const verification = g.verification.filter((e) => e.componentRefs.some((r) => verificationScope.includes(r)));
155
+ const journeys = g.journeys.filter((j) => j.components.includes(ref));
156
+ const contracts = g.contracts.filter((c) => c.apiRef && apiRefs.has(c.apiRef));
157
+ const conflicts = g.conflicts.filter((c) => c.subject === ref);
158
+ const assertions = g.assertions.filter((a) => a.subject === ref);
159
+ const resources = g.relations
160
+ .filter((r) => r.type === "usesResource" && r.sourceRef === ref)
161
+ .map((r) => r.targetRef);
162
+ const affectedByResources = g.relations
163
+ .filter((r) => r.type === "usesResource" && r.targetRef === ref)
164
+ .map((r) => r.sourceRef);
165
+
166
+ const provIds = new Set();
167
+ const collect = (obj) => {
168
+ for (const id of obj.provenanceIds || []) provIds.add(id);
169
+ };
170
+ collect(entity);
171
+ relations.forEach(collect);
172
+ events.forEach(collect);
173
+ externals.forEach(collect);
174
+ verification.forEach(collect);
175
+ journeys.forEach(collect);
176
+ contracts.forEach(collect);
177
+ conflicts.forEach(collect);
178
+ assertions.forEach(collect);
179
+ const provenance = g.provenance.filter((p) => provIds.has(p.id));
180
+
181
+ const journeyPeers = new Set();
182
+ for (const j of journeys) for (const c of j.components) if (c !== ref) journeyPeers.add(c);
183
+
184
+ const neighbourSummaries = [...neighbourRefs]
185
+ .filter((r) => r !== ref)
186
+ .sort()
187
+ .map((r) => {
188
+ const e = idx.byRef.get(r);
189
+ return e ? { ref: r, kind: e.kind, name: e.name, description: e.entity.metadata.description } : { ref: r };
190
+ });
191
+
192
+ const out = {
193
+ query: { needle, resolved: ref, namespace: ref.split("/")[0].split(":")[1] },
194
+ entity: {
195
+ ref,
196
+ kind: entity.kind,
197
+ name: entity.name,
198
+ description: entity.entity.metadata.description,
199
+ spec: entity.entity.spec || {},
200
+ sourcePaths: entity.sourcePaths,
201
+ derived: entity.derived || {},
202
+ },
203
+ placement: { systems, domains },
204
+ relations: take(
205
+ relations.map((r) => ({
206
+ type: r.type,
207
+ direction: r.sourceRef === ref ? "out" : "in",
208
+ other: r.sourceRef === ref ? r.targetRef : r.sourceRef,
209
+ evidenceClass: r.evidenceClass,
210
+ attributes: Object.keys(r.attributes || {}).length ? r.attributes : undefined,
211
+ provenanceIds: r.provenanceIds,
212
+ })),
213
+ budget.relations
214
+ ),
215
+ neighbours: neighbourSummaries,
216
+ apis: {
217
+ provided: providedApis.map((a) => ({ api: a, contract: contractRef(g, a) })),
218
+ consumed: consumedApis.map((a) => ({ api: a, provider: providerOf(a), contract: contractRef(g, a) })),
219
+ provider: apiProvider,
220
+ consumers: [...new Set(apiConsumers)].sort(),
221
+ contract: entity.kind === "API" ? contractRef(g, ref) : null,
222
+ },
223
+ events: take(
224
+ events.map((e) => ({ pattern: e.pattern, producers: e.producers, consumers: e.consumers, resolution: e.resolution, contract: e.contract })),
225
+ budget.events
226
+ ),
227
+ resources: { used: resources, usedBy: affectedByResources },
228
+ externalDependencies: take(
229
+ externals.map((e) => ({ name: e.name, role: e.role, mechanism: e.mechanism, evidenceClass: e.evidenceClass })),
230
+ budget.externals
231
+ ),
232
+ capabilities: take(capabilities.map((c) => ({ name: c.name, evidenceClass: c.evidenceClass })), budget.capabilities),
233
+ constraints: {
234
+ docs: (entity.derived && entity.derived.docs) || [],
235
+ constraints: (entity.derived && entity.derived.constraints) || [],
236
+ runbooks: (entity.derived && entity.derived.runbooks) || [],
237
+ },
238
+ verification: take(
239
+ verification.map((v) => ({ id: v.id, tier: v.tier, command: v.command, configPaths: v.configPaths })),
240
+ budget.verification
241
+ ),
242
+ journeys: take(
243
+ journeys.map((j) => ({ id: j.id, description: j.description, components: j.components })),
244
+ budget.journeys
245
+ ),
246
+ // A journey is a claim that these components act together. Touching one of
247
+ // them means the others are in scope, and naming them is cheaper for the
248
+ // agent than rediscovering the coupling.
249
+ journeyPeers: [...journeyPeers].sort(),
250
+ contracts: take(contracts, budget.contracts),
251
+ authority: {
252
+ conflicts: take(
253
+ conflicts.map((c) => ({
254
+ id: c.id, key: c.key, kind: c.kind, status: c.status,
255
+ detail: c.detail, election: c.election,
256
+ assertions: c.assertionIds.map((id) => {
257
+ const a = g.assertions.find((x) => x.id === id);
258
+ return a ? { id: a.id, evidenceClass: a.evidenceClass, confidence: a.confidence, value: a.value, status: a.status, semantics: a.semantics, observed: a.observed } : { id };
259
+ }),
260
+ })),
261
+ budget.conflicts
262
+ ),
263
+ assertions: take(
264
+ assertions.map((a) => ({
265
+ id: a.id, key: a.key, value: a.value, evidenceClass: a.evidenceClass,
266
+ confidence: a.confidence, status: a.status, semantics: a.semantics,
267
+ })),
268
+ budget.assertions
269
+ ),
270
+ observations: g.observations
271
+ .filter((o) => o.facts.some((f) => f.subject === ref))
272
+ .map((o) => ({
273
+ id: o.id, environment: o.environment, sourceSystem: o.sourceSystem,
274
+ collector: o.collector, capturedAt: o.capturedAt, maxAgeDays: o.maxAgeDays,
275
+ evidenceBundle: o.evidenceBundle,
276
+ facts: o.facts.filter((f) => f.subject === ref),
277
+ })),
278
+ },
279
+ provenance: take(provenance.map((p) => ({ id: p.id, class: p.class, path: p.path, extractor: p.extractor.name, contentHash: p.contentHash })), budget.provenance),
280
+ globalGates: g.gates.map((x) => ({ id: x.id, tier: x.tier, command: x.command })),
281
+ // Everything a reviewer committed to re-examining, in one place. A review
282
+ // condition nobody can see is a review condition nobody performs.
283
+ reviewCommitments: [
284
+ ...g.conflicts
285
+ .filter((c) => c.status !== "unresolved")
286
+ .map((c) => ({
287
+ kind: c.kind, subject: c.subject, key: c.key, status: c.status,
288
+ basis: c.election.basis, ruleId: c.election.ruleId,
289
+ // The governing authority rule's own re-examination condition. When
290
+ // there is no rule the fact stayed unresolved, and `null` says exactly
291
+ // that — it is not a missing value to be papered over with a pointer
292
+ // to a record that was never written.
293
+ reviewWhen: c.election.reviewWhen ?? null,
294
+ })),
295
+ ...g.diagnostics
296
+ .filter((d) => d.acceptance)
297
+ .map((d) => ({
298
+ kind: d.code, subject: d.subject || null, status: "accepted",
299
+ classification: d.acceptance.classification,
300
+ reason: d.acceptance.reason, reviewWhen: d.acceptance.reviewWhen,
301
+ })),
302
+ ...g.assertions
303
+ .filter((a) => a.review)
304
+ .map((a) => ({
305
+ kind: "reviewed-override", subject: a.subject, key: a.key, status: a.status,
306
+ reason: a.review.reason, reviewWhen: a.review.reviewWhen,
307
+ })),
308
+ ],
309
+ };
310
+ // Truncation is reported for every capped section, including the nested ones
311
+ // inside `authority`. A silently shortened conflict list is the most damaging
312
+ // cut this command could make.
313
+ out.truncated = [];
314
+ for (const [k, v] of Object.entries(out)) {
315
+ if (v && typeof v === "object" && v.truncated) out.truncated.push(k);
316
+ if (k === "authority" && v) {
317
+ for (const [ak, av] of Object.entries(v)) {
318
+ if (av && typeof av === "object" && av.truncated) out.truncated.push("authority." + ak);
319
+ }
320
+ }
321
+ }
322
+ assertNoSecretsInText(JSON.stringify(out), "query result");
323
+ return out;
324
+ }
325
+
326
+ function contractRef(graph, apiRef) {
327
+ const e = graph.entities.find((x) => x.ref === apiRef);
328
+ return e ? e.entity.spec.contract : null;
329
+ }
330
+
331
+ // Compact markdown rendering for a prompt or a human. Same routing, less tokens.
332
+ export function renderContext(ctx) {
333
+ const L = [];
334
+ const e = ctx.entity;
335
+ L.push("# " + e.ref + " (" + e.kind + ")");
336
+ L.push(e.description);
337
+ if (e.derived.sourcePath) L.push("- source: " + e.derived.sourcePath);
338
+ if (e.derived.runtimes?.length) L.push("- runtimes: " + e.derived.runtimes.join(", "));
339
+ if (e.derived.languages?.length) L.push("- languages: " + e.derived.languages.join(", "));
340
+ if (ctx.placement.systems.length) L.push("- system: " + ctx.placement.systems.join(", "));
341
+ if (ctx.placement.domains.length) L.push("- domain: " + ctx.placement.domains.join(", "));
342
+ if (ctx.apis.provided.length) L.push("- provides: " + ctx.apis.provided.map((a) => a.api).join(", "));
343
+ if (ctx.apis.consumed.length) L.push("- consumes: " + ctx.apis.consumed.map((a) => a.api + " (via " + a.provider + ")").join(", "));
344
+ if (ctx.resources.used.length) L.push("- uses resource: " + ctx.resources.used.join(", "));
345
+ if (ctx.events.items.length) L.push("- events: " + ctx.events.items.map((x) => x.pattern + " [" + x.resolution + "]").join(", "));
346
+ if (ctx.externalDependencies.items.length) L.push("- external: " + ctx.externalDependencies.items.map((x) => x.name + " (" + x.role + ")").join(", "));
347
+ if (ctx.capabilities.items.length) L.push("- capabilities: " + ctx.capabilities.items.map((x) => x.name).join(", "));
348
+ if (ctx.constraints.constraints.length) L.push("- CONSTRAINTS: " + ctx.constraints.constraints.join(", "));
349
+ if (ctx.constraints.docs.length) L.push("- docs: " + ctx.constraints.docs.join(", "));
350
+ if (ctx.constraints.runbooks.length) L.push("- runbooks: " + ctx.constraints.runbooks.join(", "));
351
+ if (ctx.verification.items.length) {
352
+ L.push("- verify:");
353
+ for (const v of ctx.verification.items) L.push(" - [" + v.tier + "] " + v.command);
354
+ }
355
+ if (ctx.journeys.items.length) L.push("- journeys: " + ctx.journeys.items.map((j) => j.id).join(", "));
356
+ if (ctx.authority.conflicts.items.length) {
357
+ L.push("- CONFLICTS (do not assume one is true):");
358
+ for (const c of ctx.authority.conflicts.items) {
359
+ L.push(" - " + c.key + " [" + c.kind + "/" + c.status + "] " + (c.detail || ""));
360
+ for (const a of c.assertions) L.push(" - " + a.evidenceClass + "/" + a.confidence + ": " + JSON.stringify(a.value));
361
+ }
362
+ }
363
+ if (ctx.reviewCommitments.length) {
364
+ L.push("- REVIEW COMMITMENTS (re-check when the condition is met):");
365
+ for (const r of ctx.reviewCommitments) {
366
+ // An absent condition is stated as absent. Printing a pointer to a record
367
+ // that does not exist is worse than printing nothing: it looks actionable
368
+ // and is not.
369
+ const when = r.reviewWhen
370
+ ? r.reviewWhen
371
+ : "NO REVIEW CONDITION RECORDED — " + r.kind + " " + (r.subject || "") + " " + (r.key || "") +
372
+ " is settled with none; re-examine it now";
373
+ L.push(" - " + (r.subject || "repository") + " " + (r.key || r.kind) + ": " + when);
374
+ }
375
+ }
376
+ const obs = ctx.authority.observations;
377
+ if (obs.length) {
378
+ L.push("- RUNTIME OBSERVATIONS:");
379
+ for (const o of obs) L.push(" - " + o.environment + " via " + o.sourceSystem + " at " + o.capturedAt + " (evidence " + o.evidenceBundle + ")");
380
+ }
381
+ const neighbours = ctx.neighbours.filter((n) => n.ref !== e.ref);
382
+ if (neighbours.length) L.push("- neighbours: " + neighbours.map((n) => n.ref).join(", "));
383
+ return L.join("\n");
384
+ }
@@ -0,0 +1,286 @@
1
+ // Relation materialisation.
2
+ //
3
+ // No relation is ever authored. Placement comes from authored placement fields,
4
+ // provider edges from the API descriptor, everything else from deterministic
5
+ // extractors or bounded reviewed overrides. Inversions are generated, the kind
6
+ // pair is validated, and duplicates merge their provenance instead of forking
7
+ // the graph.
8
+ import { CODES, AgentDocError } from "./codes.mjs";
9
+ import { PROV_TO_EVIDENCE } from "./authority/classes.mjs";
10
+
11
+ export const INVERSE = Object.freeze({
12
+ partOf: "hasPart",
13
+ hasPart: "partOf",
14
+ providesApi: "apiProvidedBy",
15
+ apiProvidedBy: "providesApi",
16
+ consumesApi: "apiConsumedBy",
17
+ apiConsumedBy: "consumesApi",
18
+ buildDependsOn: "buildDependencyOf",
19
+ buildDependencyOf: "buildDependsOn",
20
+ testDependsOn: "testDependencyOf",
21
+ testDependencyOf: "testDependsOn",
22
+ runtimeCalls: "runtimeCalledBy",
23
+ runtimeCalledBy: "runtimeCalls",
24
+ usesResource: "resourceUsedBy",
25
+ resourceUsedBy: "usesResource",
26
+ schedules: "scheduledBy",
27
+ scheduledBy: "schedules",
28
+ });
29
+
30
+ export const KIND_PAIRS = Object.freeze({
31
+ partOf: { Component: ["System", "Domain"], System: ["Domain"], Resource: ["System"] },
32
+ hasPart: { System: ["Component", "Resource"], Domain: ["Component", "System"] },
33
+ providesApi: { Component: ["API"] },
34
+ apiProvidedBy: { API: ["Component"] },
35
+ consumesApi: { Component: ["API"] },
36
+ apiConsumedBy: { API: ["Component"] },
37
+ buildDependsOn: { Component: ["Component"] },
38
+ buildDependencyOf: { Component: ["Component"] },
39
+ testDependsOn: { Component: ["Component"] },
40
+ testDependencyOf: { Component: ["Component"] },
41
+ runtimeCalls: { Component: ["Component"] },
42
+ runtimeCalledBy: { Component: ["Component"] },
43
+ usesResource: { Component: ["Resource"] },
44
+ resourceUsedBy: { Resource: ["Component"] },
45
+ schedules: { Component: ["Component"] },
46
+ scheduledBy: { Component: ["Component"] },
47
+ });
48
+
49
+ // A closed whitelist of attribute keys per relation type. An unknown key is a
50
+ // compiler error, so the graph cannot accumulate unvalidated payload — and a
51
+ // new relation type cannot silently start carrying arbitrary data.
52
+ export const ATTRIBUTE_KEYS = Object.freeze({
53
+ partOf: [], hasPart: [],
54
+ providesApi: [], apiProvidedBy: [],
55
+ consumesApi: ["via"], apiConsumedBy: ["via"],
56
+ buildDependsOn: ["via", "package"], buildDependencyOf: ["via", "package"],
57
+ testDependsOn: ["via", "package"], testDependencyOf: ["via", "package"],
58
+ runtimeCalls: ["transport", "contractRef", "deliverySemantics"],
59
+ runtimeCalledBy: ["transport", "contractRef", "deliverySemantics"],
60
+ usesResource: ["via", "client"], resourceUsedBy: ["via", "client"],
61
+ schedules: ["via", "route"], scheduledBy: ["via", "route"],
62
+ });
63
+
64
+ // Attributes that record *how* an edge was found, not what the edge is.
65
+ // `via` is the derivation route ("npm-manifest", "source-import",
66
+ // "reviewed-override"). It must not take part in a relation's identity: if it
67
+ // did, the single fact "accounts consumes the OCR admin API" would appear once
68
+ // per adapter that noticed it, and a repository's edge count would measure
69
+ // adapter overlap rather than architecture. `package` and `route` are the
70
+ // opposite case — they name the specific instance, so two of them are two real
71
+ // edges and are kept apart deliberately.
72
+ const DERIVATION_ATTRS = new Set(["via"]);
73
+
74
+ // The identity of a relation: what it asserts, ignoring how it was found.
75
+ export function relationIdentity(e) {
76
+ const attrs = {};
77
+ for (const k of Object.keys(e.attributes || {}).sort()) {
78
+ if (!DERIVATION_ATTRS.has(k)) attrs[k] = e.attributes[k];
79
+ }
80
+ return e.type + "|" + e.sourceRef + "|" + e.targetRef + "|" + canonicalAttrs(attrs);
81
+ }
82
+
83
+ export function canonicalAttrs(attrs) {
84
+ return Object.keys(attrs || {}).sort().map((k) => k + "=" + JSON.stringify(attrs[k])).join("|");
85
+ }
86
+
87
+ export function buildRelations(ctx) {
88
+ const { entities, byRef } = ctx.sources;
89
+ const { refs } = ctx;
90
+ const errors = [];
91
+ const forward = [];
92
+
93
+ const add = (type, sourceRef, targetRef, attributes, provRecs, evidenceClass) => {
94
+ forward.push({
95
+ type,
96
+ sourceRef,
97
+ targetRef,
98
+ attributes: attributes || {},
99
+ provs: new Set(provRecs.filter(Boolean)),
100
+ evidenceClass: evidenceClass || evidenceOf(provRecs),
101
+ });
102
+ };
103
+
104
+ // placement
105
+ for (const e of entities) {
106
+ const spec = e.doc.spec || {};
107
+ const p = () => ctx.prov.add("declared", e.file, "descriptor-reader", ["rel:pending"]);
108
+ if (e.kind === "Component") {
109
+ if (spec.system) add("partOf", e.ref, refs.refFor("System", spec.system), {}, [p()], "AUTHORED");
110
+ else if (spec.domain) add("partOf", e.ref, refs.refFor("Domain", spec.domain), {}, [p()], "AUTHORED");
111
+ } else if (e.kind === "System" && spec.domain) {
112
+ add("partOf", e.ref, refs.refFor("Domain", spec.domain), {}, [p()], "AUTHORED");
113
+ } else if (e.kind === "Resource" && spec.system) {
114
+ add("partOf", e.ref, refs.refFor("System", spec.system), {}, [p()], "AUTHORED");
115
+ }
116
+ }
117
+
118
+ // provider edges
119
+ for (const e of entities.filter((x) => x.kind === "API")) {
120
+ add("providesApi", e.doc.spec.provider, e.ref, {}, [ctx.prov.add("declared", e.file, "api-contract-extractor", ["rel:pending"])], "AUTHORED");
121
+ }
122
+
123
+ // extractor edges
124
+ for (const e of ctx.edges) add(e.type, e.sourceRef, e.targetRef, e.attributes, [...e.provs], e.evidenceClass);
125
+
126
+ // reviewed overrides
127
+ for (const e of ctx.overrideEdges) add(e.type, e.sourceRef, e.targetRef, e.attributes, [...e.provs], "REVIEWED_OVERRIDE");
128
+
129
+ // An API contract owns the boundary: a runtime call that duplicates the
130
+ // canonical contract is a modelling error, not a second fact.
131
+ const apiProviderOf = new Map();
132
+ const contractOf = new Map();
133
+ for (const e of entities.filter((x) => x.kind === "API")) {
134
+ apiProviderOf.set(e.ref, e.doc.spec.provider);
135
+ if (e.doc.spec.contract?.ref) contractOf.set(e.ref, e.doc.spec.contract.ref);
136
+ }
137
+ const consumed = new Map();
138
+ for (const e of forward) {
139
+ if (e.type !== "consumesApi") continue;
140
+ if (!consumed.has(e.sourceRef)) consumed.set(e.sourceRef, new Set());
141
+ consumed.get(e.sourceRef).add(e.targetRef);
142
+ }
143
+ const filtered = forward.filter((e) => {
144
+ if (e.type !== "runtimeCalls") return true;
145
+ for (const apiRef of consumed.get(e.sourceRef) || []) {
146
+ if (apiProviderOf.get(apiRef) !== e.targetRef) continue;
147
+ if (contractOf.get(apiRef) && contractOf.get(apiRef) === e.attributes?.contractRef) {
148
+ errors.push(new AgentDocError(
149
+ CODES.RELATION_CONTRACT_DUP,
150
+ "runtime call " + e.sourceRef + " -> " + e.targetRef + " duplicates canonical API contract " + contractOf.get(apiRef),
151
+ { ref: e.sourceRef }
152
+ ));
153
+ return false;
154
+ }
155
+ }
156
+ return true;
157
+ });
158
+
159
+ const all = [];
160
+ for (const e of filtered) {
161
+ all.push(e);
162
+ const inv = INVERSE[e.type];
163
+ if (!inv) {
164
+ errors.push(new AgentDocError(CODES.RELATION_KIND, "unknown relation type '" + e.type + "'", { ref: e.sourceRef }));
165
+ continue;
166
+ }
167
+ all.push({ type: inv, sourceRef: e.targetRef, targetRef: e.sourceRef, attributes: e.attributes, provs: e.provs, evidenceClass: e.evidenceClass });
168
+ }
169
+
170
+ const kindOf = (ref) => refs.kindOfRef(ref);
171
+ for (const e of all) {
172
+ const sk = kindOf(e.sourceRef);
173
+ const tk = kindOf(e.targetRef);
174
+ if (!sk || !tk || !byRef.has(e.sourceRef) || !byRef.has(e.targetRef)) {
175
+ errors.push(new AgentDocError(
176
+ CODES.RELATION_UNRESOLVED,
177
+ "relation endpoint does not resolve to a real entity: " + e.sourceRef + " -> " + e.targetRef
178
+ ));
179
+ continue;
180
+ }
181
+ const allowed = KIND_PAIRS[e.type]?.[sk] || [];
182
+ if (!allowed.includes(tk)) {
183
+ errors.push(new AgentDocError(CODES.RELATION_KIND, "invalid kind pair for " + e.type + ": " + sk + " -> " + tk, { ref: e.sourceRef }));
184
+ }
185
+ const allowedAttrs = ATTRIBUTE_KEYS[e.type];
186
+ if (!allowedAttrs) {
187
+ errors.push(new AgentDocError(CODES.RELATION_KIND, "unknown relation type '" + e.type + "'", { ref: e.sourceRef }));
188
+ } else {
189
+ const attrKeys = Object.keys(e.attributes || {});
190
+ for (const k of attrKeys) {
191
+ if (!allowedAttrs.includes(k)) {
192
+ errors.push(new AgentDocError(
193
+ CODES.RELATION_KIND,
194
+ "relation " + e.type + " does not allow attribute '" + k + "' (allowed: " + allowedAttrs.join(", ") + ")",
195
+ { ref: e.sourceRef }
196
+ ));
197
+ }
198
+ }
199
+ if (e.type === "runtimeCalls" || e.type === "runtimeCalledBy") {
200
+ const a = e.attributes || {};
201
+ if (!["http", "service-binding", "signed-http", "grpc", "queue", "in-process"].includes(a.transport)) {
202
+ errors.push(new AgentDocError(CODES.RELATION_KIND, e.type + " requires a known transport", { ref: e.sourceRef }));
203
+ }
204
+ if (!a.contractRef) {
205
+ errors.push(new AgentDocError(CODES.RELATION_KIND, e.type + " requires contractRef", { ref: e.sourceRef }));
206
+ }
207
+ }
208
+ }
209
+ }
210
+
211
+ const seen = new Map();
212
+ const out = [];
213
+ const vias = new Map();
214
+ for (const e of all) {
215
+ const key = relationIdentity(e);
216
+ if (seen.has(key)) {
217
+ const prev = seen.get(key);
218
+ for (const p of e.provs) prev.provs.add(p);
219
+ // A corroborated edge keeps the label its evidence supports, recomputed
220
+ // from the merged provenance rather than promoted by a fixed order.
221
+ prev.evidenceClass = evidenceOf([...prev.provs]);
222
+ // Every derivation route that found it, so merging never discards the
223
+ // fact that a second, independent adapter agreed.
224
+ const v = vias.get(key);
225
+ if (v && e.attributes && e.attributes.via) v.add(e.attributes.via);
226
+ continue;
227
+ }
228
+ const canon = {};
229
+ for (const k of Object.keys(e.attributes || {}).sort()) canon[k] = e.attributes[k];
230
+ e.attributes = canon;
231
+ if (canon.via !== undefined) {
232
+ vias.set(key, new Set([canon.via]));
233
+ delete e.attributes.via;
234
+ }
235
+ seen.set(key, e);
236
+ out.push(e);
237
+ }
238
+ // Re-attach the sorted derivation routes.
239
+ for (const e of out) {
240
+ const key = relationIdentity(e);
241
+ const v = vias.get(key);
242
+ if (v) {
243
+ const canon = {};
244
+ canon.via = [...v].sort();
245
+ for (const k of Object.keys(e.attributes).sort()) canon[k] = e.attributes[k];
246
+ e.attributes = canon;
247
+ }
248
+ }
249
+ out.sort((a, b) => {
250
+ const ka = relationIdentity(a);
251
+ const kb = relationIdentity(b);
252
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
253
+ });
254
+
255
+ const keys = new Set(out.map(relationIdentity));
256
+ for (const e of out) {
257
+ const invKey = relationIdentity({ type: INVERSE[e.type], sourceRef: e.targetRef, targetRef: e.sourceRef, attributes: e.attributes });
258
+ if (!keys.has(invKey)) {
259
+ errors.push(new AgentDocError(CODES.RELATION_MISSING_INVERSE, "missing inverse for " + e.type + " " + e.sourceRef + " -> " + e.targetRef));
260
+ }
261
+ }
262
+ return { relations: out, errors };
263
+ }
264
+
265
+ // The label on a relation records which classes back it.
266
+ //
267
+ // It does NOT pick a winner by strength. A precedence list here is a global
268
+ // precedence order hiding in a helper — the exact thing the authority model
269
+ // forbids — and it was actively wrong: a reviewed override corroborated by a
270
+ // source reference was relabelled AUTHORED, so the same fact read as a weaker,
271
+ // purely-authored claim on the relation while its assertion read
272
+ // REVIEWED_OVERRIDE. The graph contradicted itself about its own highest-
273
+ // authority input.
274
+ //
275
+ // So: when the evidence agrees there is one class and it is reported. When it
276
+ // disagrees, this elects nothing. REVIEWED_OVERRIDE is reported when a human
277
+ // interpretation is among the classes, because that is not an election between
278
+ // competing claims — it records that someone reviewed this specific edge — and
279
+ // suppressing it would hide the review. The full class set is always recoverable
280
+ // from the provenance records.
281
+ function evidenceOf(provRecs) {
282
+ const classes = new Set((provRecs || []).map((p) => p && p.cls).filter(Boolean).map((c) => PROV_TO_EVIDENCE[c]).filter(Boolean));
283
+ if (classes.size <= 1) return classes.size === 1 ? [...classes][0] : "DERIVED";
284
+ if (classes.has("REVIEWED_OVERRIDE")) return "REVIEWED_OVERRIDE";
285
+ return "DERIVED";
286
+ }