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/compile.mjs
ADDED
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
// The canonical compilation pipeline. One code path for validate, compile,
|
|
2
|
+
// check, query, impact and audit — locally and in CI. No behaviour depends on
|
|
3
|
+
// how it was invoked.
|
|
4
|
+
import { AgentDocError, CODES, collect } from "./codes.mjs";
|
|
5
|
+
import { Repo, computeInputHash, sha256Hex } from "./fsx.mjs";
|
|
6
|
+
import { loadSchemaBundle, loadConfig, loadDescriptors, loadJourneys, loadObservations, loadReviewedOverrides, makeRefs, CONFIG_PATH } from "./descriptors.mjs";
|
|
7
|
+
import { discoverUnits, matchComponents } from "./discover.mjs";
|
|
8
|
+
import { genericFacts, checkArtifactType } from "./derive.mjs";
|
|
9
|
+
import { buildRelations } from "./relations.mjs";
|
|
10
|
+
import { buildIndexes, checkContractCoverage } from "./indexes.mjs";
|
|
11
|
+
import { ProvRegistry } from "./provenance.mjs";
|
|
12
|
+
import { ingestObservations } from "./observations.mjs";
|
|
13
|
+
import { FactStore } from "./facts.mjs";
|
|
14
|
+
import { AuthorityEngine, resolveAssertions } from "./authority/engine.mjs";
|
|
15
|
+
import { PROV_TO_EVIDENCE } from "./authority/classes.mjs";
|
|
16
|
+
import { redactCommand } from "./secrets.mjs";
|
|
17
|
+
import { formatErrors } from "./jsonschema.mjs";
|
|
18
|
+
import { validateContractFile } from "./contracts.mjs";
|
|
19
|
+
import { sourceFiles, importSpecifiers } from "./sourcescan.mjs";
|
|
20
|
+
import { applyWarningAcceptances } from "./acceptances.mjs";
|
|
21
|
+
import { loadAdapters } from "../adapters/index.mjs";
|
|
22
|
+
import {
|
|
23
|
+
COMPILER_NAME, COMPILER_VERSION, GRAPH_SCHEMA_VERSION,
|
|
24
|
+
assembleGraph, serializeGraph, finalizeSerialization, subjectResolver,
|
|
25
|
+
} from "./graph.mjs";
|
|
26
|
+
|
|
27
|
+
export const ADAPTER_VERSION_INPUT_PREFIX = "adapter:";
|
|
28
|
+
|
|
29
|
+
export class CompileResult {
|
|
30
|
+
constructor() {
|
|
31
|
+
this.errors = [];
|
|
32
|
+
this.diagnostics = [];
|
|
33
|
+
this.graph = null;
|
|
34
|
+
this.serialized = null;
|
|
35
|
+
this.stats = {};
|
|
36
|
+
this.ctx = null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function earlyOut(res, extra = {}) {
|
|
41
|
+
res.stats = { ...(res.stats || {}), ...extra };
|
|
42
|
+
return res;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function compile(root, opts = {}) {
|
|
46
|
+
const res = new CompileResult();
|
|
47
|
+
// A peek load, only to read the generated-directory list: a project that keeps
|
|
48
|
+
// build output in a directory this build does not know about must be able to
|
|
49
|
+
// say so.
|
|
50
|
+
let cfgGeneratedDirs = opts.generatedDirs;
|
|
51
|
+
try {
|
|
52
|
+
const peek = loadConfig(new Repo(root, { noGit: true }), loadSchemaBundle().bundle);
|
|
53
|
+
if (Array.isArray(peek.discovery.generatedDirs)) cfgGeneratedDirs = peek.discovery.generatedDirs;
|
|
54
|
+
} catch {
|
|
55
|
+
// No configuration, or an invalid one: the real load below reports it.
|
|
56
|
+
}
|
|
57
|
+
const repo = new Repo(root, { noGit: opts.noGit, generatedDirs: cfgGeneratedDirs });
|
|
58
|
+
let bundle;
|
|
59
|
+
let schemaInputs;
|
|
60
|
+
try {
|
|
61
|
+
({ bundle, versionInputs: schemaInputs } = loadSchemaBundle());
|
|
62
|
+
} catch (e) {
|
|
63
|
+
if (e instanceof AgentDocError) { res.errors.push(e); return earlyOut(res, {}); }
|
|
64
|
+
throw e;
|
|
65
|
+
}
|
|
66
|
+
let cfg;
|
|
67
|
+
try {
|
|
68
|
+
cfg = loadConfig(repo, bundle);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
// A configuration error is reported like any other, so `validate` gives a
|
|
71
|
+
// complete picture instead of dying on the first bad key.
|
|
72
|
+
if (e instanceof AgentDocError) { res.errors.push(e); return earlyOut(res, {}); }
|
|
73
|
+
throw e;
|
|
74
|
+
}
|
|
75
|
+
// Exposed even when the compile later fails: `scaffold` needs the
|
|
76
|
+
// configuration and repo handle precisely in the not-yet-compiling state —
|
|
77
|
+
// a coverage failure is what scaffolding exists to fix.
|
|
78
|
+
res.repo = repo;
|
|
79
|
+
res.cfg = cfg;
|
|
80
|
+
const refs = makeRefs(cfg.namespace);
|
|
81
|
+
const graphPath = cfg.output.graph;
|
|
82
|
+
|
|
83
|
+
const sources = loadDescriptors(repo, cfg, bundle, refs);
|
|
84
|
+
res.errors.push(...sources.errors);
|
|
85
|
+
|
|
86
|
+
const journeys = loadJourneys(repo, cfg, bundle, sources.byRef);
|
|
87
|
+
res.errors.push(...journeys.errors);
|
|
88
|
+
|
|
89
|
+
const observations = loadObservations(repo, cfg, bundle, sources.byRef);
|
|
90
|
+
res.errors.push(...observations.errors);
|
|
91
|
+
|
|
92
|
+
const overrides = loadReviewedOverrides(repo, cfg, refs, sources.byRef);
|
|
93
|
+
res.errors.push(...overrides.errors);
|
|
94
|
+
|
|
95
|
+
// The review record attached to every REVIEWED_OVERRIDE fact. The reviewer and
|
|
96
|
+
// the date are deliberately not typed in: they are the commit that changed the
|
|
97
|
+
// configuration carrying this record, which is stronger evidence than a string.
|
|
98
|
+
//
|
|
99
|
+
// `reviewWhen` is authorable and required, not defaulted. A fixed string here
|
|
100
|
+
// would be the same sentence on every override, which is a review condition
|
|
101
|
+
// that conditions nothing — and it would be reported as though it were real.
|
|
102
|
+
//
|
|
103
|
+
// The evidence digests are re-verified on every compile. A REVIEWED_OVERRIDE is
|
|
104
|
+
// the highest-authority input the model accepts, and the only thing that can
|
|
105
|
+
// falsify it is the evidence it cites. If that evidence is rewritten or deleted
|
|
106
|
+
// and the override survives, the graph ships a digest that no longer matches any
|
|
107
|
+
// file, and a stale human judgement is silently promoted to fact. This mirrors
|
|
108
|
+
// what `acceptances.mjs` already does for accepted warnings.
|
|
109
|
+
// The digests were verified against the pinned values in
|
|
110
|
+
// `loadReviewedOverrides`, so the record carried into the graph is the
|
|
111
|
+
// author's, already known to be current.
|
|
112
|
+
const reviewed = (o) => ({
|
|
113
|
+
reason: o.reason,
|
|
114
|
+
reviewWhen: o.reviewWhen,
|
|
115
|
+
evidence: o.evidence.map((ev) => ({ path: ev.path, sha256: ev.sha256 })),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
res.errors.push(...checkContractCoverage({ repo, cfg, sources }));
|
|
119
|
+
|
|
120
|
+
for (const api of sources.entities.filter((e) => e.kind === "API")) {
|
|
121
|
+
collect(() => validateContractFile(repo, bundle, api), res.errors);
|
|
122
|
+
}
|
|
123
|
+
res.errors.push(...validateOidcProviders({ repo, sources, discovery: null }));
|
|
124
|
+
|
|
125
|
+
if (res.errors.length) {
|
|
126
|
+
return earlyOut(res, { entities: sources.entities.length });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let adapters;
|
|
130
|
+
try {
|
|
131
|
+
adapters = loadAdapters(cfg.adapters || []);
|
|
132
|
+
} catch (e) {
|
|
133
|
+
if (e instanceof AgentDocError) {
|
|
134
|
+
res.errors.push(e);
|
|
135
|
+
return earlyOut(res, { entities: sources.entities.length });
|
|
136
|
+
}
|
|
137
|
+
throw e;
|
|
138
|
+
}
|
|
139
|
+
const discovery = discoverUnits(repo, cfg, adapters);
|
|
140
|
+
// Eligibility is the compiler's own answer to "which directories are units" —
|
|
141
|
+
// exposed on the result so scaffold can write descriptors for exactly these
|
|
142
|
+
// roots rather than re-deriving them from filesystem markers.
|
|
143
|
+
res.discovery = discovery;
|
|
144
|
+
const match = matchComponents(discovery.eligible, sources.entities.filter((e) => e.kind === "Component"));
|
|
145
|
+
res.errors.push(...match.errors);
|
|
146
|
+
|
|
147
|
+
// OIDC discovery must be exposed by the provider's own source. Re-check now
|
|
148
|
+
// that component -> unit bindings are known.
|
|
149
|
+
res.errors.push(...validateOidcProviders({ repo, sources, discovery }));
|
|
150
|
+
|
|
151
|
+
if (res.errors.length) {
|
|
152
|
+
return earlyOut(res, { entities: sources.entities.length, units: discovery.eligible.length });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const prov = new ProvRegistry(repo);
|
|
156
|
+
const diagnostics = [];
|
|
157
|
+
const facts = new FactStore();
|
|
158
|
+
const edges = [];
|
|
159
|
+
const events = new Map();
|
|
160
|
+
const externals = new Map();
|
|
161
|
+
const capabilities = new Map();
|
|
162
|
+
const verification = new Map();
|
|
163
|
+
|
|
164
|
+
const ctx = {
|
|
165
|
+
repo, cfg, refs, bundle, prov, diagnostics, facts,
|
|
166
|
+
sources: { ...sources, journeys: journeys.journeys }, discovery, journeys: journeys.journeys, journeysPath: journeys.path,
|
|
167
|
+
observations, overrides: overrides.overrides,
|
|
168
|
+
edges, events, externals, capabilities, verification,
|
|
169
|
+
errors: [],
|
|
170
|
+
unitByComponent: new Map(),
|
|
171
|
+
secondPasses: [],
|
|
172
|
+
};
|
|
173
|
+
ctx.errors = res.errors;
|
|
174
|
+
|
|
175
|
+
const addEdge = (type, sourceRef, targetRef, attributes, provRecs, evidenceClass) => {
|
|
176
|
+
const key = type + "|" + sourceRef + "|" + targetRef + "|" + JSON.stringify(attributes || {});
|
|
177
|
+
let e = edges.find((x) => x.key === key);
|
|
178
|
+
if (!e) {
|
|
179
|
+
e = { key, type, sourceRef, targetRef, attributes: attributes || {}, provs: new Set(), evidenceClass: evidenceClass || "DERIVED" };
|
|
180
|
+
edges.push(e);
|
|
181
|
+
}
|
|
182
|
+
for (const p of provRecs) if (p) e.provs.add(p);
|
|
183
|
+
if (evidenceClass === "REVIEWED_OVERRIDE") e.evidenceClass = "REVIEWED_OVERRIDE";
|
|
184
|
+
return e;
|
|
185
|
+
};
|
|
186
|
+
const addEvent = (pattern, role, compRef, provRec, contract) => {
|
|
187
|
+
if (!events.has(pattern)) events.set(pattern, { producers: new Set(), consumers: new Set(), provs: new Set(), contract: contract || null });
|
|
188
|
+
const ev = events.get(pattern);
|
|
189
|
+
if (role === "producer") ev.producers.add(compRef);
|
|
190
|
+
else ev.consumers.add(compRef);
|
|
191
|
+
if (contract) ev.contract = contract;
|
|
192
|
+
if (provRec) ev.provs.add(provRec);
|
|
193
|
+
};
|
|
194
|
+
const addExternal = (compRef, name, mechanism, role, provRec, evidenceClass) => {
|
|
195
|
+
const key = compRef + "|" + name;
|
|
196
|
+
if (!externals.has(key)) {
|
|
197
|
+
externals.set(key, { componentRef: compRef, name, mechanism, role, evidenceClass: evidenceClass || "DERIVED", provs: new Set() });
|
|
198
|
+
}
|
|
199
|
+
externals.get(key).provs.add(provRec);
|
|
200
|
+
};
|
|
201
|
+
const addCapability = (compRef, name, provRec, evidenceClass) => {
|
|
202
|
+
const key = compRef + "|" + name;
|
|
203
|
+
if (!capabilities.has(key)) {
|
|
204
|
+
capabilities.set(key, { componentRef: compRef, name, evidenceClass: evidenceClass || "DERIVED", provs: new Set() });
|
|
205
|
+
}
|
|
206
|
+
capabilities.get(key).provs.add(provRec);
|
|
207
|
+
};
|
|
208
|
+
const addVerification = (v) => {
|
|
209
|
+
if (!v.componentRefs || v.componentRefs.length === 0) return;
|
|
210
|
+
// A verification command is a routing index, not a credential transport.
|
|
211
|
+
v = { ...v, command: redactCommand(String(v.command || "").trim()).slice(0, 600) };
|
|
212
|
+
if (!verification.has(v.id)) verification.set(v.id, { ...v, provs: new Set(), componentRefs: new Set(v.componentRefs) });
|
|
213
|
+
const cur = verification.get(v.id);
|
|
214
|
+
for (const p of v.provRecs || []) cur.provs.add(p);
|
|
215
|
+
for (const r of v.componentRefs) cur.componentRefs.add(r);
|
|
216
|
+
};
|
|
217
|
+
const gates = new Map();
|
|
218
|
+
const addGate = (g) => {
|
|
219
|
+
g = { ...g, command: redactCommand(String(g.command || "").trim()).slice(0, 600) };
|
|
220
|
+
if (!gates.has(g.id)) gates.set(g.id, { ...g, provs: new Set() });
|
|
221
|
+
for (const p of g.provRecs || []) gates.get(g.id).provs.add(p);
|
|
222
|
+
};
|
|
223
|
+
ctx.addEdge = addEdge;
|
|
224
|
+
ctx.addEvent = addEvent;
|
|
225
|
+
ctx.addExternal = addExternal;
|
|
226
|
+
ctx.addCapability = addCapability;
|
|
227
|
+
ctx.addVerification = addVerification;
|
|
228
|
+
ctx.addGate = addGate;
|
|
229
|
+
ctx.gates = gates;
|
|
230
|
+
ctx.deferSecondPass = (fn) => ctx.secondPasses.push(fn);
|
|
231
|
+
ctx.unitForRoot = (root) => discovery.units.find((u) => u.root === root);
|
|
232
|
+
ctx.primaryEventContract = () => {
|
|
233
|
+
const ec = (cfg.discovery.eventContracts || [])[0];
|
|
234
|
+
if (!ec) return null;
|
|
235
|
+
return { format: ec.format === "code" ? "code" : "declared-catalog", ref: ec.path };
|
|
236
|
+
};
|
|
237
|
+
ctx.addFact = (subject, key, value, opts) => facts.add({ subject, key, value, ...opts });
|
|
238
|
+
|
|
239
|
+
// External-service classification is declared, never inferred. Compiled once
|
|
240
|
+
// so every adapter sees the same matchers in the same order.
|
|
241
|
+
const externalMatchers = ((cfg.discovery.externalDependencies || []).map((x) => {
|
|
242
|
+
let re;
|
|
243
|
+
try {
|
|
244
|
+
re = new RegExp(x.match);
|
|
245
|
+
} catch {
|
|
246
|
+
throw new AgentDocError(CODES.CONFIG, "external dependency pattern is not a valid regular expression: " + x.match);
|
|
247
|
+
}
|
|
248
|
+
return { re, name: x.name, mechanism: x.mechanism, role: x.role, resourceType: x.resourceType || null };
|
|
249
|
+
})).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
250
|
+
ctx.externalMatchers = () => externalMatchers;
|
|
251
|
+
|
|
252
|
+
// The declared client name that owns a Resource of the given type, when exactly
|
|
253
|
+
// one does.
|
|
254
|
+
//
|
|
255
|
+
// A component depending on a `redis` client can reach one Resource two ways: a
|
|
256
|
+
// `cache-client` route that spots the import, and a `declared-client` route
|
|
257
|
+
// that matches the configured external dependency. Those are the same
|
|
258
|
+
// dependency. When only the declared route carried `client`, the two had
|
|
259
|
+
// different identities and never merged, so one dependency shipped as two
|
|
260
|
+
// edges — the very duplication the identity rule exists to prevent.
|
|
261
|
+
//
|
|
262
|
+
// Keyed on the Resource's type rather than on a package name, because the
|
|
263
|
+
// mechanism adapters detect by pattern over file *text* and have no package
|
|
264
|
+
// name to match against. Returns undefined when the type is unclaimed or
|
|
265
|
+
// ambiguous, and the mechanism edge then stands alone rather than guessing.
|
|
266
|
+
ctx.clientKeyForResourceType = (resourceType) => {
|
|
267
|
+
if (!resourceType) return undefined;
|
|
268
|
+
const claims = externalMatchers.filter((x) => x.resourceType === resourceType);
|
|
269
|
+
return claims.length === 1 ? claims[0].name : undefined;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// ── manifest pre-pass: identify each unit's package / module namespace ────
|
|
273
|
+
// Done before extraction so cross-unit resolution never depends on the order
|
|
274
|
+
// in which units happen to be processed.
|
|
275
|
+
for (const unit of discovery.eligible) {
|
|
276
|
+
const pkgPath = unit.root + "/package.json";
|
|
277
|
+
if (repo.exists(pkgPath)) {
|
|
278
|
+
try {
|
|
279
|
+
const pkg = repo.readJson(pkgPath);
|
|
280
|
+
unit.pkgName = typeof pkg.name === "string" ? pkg.name : null;
|
|
281
|
+
unit.pkg = pkg;
|
|
282
|
+
} catch {
|
|
283
|
+
res.errors.push(new AgentDocError(CODES.SCHEMA, "package.json is not valid JSON: " + pkgPath, { path: pkgPath }));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const gomodPath = unit.root + "/go.mod";
|
|
287
|
+
if (repo.exists(gomodPath)) {
|
|
288
|
+
const m = /^module\s+(\S+)/m.exec(repo.readText(gomodPath));
|
|
289
|
+
unit.goModule = m ? m[1] : null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const unitByPkgName = new Map();
|
|
293
|
+
const unitByGoModule = new Map();
|
|
294
|
+
for (const unit of discovery.eligible) {
|
|
295
|
+
if (unit.pkgName && !unitByPkgName.has(unit.pkgName)) unitByPkgName.set(unit.pkgName, unit);
|
|
296
|
+
if (unit.goModule && !unitByGoModule.has(unit.goModule)) unitByGoModule.set(unit.goModule, unit);
|
|
297
|
+
}
|
|
298
|
+
ctx.unitByPkgName = unitByPkgName;
|
|
299
|
+
ctx.unitByGoModule = unitByGoModule;
|
|
300
|
+
|
|
301
|
+
// Manifest-declared unit dependencies, computed before any adapter runs so
|
|
302
|
+
// that cross-unit reasoning never depends on adapter execution order.
|
|
303
|
+
// Production dependency names per unit, so a "depends on this service" rule
|
|
304
|
+
// matches a dependency name rather than a blob of source.
|
|
305
|
+
const productionDependencies = new Map();
|
|
306
|
+
const dependencyMap = new Map();
|
|
307
|
+
for (const unit of discovery.eligible) {
|
|
308
|
+
if (!unit.component) continue;
|
|
309
|
+
const deps = new Set();
|
|
310
|
+
const pkg = unit.pkg || {};
|
|
311
|
+
productionDependencies.set(unit.component.ref, [
|
|
312
|
+
...Object.keys(pkg.dependencies || {}),
|
|
313
|
+
...Object.keys(pkg.optionalDependencies || {}),
|
|
314
|
+
]);
|
|
315
|
+
for (const d of [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.devDependencies || {})]) {
|
|
316
|
+
const t = unitByPkgName.get(d);
|
|
317
|
+
if (t && t.component && t.component.ref !== unit.component.ref) deps.add(t.component.ref);
|
|
318
|
+
}
|
|
319
|
+
if (unit.goModule) {
|
|
320
|
+
const gomod = repo.exists(unit.root + "/go.mod") ? repo.readText(unit.root + "/go.mod") : "";
|
|
321
|
+
const goReqs = [...gomod.matchAll(/^\s*(?:require\s+)?([a-z0-9.]+\.[a-z0-9./-]+)\s+v[0-9]/gm)].map((m) => m[1]);
|
|
322
|
+
productionDependencies.set(unit.component.ref, [...(productionDependencies.get(unit.component.ref) || []), ...goReqs]);
|
|
323
|
+
for (const m of gomod.matchAll(/^\s*(?:require\s+)?([a-z0-9.]+\.[a-z0-9./-]+)\s+v[0-9]/gm)) {
|
|
324
|
+
const t = unitByGoModule.get(m[1]);
|
|
325
|
+
if (t && t.component && t.component.ref !== unit.component.ref) deps.add(t.component.ref);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
dependencyMap.set(unit.component.ref, deps);
|
|
329
|
+
}
|
|
330
|
+
ctx.dependencyMap = dependencyMap;
|
|
331
|
+
ctx.productionDependencies = (ref) => productionDependencies.get(ref) || [];
|
|
332
|
+
ctx.transitiveDependencies = (ref) => {
|
|
333
|
+
const out = new Set([ref]);
|
|
334
|
+
const stack = [ref];
|
|
335
|
+
while (stack.length) {
|
|
336
|
+
const cur = stack.pop();
|
|
337
|
+
for (const d of dependencyMap.get(cur) || []) {
|
|
338
|
+
if (!out.has(d)) { out.add(d); stack.push(d); }
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// ── cross-unit import index (language-neutral scan, adapter-specific use) ─
|
|
345
|
+
const importIndex = buildImportIndex(repo, discovery);
|
|
346
|
+
ctx.importIndex = importIndex;
|
|
347
|
+
|
|
348
|
+
// ── per-unit derivation ──────────────────────────────────────────────────
|
|
349
|
+
const derivedMap = new Map();
|
|
350
|
+
for (const unit of discovery.eligible) {
|
|
351
|
+
const comp = unit.component;
|
|
352
|
+
if (!comp) continue;
|
|
353
|
+
const compRef = comp.ref;
|
|
354
|
+
ctx.unitByComponent.set(compRef, unit);
|
|
355
|
+
const unitCtx = {
|
|
356
|
+
...ctx,
|
|
357
|
+
compRef,
|
|
358
|
+
unitImports: importIndex.byUnit.get(unit) || new Map(),
|
|
359
|
+
descriptorPath: () => comp.file,
|
|
360
|
+
componentEntity: () => comp,
|
|
361
|
+
};
|
|
362
|
+
const d = genericFacts(unitCtx, unit);
|
|
363
|
+
for (const a of adapters) {
|
|
364
|
+
if (a.extract) {
|
|
365
|
+
try {
|
|
366
|
+
const extra = a.extract(unitCtx, unit) || {};
|
|
367
|
+
mergeDerived(d, extra);
|
|
368
|
+
} catch (e) {
|
|
369
|
+
if (e instanceof AgentDocError) res.errors.push(e);
|
|
370
|
+
else throw e;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
d.runtimes = [...(d.runtimes || new Set())].sort();
|
|
375
|
+
d.languages = [...(d.languages || new Set())].sort();
|
|
376
|
+
derivedMap.set(compRef, d);
|
|
377
|
+
diagnostics.push(...checkArtifactType(comp, unit));
|
|
378
|
+
// Authored-field provenance for every entity, not just components.
|
|
379
|
+
prov.add("declared", comp.file, "descriptor-reader", [
|
|
380
|
+
"ent:" + compRef, "entf:" + compRef + ":/apiVersion", "entf:" + compRef + ":/kind",
|
|
381
|
+
"entf:" + compRef + ":/metadata", "entf:" + compRef + ":/metadata/name",
|
|
382
|
+
"entf:" + compRef + ":/metadata/description", "entf:" + compRef + ":/spec",
|
|
383
|
+
]);
|
|
384
|
+
}
|
|
385
|
+
for (const e of sources.entities) {
|
|
386
|
+
if (e.kind === "Component") continue;
|
|
387
|
+
const subjects = [
|
|
388
|
+
"ent:" + e.ref, "entf:" + e.ref + ":/apiVersion", "entf:" + e.ref + ":/kind",
|
|
389
|
+
"entf:" + e.ref + ":/metadata", "entf:" + e.ref + ":/metadata/name",
|
|
390
|
+
"entf:" + e.ref + ":/metadata/description",
|
|
391
|
+
];
|
|
392
|
+
if (e.doc.spec !== undefined) subjects.push("entf:" + e.ref + ":/spec");
|
|
393
|
+
prov.add("declared", e.file, "descriptor-reader", subjects);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── contract adapters run once, after every unit is known ───────────────
|
|
397
|
+
const contractOut = [];
|
|
398
|
+
ctx.addContract = (rec) => {
|
|
399
|
+
if (!rec || !rec.apiRef || !rec.ref) return;
|
|
400
|
+
// A contract entry is a pointer, but the pointer itself is a claim about the
|
|
401
|
+
// repository and is corroborated by the file it names.
|
|
402
|
+
contractOut.push({ ...rec, provs: new Set([prov.add("observed", rec.ref, "contract-extractor", ["ctr:" + rec.apiRef])]) });
|
|
403
|
+
};
|
|
404
|
+
for (const a of adapters) {
|
|
405
|
+
if (!a.contracts) continue;
|
|
406
|
+
try {
|
|
407
|
+
a.contracts(ctx);
|
|
408
|
+
} catch (e) {
|
|
409
|
+
if (e instanceof AgentDocError) res.errors.push(e);
|
|
410
|
+
else throw e;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
for (const e of ctx.errors) if (!res.errors.includes(e)) res.errors.push(new AgentDocError(e.code, e.message, { path: e.path }));
|
|
414
|
+
|
|
415
|
+
// ── second pass: cross-unit resolutions that need the full picture ──────
|
|
416
|
+
const byPlatformName = new Map();
|
|
417
|
+
for (const u of discovery.eligible) if (u.platformName) byPlatformName.set(u.platformName, u);
|
|
418
|
+
for (const fn of ctx.secondPasses) {
|
|
419
|
+
try {
|
|
420
|
+
fn({ byPlatformName });
|
|
421
|
+
} catch (e) {
|
|
422
|
+
if (e instanceof AgentDocError) res.errors.push(e);
|
|
423
|
+
else throw e;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── authored placement facts ─────────────────────────────────────────────
|
|
428
|
+
for (const e of sources.entities) {
|
|
429
|
+
const spec = e.doc.spec || {};
|
|
430
|
+
const mk = () => prov.add("declared", e.file, "placement-extractor", ["ass:pending"]);
|
|
431
|
+
if ((e.kind === "Component" || e.kind === "System") && spec.domain) {
|
|
432
|
+
facts.add({ subject: e.ref, key: "placement.domain", value: spec.domain, evidenceClass: "AUTHORED", confidence: "declared", provRecs: [mk()] });
|
|
433
|
+
}
|
|
434
|
+
if ((e.kind === "Component" || e.kind === "Resource") && spec.system) {
|
|
435
|
+
facts.add({ subject: e.ref, key: "placement.system", value: spec.system, evidenceClass: "AUTHORED", confidence: "declared", provRecs: [mk()] });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ── reviewed overrides → relations and facts ─────────────────────────────
|
|
440
|
+
const overrideEdges = [];
|
|
441
|
+
for (const o of overrides.overrides) {
|
|
442
|
+
// Which pending subject the evidence must resolve to depends on whether
|
|
443
|
+
// this override produces a relation or a fact.
|
|
444
|
+
const producesRelation = o.fact === "runtime-call" || o.fact === "api-consumer" || o.fact === "external-dependency-role";
|
|
445
|
+
const subject = producesRelation ? "rel:pending" : "ass:pending";
|
|
446
|
+
const provs = [
|
|
447
|
+
...o.evidence.map((ev) => prov.add("declared", ev.path, "override-extractor", [subject])),
|
|
448
|
+
prov.add("reviewed", CONFIG_PATH, "override-extractor", [subject]),
|
|
449
|
+
];
|
|
450
|
+
if (o.fact === "runtime-call") {
|
|
451
|
+
const attrs = { transport: o.transport, contractRef: o.contractRef };
|
|
452
|
+
if (o.deliverySemantics) attrs.deliverySemantics = o.deliverySemantics;
|
|
453
|
+
overrideEdges.push({ type: "runtimeCalls", sourceRef: o.subject, targetRef: o.target, attributes: attrs, provs });
|
|
454
|
+
facts.add({
|
|
455
|
+
subject: o.subject, key: "runtime-call." + String(o.target).split("/")[1], value: attrs,
|
|
456
|
+
evidenceClass: "REVIEWED_OVERRIDE", confidence: "reviewed", provRecs: provs,
|
|
457
|
+
semantics: o.reason,
|
|
458
|
+
review: reviewed(o),
|
|
459
|
+
});
|
|
460
|
+
} else if (o.fact === "api-consumer") {
|
|
461
|
+
overrideEdges.push({ type: "consumesApi", sourceRef: o.subject, targetRef: o.target, attributes: { via: "reviewed-override" }, provs });
|
|
462
|
+
facts.add({
|
|
463
|
+
subject: o.subject, key: "api-consumer." + String(o.target).split("/")[1], value: String(o.target),
|
|
464
|
+
evidenceClass: "REVIEWED_OVERRIDE", confidence: "reviewed", provRecs: provs, semantics: o.reason,
|
|
465
|
+
review: reviewed(o),
|
|
466
|
+
});
|
|
467
|
+
} else if (o.fact === "external-dependency-role") {
|
|
468
|
+
const rec = externals.get(o.subject + "|" + o.target);
|
|
469
|
+
if (!rec) {
|
|
470
|
+
res.errors.push(new AgentDocError(
|
|
471
|
+
CODES.RELATION_KIND,
|
|
472
|
+
"external-dependency-role override targets no observed external dependency: " + o.subject + " -> " + o.target,
|
|
473
|
+
{ path: CONFIG_PATH, ref: o.subject }
|
|
474
|
+
));
|
|
475
|
+
} else {
|
|
476
|
+
rec.role = o.role;
|
|
477
|
+
rec.evidenceClass = "REVIEWED_OVERRIDE";
|
|
478
|
+
for (const p of provs) rec.provs.add(p);
|
|
479
|
+
facts.add({
|
|
480
|
+
subject: o.subject, key: "external-role." + o.target, value: o.role,
|
|
481
|
+
evidenceClass: "REVIEWED_OVERRIDE", confidence: "reviewed", provRecs: provs, semantics: o.reason,
|
|
482
|
+
review: reviewed(o),
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
} else if (o.fact === "binding") {
|
|
486
|
+
facts.add({
|
|
487
|
+
subject: o.subject, key: "binding.kind", value: o.kind,
|
|
488
|
+
evidenceClass: "REVIEWED_OVERRIDE", confidence: "reviewed", provRecs: provs,
|
|
489
|
+
semantics: o.reason,
|
|
490
|
+
review: reviewed(o),
|
|
491
|
+
});
|
|
492
|
+
} else if (o.fact === "capability") {
|
|
493
|
+
addCapability(o.subject, o.kind, provs[0], "REVIEWED_OVERRIDE");
|
|
494
|
+
facts.add({
|
|
495
|
+
subject: o.subject, key: "capability." + o.kind, value: true,
|
|
496
|
+
evidenceClass: "REVIEWED_OVERRIDE", confidence: "reviewed", provRecs: provs, semantics: o.reason,
|
|
497
|
+
review: reviewed(o),
|
|
498
|
+
});
|
|
499
|
+
} else if (o.fact === "event-producer" || o.fact === "event-consumer") {
|
|
500
|
+
addEvent(o.eventPattern, o.fact === "event-producer" ? "producer" : "consumer", o.subject, provs[0], ctx.primaryEventContract());
|
|
501
|
+
facts.add({
|
|
502
|
+
subject: o.subject, key: "event-role." + o.eventPattern, value: o.fact === "event-producer" ? "producer" : "consumer",
|
|
503
|
+
evidenceClass: "REVIEWED_OVERRIDE", confidence: "reviewed", provRecs: provs, semantics: o.reason,
|
|
504
|
+
review: reviewed(o),
|
|
505
|
+
});
|
|
506
|
+
} else if (o.fact === "component-placement") {
|
|
507
|
+
const key = o.system ? "placement.system" : "placement.domain";
|
|
508
|
+
facts.add({
|
|
509
|
+
subject: o.subject, key, value: o.system || o.domain,
|
|
510
|
+
evidenceClass: "REVIEWED_OVERRIDE", confidence: "reviewed", provRecs: provs,
|
|
511
|
+
semantics: o.reason,
|
|
512
|
+
review: reviewed(o),
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
ctx.overrideEdges = overrideEdges;
|
|
517
|
+
|
|
518
|
+
// ── observations → OBSERVED_RUNTIME facts ────────────────────────────────
|
|
519
|
+
const observationOut = ingestObservations(ctx);
|
|
520
|
+
|
|
521
|
+
// ── authority resolution ─────────────────────────────────────────────────
|
|
522
|
+
const authority = new AuthorityEngine(cfg.authority.rules);
|
|
523
|
+
|
|
524
|
+
// Supersession is applied before election: an observation that a later set
|
|
525
|
+
// explicitly replaces does not compete for the election, though it stays in
|
|
526
|
+
// the graph as history.
|
|
527
|
+
// Keyed on capturedAt+environment: batch captures across environments can
|
|
528
|
+
// share a timestamp, and a timestamp-only key would mark the wrong set's
|
|
529
|
+
// facts superseded.
|
|
530
|
+
const supersededAt = new Set();
|
|
531
|
+
for (const o of observationOut) {
|
|
532
|
+
if (o.supersedes) {
|
|
533
|
+
const prior = observationOut.find((x) => x.id === o.supersedes);
|
|
534
|
+
if (prior) supersededAt.add(prior.capturedAt + "\u0000" + prior.environment);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
for (const f of facts.facts) {
|
|
538
|
+
if (f.evidenceClass !== "OBSERVED_RUNTIME") continue;
|
|
539
|
+
const fo = f.observed;
|
|
540
|
+
if (!fo || !supersededAt.has(fo.at + "\u0000" + fo.environment)) continue;
|
|
541
|
+
f.status = "superseded";
|
|
542
|
+
// Supersession is a claim a human made, so it is a reviewable claim. Silence
|
|
543
|
+
// would let any observation quietly void any other: the code is in
|
|
544
|
+
// ACCEPTABLE_WARNING_CODES precisely so the disposition can be recorded
|
|
545
|
+
// against pinned evidence rather than left implicit.
|
|
546
|
+
diagnostics.push({
|
|
547
|
+
severity: "warning",
|
|
548
|
+
code: "AGENTDOC_OBSERVATION_SUPERSEDED",
|
|
549
|
+
subject: f.subject,
|
|
550
|
+
refs: [f.subject],
|
|
551
|
+
message:
|
|
552
|
+
"runtime observation captured " + (f.observed && f.observed.at) + " was superseded by a later capture " +
|
|
553
|
+
"and no longer competes for the election. It remains in the graph as history. " +
|
|
554
|
+
"Confirm the superseding capture really replaces it.",
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const { conflicts: rawConflicts } = resolveAssertions(facts.facts, authority);
|
|
559
|
+
pushConflictDiagnostics(rawConflicts, diagnostics, facts);
|
|
560
|
+
|
|
561
|
+
// ── relations ────────────────────────────────────────────────────────────
|
|
562
|
+
const rel = buildRelations(ctx);
|
|
563
|
+
res.errors.push(...rel.errors);
|
|
564
|
+
|
|
565
|
+
// A monorepo task graph and a package script often cover the same tier. Two
|
|
566
|
+
// runnable commands for one check is duplication an agent has to read past, so
|
|
567
|
+
// the preference is explicit and the loser is dropped with its provenance.
|
|
568
|
+
const pref = cfg.discovery.verificationPreference || "task-graph";
|
|
569
|
+
if (pref !== "both") {
|
|
570
|
+
const taskGraphTiers = new Set();
|
|
571
|
+
for (const id of verification.keys()) {
|
|
572
|
+
if (!id.startsWith("tg:")) continue;
|
|
573
|
+
const v = verification.get(id);
|
|
574
|
+
for (const r of v.componentRefs) taskGraphTiers.add(r + "|" + v.tier);
|
|
575
|
+
}
|
|
576
|
+
for (const [id, v] of [...verification.entries()]) {
|
|
577
|
+
if (id.startsWith("tg:") || id.startsWith("ci:")) continue;
|
|
578
|
+
const shadowed = [...v.componentRefs].some((r) => taskGraphTiers.has(r + "|" + v.tier));
|
|
579
|
+
if (!shadowed) continue;
|
|
580
|
+
verification.delete(id);
|
|
581
|
+
// The dropped entry's evidence must stop claiming to prove it, or the
|
|
582
|
+
// provenance record would point at a verification entry that no longer
|
|
583
|
+
// exists.
|
|
584
|
+
for (const p of v.provs) p.subjects.delete("ver:" + id);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const indexes = buildIndexes(ctx);
|
|
589
|
+
indexes.gates = [...gates.values()]
|
|
590
|
+
.sort((a, b) => (a.id < b.id ? -1 : 1))
|
|
591
|
+
.map((g) => ({ id: g.id, tier: g.tier, command: g.command, configPaths: [...(g.configPaths || [])].sort(), provs: g.provs }));
|
|
592
|
+
indexes.contracts = contractOut.slice().sort((a, b) => (a.ref < b.ref ? -1 : a.ref > b.ref ? 1 : 0));
|
|
593
|
+
|
|
594
|
+
res.errors.push(...applyWarningAcceptances(repo, cfg.warningAcceptances || [], diagnostics));
|
|
595
|
+
|
|
596
|
+
// Bind pending provenance subjects now that indices exist.
|
|
597
|
+
rel.relations.forEach((r) => {
|
|
598
|
+
const key = r.type + "|" + r.sourceRef + "|" + r.targetRef + "|" + JSON.stringify(r.attributes || {});
|
|
599
|
+
for (const p of r.provs) {
|
|
600
|
+
p.subjects.delete("rel:pending");
|
|
601
|
+
p.subjects.add("rel:" + key);
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
for (const f of facts.facts) {
|
|
605
|
+
for (const p of f.provRecs) {
|
|
606
|
+
p.subjects.delete("ass:pending");
|
|
607
|
+
p.subjects.add("ass:" + f.id);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
// Safety net: evidence whose candidate edge or fact was dropped (an
|
|
611
|
+
// unresolved override, a rejected candidate) legitimately has no subject.
|
|
612
|
+
// Records that end up proving nothing are removed rather than left dangling,
|
|
613
|
+
// and the count is reported so a regression here is visible.
|
|
614
|
+
let droppedProvenance = 0;
|
|
615
|
+
for (let i = prov.records.length - 1; i >= 0; i--) {
|
|
616
|
+
const rec = prov.records[i];
|
|
617
|
+
rec.subjects.delete("rel:pending");
|
|
618
|
+
rec.subjects.delete("ass:pending");
|
|
619
|
+
if (rec.subjects.size === 0) {
|
|
620
|
+
prov.records.splice(i, 1);
|
|
621
|
+
droppedProvenance++;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// error-severity diagnostics block publication
|
|
626
|
+
for (const dg of diagnostics) {
|
|
627
|
+
if (dg.severity === "error") res.errors.push(new AgentDocError(dg.code, dg.message, { path: dg.paths?.[0], ref: dg.refs?.[0] }));
|
|
628
|
+
}
|
|
629
|
+
if (res.errors.length) {
|
|
630
|
+
return earlyOut(res, { entities: sources.entities.length, units: discovery.eligible.length });
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
diagnostics.sort((a, b) => {
|
|
634
|
+
const ka = a.severity + "|" + a.code + "|" + (a.paths?.[0] || "") + "|" + a.message;
|
|
635
|
+
const kb = b.severity + "|" + b.code + "|" + (b.paths?.[0] || "") + "|" + b.message;
|
|
636
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// Nested provenance (bindings/health) needs the finalized ids.
|
|
640
|
+
const skeleton = assembleGraph(sources, rel, indexes, derivedMap, facts, rawConflicts, observationOut, { idOf: new Map(), list: [] }, diagnostics);
|
|
641
|
+
let provFinal;
|
|
642
|
+
try {
|
|
643
|
+
provFinal = prov.finalize(subjectResolver(skeleton));
|
|
644
|
+
} catch (e) {
|
|
645
|
+
res.errors.push(e instanceof AgentDocError ? e : new AgentDocError(CODES.PROVENANCE, e.message));
|
|
646
|
+
return earlyOut(res, { entities: sources.entities.length, units: discovery.eligible.length });
|
|
647
|
+
}
|
|
648
|
+
for (const d of derivedMap.values()) {
|
|
649
|
+
for (const list of [d.bindings || [], d.healthContracts || []]) {
|
|
650
|
+
for (const b of list) {
|
|
651
|
+
b.provenanceIds = [...(b._provs || [])].map((p) => provFinal.idOf.get(p)).filter(Boolean).sort();
|
|
652
|
+
delete b._provs;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const graph = assembleGraph(sources, rel, indexes, derivedMap, facts, rawConflicts, observationOut, provFinal, diagnostics);
|
|
658
|
+
graph.compiler = { name: COMPILER_NAME, version: COMPILER_VERSION, adapters: adapterStamp(adapters) };
|
|
659
|
+
|
|
660
|
+
const commit = repo.headCommit();
|
|
661
|
+
const inputHash = computeInputHash(repo, [
|
|
662
|
+
COMPILER_NAME + "@" + COMPILER_VERSION,
|
|
663
|
+
GRAPH_SCHEMA_VERSION,
|
|
664
|
+
"agentdoc.dev/v1",
|
|
665
|
+
"authority-rules@" + cfg.authority.rules.length,
|
|
666
|
+
...schemaInputs,
|
|
667
|
+
...adapters.map((a) => ADAPTER_VERSION_INPUT_PREFIX + a.name + "@" + a.version),
|
|
668
|
+
]);
|
|
669
|
+
const dirtySet = new Set(repo.dirtyPaths().filter((p) => p !== graphPath));
|
|
670
|
+
const walkedDirs = [...repo.walks.keys()];
|
|
671
|
+
const dirty = [...dirtySet].some(
|
|
672
|
+
(p) =>
|
|
673
|
+
repo.readPaths.has(p) ||
|
|
674
|
+
repo.probes.has(p) ||
|
|
675
|
+
repo.listings.has(p) ||
|
|
676
|
+
walkedDirs.some((d) => (d === "." ? true : p === d || p.startsWith(d + "/")))
|
|
677
|
+
);
|
|
678
|
+
graph.source = { commit, dirty, inputHash };
|
|
679
|
+
|
|
680
|
+
const gv = bundle.validator("agentdoc.dev/schema/graph.schema.json");
|
|
681
|
+
const gerrs = gv.validate(graph);
|
|
682
|
+
if (gerrs) {
|
|
683
|
+
res.errors.push(new AgentDocError(
|
|
684
|
+
CODES.GRAPH_SCHEMA,
|
|
685
|
+
"compiled graph violates the graph schema — compiler defect: " + formatErrors(gerrs)
|
|
686
|
+
));
|
|
687
|
+
return earlyOut(res, { entities: sources.entities.length, units: discovery.eligible.length });
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
let serialized;
|
|
691
|
+
try {
|
|
692
|
+
serialized = finalizeSerialization(serializeGraph(graph), graph, {
|
|
693
|
+
// Only timestamps the compiler read out of a committed ObservationSet may
|
|
694
|
+
// appear. This is what keeps compilation a pure function of the checkout.
|
|
695
|
+
allowedTimestamps: observationOut.map((o) => o.capturedAt),
|
|
696
|
+
});
|
|
697
|
+
} catch (e) {
|
|
698
|
+
res.errors.push(e instanceof AgentDocError ? e : new AgentDocError(CODES.NONDETERMINISTIC, e.message));
|
|
699
|
+
return earlyOut(res, { entities: sources.entities.length, units: discovery.eligible.length });
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
res.graph = graph;
|
|
703
|
+
res.serialized = serialized;
|
|
704
|
+
res.diagnostics = diagnostics;
|
|
705
|
+
res.graphPath = graphPath;
|
|
706
|
+
res.sources = sources;
|
|
707
|
+
res.indexes = indexes;
|
|
708
|
+
res.facts = facts;
|
|
709
|
+
res.conflicts = graph.conflicts;
|
|
710
|
+
res.stats = {
|
|
711
|
+
entities: graph.entities.length,
|
|
712
|
+
components: graph.entities.filter((e) => e.kind === "Component").length,
|
|
713
|
+
relations: graph.relations.length,
|
|
714
|
+
events: graph.interfaces.events.length,
|
|
715
|
+
externals: graph.externalDependencies.length,
|
|
716
|
+
capabilities: graph.capabilities.length,
|
|
717
|
+
verification: graph.verification.length,
|
|
718
|
+
journeys: graph.journeys.length,
|
|
719
|
+
assertions: graph.assertions.length,
|
|
720
|
+
conflicts: graph.conflicts.length,
|
|
721
|
+
unresolvedConflicts: graph.conflicts.filter((c) => c.status === "unresolved").length,
|
|
722
|
+
observations: graph.observations.length,
|
|
723
|
+
provenance: graph.provenance.length,
|
|
724
|
+
units: discovery.eligible.length,
|
|
725
|
+
acceptedWarnings: diagnostics.filter((d) => d.severity === "warning" && d.acceptance).length,
|
|
726
|
+
unacceptedWarnings: diagnostics.filter((d) => d.severity === "warning" && !d.acceptance).length,
|
|
727
|
+
droppedProvenance,
|
|
728
|
+
inputHash,
|
|
729
|
+
commit,
|
|
730
|
+
dirty,
|
|
731
|
+
};
|
|
732
|
+
return res;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// A contradiction that no authority rule can settle is an error: the system
|
|
736
|
+
// refuses to route an agent onto a subject whose facts disagree. A divergence
|
|
737
|
+
// that a rule or a reviewed override *does* settle is a warning, acceptable
|
|
738
|
+
// with review, so the divergence stays visible in the graph either way.
|
|
739
|
+
// The review record attached to every REVIEWED_OVERRIDE fact. The reviewer and
|
|
740
|
+
// the date are deliberately not typed in: they are the commit that changed the
|
|
741
|
+
// configuration carrying this record, which is stronger evidence than a string.
|
|
742
|
+
// degradeOnStale is deliberately NOT applied here: a rule's answer to "what
|
|
743
|
+
// does a stale election mean" depends on the wall clock, and a compile must be
|
|
744
|
+
// a pure function of the checkout. Degradation is evaluated at gate time, in
|
|
745
|
+
// the same place observation freshness itself is enforced (see the CLI's
|
|
746
|
+
// freshness gate). Serializing a day-count or a degraded status into the graph
|
|
747
|
+
// would make identical inputs compile to different bytes on different days.
|
|
748
|
+
function pushConflictDiagnostics(rawConflicts, diagnostics, facts) {
|
|
749
|
+
const byId = new Map(facts.facts.map((f) => [f.id, f]));
|
|
750
|
+
for (const c of rawConflicts) {
|
|
751
|
+
const values = c.assertionIds
|
|
752
|
+
.map((id) => byId.get(id))
|
|
753
|
+
.filter(Boolean)
|
|
754
|
+
.map((f) => f.evidenceClass + "=" + JSON.stringify(f.value))
|
|
755
|
+
.sort();
|
|
756
|
+
const detail = c.subject + " " + c.key + ": " + values.join(" vs ");
|
|
757
|
+
if (c.kind === "ambiguity" && c.status === "unresolved") {
|
|
758
|
+
diagnostics.push({
|
|
759
|
+
severity: "warning",
|
|
760
|
+
code: CODES.UNRESOLVED_FACT,
|
|
761
|
+
subject: c.subject,
|
|
762
|
+
refs: [c.subject],
|
|
763
|
+
message:
|
|
764
|
+
"unresolved fact — " + detail + ". " + (c.election.rationale || "") +
|
|
765
|
+
". Author the owning entity, add a reviewed override, or accept with review.",
|
|
766
|
+
});
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
if (c.status === "unresolved") {
|
|
770
|
+
diagnostics.push({
|
|
771
|
+
severity: "error",
|
|
772
|
+
code: CODES.CONFLICT_UNRESOLVED,
|
|
773
|
+
subject: c.subject,
|
|
774
|
+
refs: [c.subject],
|
|
775
|
+
message:
|
|
776
|
+
"unresolved " + c.kind + " — " + detail + ". " +
|
|
777
|
+
(c.election.rationale || "no authority rule governs this fact") +
|
|
778
|
+
". Add an authority rule and, if deterministic discovery cannot settle it, a reviewed override.",
|
|
779
|
+
});
|
|
780
|
+
} else {
|
|
781
|
+
diagnostics.push({
|
|
782
|
+
severity: "warning",
|
|
783
|
+
code: "AGENTDOC_CONFLICT_REVIEWED",
|
|
784
|
+
subject: c.subject,
|
|
785
|
+
refs: [c.subject],
|
|
786
|
+
message:
|
|
787
|
+
c.kind + " — " + detail + ". " +
|
|
788
|
+
(c.election.elected
|
|
789
|
+
? "elected " + c.election.elected.evidenceClass + " via " + c.election.basis + " (rule " + c.election.ruleId + ")"
|
|
790
|
+
: "not elected") +
|
|
791
|
+
". The contradicted value remains in the graph.",
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// A Go import is either host.tld/owner/repo/path or host.tld/owner/repo. The
|
|
798
|
+
// second form is what a Go *workspace* member uses, and it has only two dots —
|
|
799
|
+
// a pattern that requires three silently loses every workspace-only dependency.
|
|
800
|
+
const GO_IMPORT_RE = /["`]([a-z0-9]+\.[a-z0-9.-]+\/[a-z0-9./_-]+|[a-z0-9]+\.[a-z0-9]+\.[a-z0-9./-]+)["`]/g;
|
|
801
|
+
|
|
802
|
+
// Language-neutral scan of every unit's production sources, indexing the
|
|
803
|
+
// external specifiers each file mentions. Adapters decide which of those
|
|
804
|
+
// specifiers are sibling units and what kind of relation that implies.
|
|
805
|
+
function buildImportIndex(repo, discovery) {
|
|
806
|
+
const byUnit = new Map();
|
|
807
|
+
const goPathsByUnit = new Map();
|
|
808
|
+
for (const unit of discovery.eligible) {
|
|
809
|
+
const map = new Map();
|
|
810
|
+
const goPaths = new Set();
|
|
811
|
+
for (const f of sourceFiles(repo, unit.root, { test: false })) {
|
|
812
|
+
const specifiers = importSpecifiers(repo.readText(f));
|
|
813
|
+
for (const s of specifiers) {
|
|
814
|
+
if (!map.has(s)) map.set(s, new Set());
|
|
815
|
+
map.get(s).add(f);
|
|
816
|
+
}
|
|
817
|
+
if (unit.goModule) {
|
|
818
|
+
const text = repo.readText(f);
|
|
819
|
+
// Every module-shaped import is recorded, not only those under the
|
|
820
|
+
// unit's own module: a Go workspace member can be depended on purely by
|
|
821
|
+
// importing it, with no require line at all.
|
|
822
|
+
for (const m of text.matchAll(GO_IMPORT_RE) ) goPaths.add(m[1]);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
byUnit.set(unit, map);
|
|
826
|
+
goPathsByUnit.set(unit, goPaths);
|
|
827
|
+
}
|
|
828
|
+
return { byUnit, goPathsByUnit };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function mergeDerived(target, extra) {
|
|
832
|
+
for (const [k, v] of Object.entries(extra || {})) {
|
|
833
|
+
if (v instanceof Set) {
|
|
834
|
+
target[k] = target[k] instanceof Set ? new Set([...target[k], ...v]) : new Set(v);
|
|
835
|
+
} else if (Array.isArray(v)) {
|
|
836
|
+
target[k] = (target[k] || []).concat(v);
|
|
837
|
+
} else if (v && typeof v === "object") {
|
|
838
|
+
target[k] = { ...(target[k] || {}), ...v };
|
|
839
|
+
} else if (v !== undefined && v !== null) {
|
|
840
|
+
target[k] = v;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function adapterStamp(adapters) {
|
|
846
|
+
return adapters
|
|
847
|
+
.map((a) => ({ name: a.name, version: a.version }))
|
|
848
|
+
.sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function validateOidcProviders({ repo, sources, discovery }) {
|
|
852
|
+
const errors = [];
|
|
853
|
+
for (const api of sources.entities.filter((e) => e.kind === "API" && e.doc.spec.type === "oidc")) {
|
|
854
|
+
const dp = api.doc.spec.contract.discoveryPath;
|
|
855
|
+
const provider = sources.byRef.get(api.doc.spec.provider);
|
|
856
|
+
const unit = discovery && discovery.eligible.find((x) => x.component === provider);
|
|
857
|
+
if (!provider) continue;
|
|
858
|
+
const roots = unit ? [unit.root] : [dirOf(provider.file)];
|
|
859
|
+
let found = false;
|
|
860
|
+
for (const root of roots) {
|
|
861
|
+
for (const f of repo.walk(root)) {
|
|
862
|
+
if (!/\.(ts|tsx|js|mjs|jsx|py|go|rb|java|kt|rs)$/.test(f)) continue;
|
|
863
|
+
if (/(^|\/)(test|tests|__tests__)\//.test(f) || /\.(test|spec)\./.test(f)) continue;
|
|
864
|
+
if (repo.readText(f).includes(dp)) { found = true; break; }
|
|
865
|
+
}
|
|
866
|
+
if (found) break;
|
|
867
|
+
}
|
|
868
|
+
if (!found) {
|
|
869
|
+
errors.push(new AgentDocError(
|
|
870
|
+
CODES.OIDC_DISCOVERY,
|
|
871
|
+
"provider " + api.doc.spec.provider + " does not expose discovery path '" + dp + "' anywhere in its source",
|
|
872
|
+
{ path: api.file, ref: api.ref }
|
|
873
|
+
));
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
return errors;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function dirOf(p) { return p.split("/").slice(0, -1).join("/") || "."; }
|
|
880
|
+
|
|
881
|
+
export { redactCommand, PROV_TO_EVIDENCE, sha256Hex };
|