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
@@ -0,0 +1,486 @@
1
+ // Authored source loading: the config document, entity descriptors, journeys,
2
+ // observation sets and reviewed overrides.
3
+ //
4
+ // Deliberate strictness, preserved from the reference implementation:
5
+ // - one canonical location per kind (a descriptor in the wrong file is an
6
+ // error, not a warning)
7
+ // - prohibited fields: lifecycle/owner/status/relations/annotations metadata
8
+ // has no home in a machine-readable entity and would immediately drift
9
+ // - exactly one sentence descriptions
10
+ // - no untrimmed strings
11
+ // - no secrets
12
+ // - refs and context paths must resolve
13
+ import path from "node:path";
14
+ import fs from "node:fs";
15
+ import { fileURLToPath } from "node:url";
16
+ import { createHash } from "node:crypto";
17
+ import { AgentDocError, CODES, collect } from "./codes.mjs";
18
+ import { assertRepoPath, expandGlob, sha256Hex } from "./fsx.mjs";
19
+ import { parseYamlDocuments } from "./yaml.mjs";
20
+ import { scanForSecrets } from "./secrets.mjs";
21
+ import { formatErrors } from "./jsonschema.mjs";
22
+ import { SchemaBundle } from "./jsonschema.mjs";
23
+
24
+ export const KINDS = Object.freeze(["Domain", "System", "Component", "API", "Resource"]);
25
+ const NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
26
+
27
+ export function makeRefs(namespace) {
28
+ const refFor = (kind, name) => kind.toLowerCase() + ":" + namespace + "/" + name;
29
+ const kindOfRef = (ref) => KINDS.find((k) => k.toLowerCase() === String(ref).split(":")[0]);
30
+ const nameOfRef = (ref) => String(ref).split("/")[1];
31
+ const isValidRef = (ref) => typeof ref === "string" && new RegExp("^(" + KINDS.map((k) => k.toLowerCase()).join("|") + "):" + namespace + "/[a-z0-9]+(?:-[a-z0-9]+)*$").test(ref);
32
+ return { refFor, kindOfRef, nameOfRef, isValidRef, namespace };
33
+ }
34
+
35
+ const PROHIBITED_KEYS = new Set([
36
+ "status", "owner", "owners", "lifecycle", "maturity", "uid", "etag", "uuid",
37
+ "annotations", "labels", "createdAt", "updatedAt", "created", "modified",
38
+ "relations", "dependsOn", "depends_on", "runtime", "secrets",
39
+ ]);
40
+
41
+ // `extensions` is the documented forward-evolution bag: typed as free-form and
42
+ // ignored by every core rule, including this one. Recursing into it would make
43
+ // the escape hatch unusable for exactly the slow-drifting metadata the spec says
44
+ // has no home in an entity.
45
+ const FREE_FORM_KEYS = new Set(["extensions"]);
46
+
47
+ function rejectProhibitedFields(node, pathStr, freeForm = false) {
48
+ if (!node || typeof node !== "object") return;
49
+ if (Array.isArray(node)) {
50
+ node.forEach((v) => rejectProhibitedFields(v, pathStr, freeForm));
51
+ return;
52
+ }
53
+ for (const [k, v] of Object.entries(node)) {
54
+ if (FREE_FORM_KEYS.has(k)) continue;
55
+ if (PROHIBITED_KEYS.has(k)) {
56
+ const code = k === "relations" || k === "dependsOn" || k === "depends_on" || k === "runtime"
57
+ ? CODES.RELATION_AUTHORED
58
+ : CODES.PROHIBITED_FIELD;
59
+ throw new AgentDocError(
60
+ code,
61
+ "prohibited field '" + k + "': derived topology and ownership metadata have no place in an authored entity — use the graph",
62
+ { path: pathStr }
63
+ );
64
+ }
65
+ rejectProhibitedFields(v, pathStr, freeForm);
66
+ }
67
+ }
68
+
69
+ function checkTrimmed(node, pathStr, pointer = "") {
70
+ if (typeof node === "string") {
71
+ if (node !== node.trim()) {
72
+ throw new AgentDocError(CODES.SCHEMA, "string at " + (pointer || "/") + " has leading or trailing whitespace", { path: pathStr });
73
+ }
74
+ return;
75
+ }
76
+ if (!node || typeof node !== "object" || Array.isArray(node)) return;
77
+ for (const [k, v] of Object.entries(node)) checkTrimmed(v, pathStr, pointer + "/" + k);
78
+ }
79
+
80
+ function checkOneSentence(desc, pathStr) {
81
+ if (typeof desc !== "string") return;
82
+ const ok = /[.!?]$/.test(desc) && !/[.!?]/.test(desc.slice(0, -1)) && !/[\r\n]/.test(desc);
83
+ if (!ok) {
84
+ throw new AgentDocError(
85
+ CODES.SCHEMA,
86
+ "metadata.description must be one current-purpose sentence ending in '.', '!', or '?'",
87
+ { path: pathStr }
88
+ );
89
+ }
90
+ }
91
+
92
+ // Schemas ship with the compiler, not with the target repository: the target
93
+ // repository must not be able to loosen validation by editing a schema. Their
94
+ // content hashes are folded into the graph input hash so upgrading a schema
95
+ // correctly invalidates every graph compiled against the old one.
96
+ const SYSTEM_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
97
+
98
+ export function loadSchemaBundle() {
99
+ const abs = path.join(SYSTEM_ROOT, "schemas");
100
+ const names = fs.readdirSync(abs).filter((f) => f.endsWith(".schema.json")).sort();
101
+ const map = new Map();
102
+ const versionInputs = [];
103
+ for (const n of names) {
104
+ const text = fs.readFileSync(path.join(abs, n), "utf8");
105
+ const s = JSON.parse(text);
106
+ if (!s.$id) throw new AgentDocError(CODES.SCHEMA, "schema " + n + " has no $id");
107
+ map.set(s.$id, s);
108
+ versionInputs.push("schema:" + s.$id + ":" + createHash("sha256").update(text).digest("hex"));
109
+ }
110
+ if (!map.has("agentdoc.dev/schema/config.schema.json")) {
111
+ throw new AgentDocError(CODES.SCHEMA, "the compiler's own schema bundle is incomplete");
112
+ }
113
+ return { bundle: SchemaBundle.fromMap(map), versionInputs };
114
+ }
115
+
116
+ const KIND_SCHEMA_ID = {
117
+ Domain: "agentdoc.dev/schema/domain.schema.json",
118
+ System: "agentdoc.dev/schema/system.schema.json",
119
+ Component: "agentdoc.dev/schema/component.schema.json",
120
+ API: "agentdoc.dev/schema/api.schema.json",
121
+ Resource: "agentdoc.dev/schema/resource.schema.json",
122
+ };
123
+
124
+ export const CONFIG_PATH = "agentdoc/agentdoc.config.yaml";
125
+
126
+ export function loadConfig(repo, bundle) {
127
+ let text;
128
+ try {
129
+ text = repo.readText(CONFIG_PATH);
130
+ } catch (e) {
131
+ // A missing configuration is the normal "not onboarded yet" state, so the
132
+ // error must say that — `AGENTDOC_PATH_UNRESOLVED` reads like a defect.
133
+ if (e instanceof AgentDocError && e.code === CODES.PATH_UNRESOLVED) {
134
+ throw new AgentDocError(
135
+ CODES.CONFIG,
136
+ "no agentdoc configuration found — expected " + CONFIG_PATH +
137
+ "; commands search upward from the working directory, and `agentdoc init` creates the catalog",
138
+ { path: CONFIG_PATH }
139
+ );
140
+ }
141
+ throw e;
142
+ }
143
+ const docs = parseYamlDocuments(text, CONFIG_PATH);
144
+ if (docs.length !== 1) {
145
+ throw new AgentDocError(CODES.CONFIG, "agentdoc.config.yaml must contain exactly one document", { path: CONFIG_PATH });
146
+ }
147
+ const cfg = docs[0].doc;
148
+ const v = bundle.validator("agentdoc.dev/schema/config.schema.json");
149
+ const errs = v.validate(cfg);
150
+ if (errs) {
151
+ throw new AgentDocError(CODES.SCHEMA, "agentdoc config violates its schema: " + formatErrors(errs), { path: CONFIG_PATH });
152
+ }
153
+ scanForSecrets(cfg, CONFIG_PATH);
154
+ for (const g of [...cfg.discovery.componentDescriptors, ...cfg.discovery.centralDescriptors]) {
155
+ if (g.split("/").includes("..")) {
156
+ throw new AgentDocError(CODES.GLOB, "glob must not contain '..': " + g, { path: CONFIG_PATH });
157
+ }
158
+ }
159
+ for (const p of [
160
+ cfg.output.graph, cfg.output.observations,
161
+ ...(cfg.discovery.supplementalRoots || []),
162
+ ...(cfg.discovery.contractRoots || []),
163
+ ...(cfg.docs ? Object.values(cfg.docs) : []),
164
+ ].filter(Boolean)) {
165
+ assertRepoPath(p, CONFIG_PATH);
166
+ }
167
+ return cfg;
168
+ }
169
+
170
+ function descriptorGlobs(repo, cfg) {
171
+ const componentFiles = new Set();
172
+ for (const g of cfg.discovery.componentDescriptors) for (const f of expandGlob(repo, g)) componentFiles.add(f);
173
+ // A supplementalRoots unit is declared by configuration, not discovered by a
174
+ // glob — its descriptor lives at a fixed path and must be loaded even when
175
+ // no componentDescriptors glob happens to cover that directory.
176
+ for (const r of cfg.discovery.supplementalRoots || []) {
177
+ const f = String(r).replace(/\/+$/, "") + "/" + cfg.discovery.componentDescriptorName;
178
+ if (repo.exists(f)) componentFiles.add(f);
179
+ }
180
+ const centralFiles = new Set();
181
+ for (const g of cfg.discovery.centralDescriptors) for (const f of expandGlob(repo, g)) centralFiles.add(f);
182
+ return { componentFiles: [...componentFiles].sort(), centralFiles: [...centralFiles].sort() };
183
+ }
184
+
185
+ export function loadDescriptors(repo, cfg, bundle, refs) {
186
+ const errors = [];
187
+ const entities = [];
188
+ const { componentFiles, centralFiles } = descriptorGlobs(repo, cfg);
189
+ const componentSet = new Set(componentFiles);
190
+ const centralSet = new Set(centralFiles);
191
+ const descriptorName = cfg.discovery.componentDescriptorName;
192
+
193
+ for (const file of [...componentFiles, ...centralFiles]) {
194
+ const isComponentFile = componentSet.has(file);
195
+ const docs = collect(() => parseYamlDocuments(repo.readText(file), file), errors);
196
+ if (!docs) continue;
197
+ for (const { doc, line } of docs) {
198
+ collect(() => {
199
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
200
+ throw new AgentDocError(CODES.YAML_NON_MAP_ROOT, "descriptor must be a YAML mapping", { path: file, line });
201
+ }
202
+ if (doc.apiVersion !== "agentdoc.dev/v1") {
203
+ throw new AgentDocError(CODES.API_VERSION, "apiVersion must be exactly 'agentdoc.dev/v1'", { path: file, line });
204
+ }
205
+ if (!KINDS.includes(doc.kind)) {
206
+ throw new AgentDocError(CODES.KIND, "kind must be one of " + KINDS.join(", "), { path: file, line });
207
+ }
208
+ const kind = doc.kind;
209
+ if (kind === "Component") {
210
+ if (!isComponentFile || path.posix.basename(file) !== descriptorName) {
211
+ throw new AgentDocError(
212
+ CODES.LOCATION,
213
+ "Component descriptors must live in <unit-root>/" + descriptorName,
214
+ { path: file, line }
215
+ );
216
+ }
217
+ } else if (!centralSet.has(file)) {
218
+ throw new AgentDocError(
219
+ CODES.LOCATION,
220
+ kind + " descriptors must be listed in discovery.centralDescriptors",
221
+ { path: file, line }
222
+ );
223
+ }
224
+ // Order matters: a secret is a harder failure than a style rule, and a
225
+ // description containing a URL fails the sentence rule. Refusing the
226
+ // secret first means the operator never sees a style complaint about
227
+ // content they were never allowed to write.
228
+ scanForSecrets(doc, file);
229
+ rejectProhibitedFields(doc, file);
230
+ checkTrimmed(doc, file);
231
+ checkOneSentence(doc.metadata && doc.metadata.description, file);
232
+ const v = bundle.validator(KIND_SCHEMA_ID[kind]);
233
+ const errs = v.validate(doc);
234
+ if (errs) {
235
+ const unknown = errs.find((e) => e.keyword === "additionalProperties");
236
+ throw new AgentDocError(
237
+ unknown ? CODES.UNKNOWN_FIELD : CODES.SCHEMA,
238
+ "descriptor violates the " + kind + " schema: " + formatErrors(errs),
239
+ { path: file, line }
240
+ );
241
+ }
242
+ const name = doc.metadata.name;
243
+ if (!NAME_RE.test(name)) {
244
+ throw new AgentDocError(CODES.NAME_INVALID, "invalid name " + JSON.stringify(name), { path: file, line });
245
+ }
246
+ entities.push({ kind, name, ref: refs.refFor(kind, name), doc, file, line });
247
+ }, errors);
248
+ }
249
+ }
250
+
251
+ const seen = new Map();
252
+ for (const e of entities) {
253
+ const key = e.kind + "/" + e.name;
254
+ if (seen.has(key)) {
255
+ errors.push(new AgentDocError(
256
+ CODES.DUPLICATE_IDENTITY,
257
+ "duplicate " + e.kind + " name '" + e.name + "', also defined in " + seen.get(key),
258
+ { path: e.file, ref: e.ref }
259
+ ));
260
+ } else seen.set(key, e.file);
261
+ }
262
+
263
+ const byRef = new Map(entities.map((e) => [e.ref, e]));
264
+ const byKindName = new Map(entities.map((e) => [e.kind + "/" + e.name, e]));
265
+
266
+ const resolveName = (kind, name, file, field) => {
267
+ if (!byKindName.has(kind + "/" + name)) {
268
+ throw new AgentDocError(CODES.REF_UNRESOLVED, field + " references unknown " + kind + " '" + name + "'", {
269
+ path: file, ref: refs.refFor(kind, name),
270
+ });
271
+ }
272
+ };
273
+
274
+ for (const e of entities) {
275
+ collect(() => {
276
+ const spec = e.doc.spec || {};
277
+ const ctxPaths = [];
278
+ if (e.kind === "System" && spec.domain) resolveName("Domain", spec.domain, e.file, "spec.domain");
279
+ if (e.kind === "Resource" && spec.system) resolveName("System", spec.system, e.file, "spec.system");
280
+ if (e.kind === "Component") {
281
+ if (spec.system) resolveName("System", spec.system, e.file, "spec.system");
282
+ if (spec.domain) resolveName("Domain", spec.domain, e.file, "spec.domain");
283
+ ctxPaths.push(...(spec.context?.docs || []), ...(spec.context?.constraints || []), ...(spec.context?.runbooks || []));
284
+ if (e.kind === "Component" && e.doc.spec.type === "library" && spec.system) {
285
+ // allowed; no extra rule
286
+ }
287
+ }
288
+ if (e.kind === "System" && spec.context) ctxPaths.push(...Object.values(spec.context).flat());
289
+ if (e.kind === "Resource" && spec.context) ctxPaths.push(...Object.values(spec.context).flat());
290
+ for (const p of ctxPaths) {
291
+ assertRepoPath(p, e.file);
292
+ if (!repo.exists(p)) {
293
+ throw new AgentDocError(CODES.PATH_UNRESOLVED, "context link does not resolve: " + p, { path: e.file, ref: e.ref });
294
+ }
295
+ }
296
+ if (e.kind === "API") {
297
+ const prov = spec.provider;
298
+ if (!refs.isValidRef(prov) || refs.kindOfRef(prov) !== "Component") {
299
+ throw new AgentDocError(CODES.REF_INVALID, "spec.provider must be a full component ref", { path: e.file, ref: e.ref });
300
+ }
301
+ if (!byRef.has(prov)) {
302
+ throw new AgentDocError(CODES.REF_UNRESOLVED, "spec.provider does not resolve: " + prov, { path: e.file, ref: e.ref });
303
+ }
304
+ const c = spec.contract;
305
+ if (spec.type === "oidc") {
306
+ if (!c.discoveryPath || !c.discoveryPath.startsWith("/") || /[?#]/.test(c.discoveryPath) || !c.standard) {
307
+ throw new AgentDocError(
308
+ CODES.CONTRACT_FORM,
309
+ "oidc contract requires a standard and an absolute discoveryPath without scheme, host or query",
310
+ { path: e.file, ref: e.ref }
311
+ );
312
+ }
313
+ } else {
314
+ if (!c.ref) {
315
+ throw new AgentDocError(CODES.CONTRACT_FORM, "contract.ref is required for type " + spec.type, { path: e.file, ref: e.ref });
316
+ }
317
+ assertRepoPath(c.ref, e.file);
318
+ if (!repo.exists(c.ref)) {
319
+ throw new AgentDocError(CODES.PATH_UNRESOLVED, "contract.ref does not resolve: " + c.ref, { path: e.file, ref: e.ref });
320
+ }
321
+ }
322
+ for (const cr of spec.consumers || []) {
323
+ if (!byRef.has(cr)) {
324
+ throw new AgentDocError(CODES.REF_UNRESOLVED, "spec.consumers references unknown component " + cr, { path: e.file, ref: e.ref });
325
+ }
326
+ if (cr === spec.provider) {
327
+ throw new AgentDocError(CODES.REF_INVALID, "a provider cannot be listed as its own consumer", { path: e.file, ref: e.ref });
328
+ }
329
+ }
330
+ }
331
+ }, errors);
332
+ }
333
+
334
+ return { entities, byRef, byKindName, componentFiles: new Set(componentFiles), errors, componentSet, centralSet };
335
+ }
336
+
337
+ export function loadJourneys(repo, cfg, bundle, byRef) {
338
+ const errors = [];
339
+ const jPath = cfg.discovery.journeyDefinitions;
340
+ if (!jPath || !repo.exists(jPath)) return { journeys: [], path: jPath || null, errors };
341
+ try {
342
+ const docs = parseYamlDocuments(repo.readText(jPath), jPath);
343
+ if (docs.length !== 1) throw new AgentDocError(CODES.JOURNEY, "journeys file must contain exactly one document", { path: jPath });
344
+ const errs = bundle.validator("agentdoc.dev/schema/journeys.schema.json").validate(docs[0].doc);
345
+ if (errs) throw new AgentDocError(CODES.SCHEMA, "journeys violate their schema: " + formatErrors(errs), { path: jPath });
346
+ scanForSecrets(docs[0].doc, jPath);
347
+ const ids = new Set();
348
+ for (const j of docs[0].doc.journeys) {
349
+ if (ids.has(j.id)) throw new AgentDocError(CODES.JOURNEY, "duplicate journey id '" + j.id + "'", { path: jPath });
350
+ ids.add(j.id);
351
+ for (const ref of j.components) {
352
+ if (!byRef.has(ref)) {
353
+ throw new AgentDocError(CODES.REF_UNRESOLVED, "journey '" + j.id + "' references unknown component " + ref, { path: jPath, ref });
354
+ }
355
+ }
356
+ }
357
+ return { journeys: docs[0].doc.journeys, path: jPath, errors };
358
+ } catch (e) {
359
+ if (e instanceof AgentDocError) { errors.push(e); return { journeys: [], path: jPath, errors }; }
360
+ throw e;
361
+ }
362
+ }
363
+
364
+ export function loadObservations(repo, cfg, bundle, byRef) {
365
+ const errors = [];
366
+ const sets = [];
367
+ const dir = cfg.output.observations;
368
+ if (!repo.exists(dir)) return { sets, errors };
369
+ const files = repo.walk(dir).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")).sort();
370
+ const v = bundle.validator("agentdoc.dev/schema/observation.schema.json");
371
+ for (const file of files) {
372
+ const docs = collect(() => parseYamlDocuments(repo.readText(file), file), errors);
373
+ if (!docs) continue;
374
+ for (const { doc, line } of docs) {
375
+ collect(() => {
376
+ if (doc.apiVersion !== "agentdoc.dev/observation/v1") {
377
+ throw new AgentDocError(CODES.API_VERSION, "apiVersion must be 'agentdoc.dev/observation/v1'", { path: file, line });
378
+ }
379
+ if (doc.kind !== "ObservationSet") {
380
+ throw new AgentDocError(CODES.KIND, "kind must be ObservationSet", { path: file, line });
381
+ }
382
+ const errs = v.validate(doc);
383
+ if (errs) throw new AgentDocError(CODES.SCHEMA, "observation set violates its schema: " + formatErrors(errs), { path: file, line });
384
+ scanForSecrets(doc, file);
385
+ const m = doc.metadata;
386
+ assertRepoPath(m.evidenceBundle, file);
387
+ if (!repo.exists(m.evidenceBundle)) {
388
+ throw new AgentDocError(
389
+ CODES.PATH_UNRESOLVED,
390
+ "observation evidence bundle does not resolve: " + m.evidenceBundle + " (observations must cite a durable, committed, secret-free reference)",
391
+ { path: file }
392
+ );
393
+ }
394
+ const seenFacts = new Set();
395
+ for (const fact of doc.spec.facts) {
396
+ if (!byRef.has(fact.subject)) {
397
+ throw new AgentDocError(
398
+ CODES.REF_UNRESOLVED,
399
+ "observation fact '" + fact.id + "' subject does not resolve: " + fact.subject,
400
+ { path: file, ref: fact.subject }
401
+ );
402
+ }
403
+ if (seenFacts.has(fact.key)) {
404
+ throw new AgentDocError(
405
+ CODES.CONFIG,
406
+ "observation set '" + m.name + "' has two facts with key '" + fact.key + "' on " + fact.subject,
407
+ { path: file, ref: fact.subject }
408
+ );
409
+ }
410
+ seenFacts.add(fact.key);
411
+ }
412
+ sets.push({ doc, file, line, meta: m, facts: doc.spec.facts });
413
+ }, errors);
414
+ }
415
+ }
416
+ return { sets, errors };
417
+ }
418
+
419
+ export function loadReviewedOverrides(repo, cfg, refs, byRef) {
420
+ const errors = [];
421
+ const out = [];
422
+ for (const o of cfg.reviewedOverrides || []) {
423
+ collect(() => {
424
+ if (!refs.isValidRef(o.subject)) {
425
+ throw new AgentDocError(CODES.REF_INVALID, "reviewed override subject is not a canonical entity ref: " + o.subject, { path: CONFIG_PATH });
426
+ }
427
+ if (!byRef.has(o.subject)) {
428
+ throw new AgentDocError(CODES.REF_UNRESOLVED, "reviewed override subject does not resolve: " + o.subject, { path: CONFIG_PATH, ref: o.subject });
429
+ }
430
+ if (o.target !== undefined && o.fact !== "external-dependency-role") {
431
+ if (!refs.isValidRef(o.target)) {
432
+ throw new AgentDocError(CODES.REF_INVALID, "reviewed override target is not a canonical entity ref: " + o.target, { path: CONFIG_PATH });
433
+ }
434
+ if (!byRef.has(o.target)) {
435
+ throw new AgentDocError(CODES.REF_UNRESOLVED, "reviewed override target does not resolve: " + o.target, { path: CONFIG_PATH, ref: o.target });
436
+ }
437
+ }
438
+ // The digests are pinned by the author and re-verified here, on every
439
+ // compile. A REVIEWED_OVERRIDE is the highest-authority input the model
440
+ // accepts and the only thing that can falsify it is the evidence it cites.
441
+ // If that evidence is rewritten and the override survives, a stale human
442
+ // judgement is silently promoted to fact, and the graph ships a digest
443
+ // that matches nothing. This is the same discipline `warningAcceptances`
444
+ // applies, for the same reason.
445
+ const seenEvidence = new Set();
446
+ for (const ev of o.evidence) {
447
+ assertRepoPath(ev.path, CONFIG_PATH);
448
+ if (seenEvidence.has(ev.path)) {
449
+ throw new AgentDocError(CODES.OVERRIDE_STALE, "duplicate reviewed override evidence path: " + ev.path, { path: CONFIG_PATH });
450
+ }
451
+ seenEvidence.add(ev.path);
452
+ if (!repo.exists(ev.path)) {
453
+ throw new AgentDocError(CODES.PATH_UNRESOLVED, "reviewed override evidence does not resolve: " + ev.path, { path: CONFIG_PATH });
454
+ }
455
+ const actual = sha256Hex(repo.readBytes(ev.path));
456
+ if (actual !== ev.sha256) {
457
+ throw new AgentDocError(
458
+ CODES.OVERRIDE_STALE,
459
+ "reviewed override evidence changed: " + ev.path + " — review the interpretation before updating its digest",
460
+ { path: CONFIG_PATH, ref: o.subject }
461
+ );
462
+ }
463
+ }
464
+ // Evidence must live under a mapped component's source root or be the
465
+ // declared contract. This is what stops a reviewed override from
466
+ // asserting something no file in the repository can corroborate.
467
+ const subjectRoot = path.posix.dirname(byRef.get(o.subject).file);
468
+ const targetRoot = o.target && o.fact !== "external-dependency-role" && byRef.has(o.target)
469
+ ? path.posix.dirname(byRef.get(o.target).file)
470
+ : null;
471
+ for (const ev of o.evidence) {
472
+ const p = ev.path;
473
+ const scoped = p === o.contractRef || p.startsWith(subjectRoot + "/") || (targetRoot && p.startsWith(targetRoot + "/"));
474
+ if (!scoped) {
475
+ throw new AgentDocError(
476
+ CODES.RELATION_KIND,
477
+ "reviewed override evidence must be the declared contract or live under a mapped component source root: " + p,
478
+ { path: CONFIG_PATH }
479
+ );
480
+ }
481
+ }
482
+ out.push(o);
483
+ }, errors);
484
+ }
485
+ return { overrides: out, errors };
486
+ }
@@ -0,0 +1,95 @@
1
+ // Determinism guard.
2
+ //
3
+ // The compiled graph must be a pure function of the checkout. A timestamp
4
+ // generated at compile time, an absolute host path, a username or a process id
5
+ // would all silently break byte-stability and freshness, so the serialized
6
+ // output is scanned for machine-specific shapes and compilation fails.
7
+ //
8
+ // Time that genuinely belongs to an observation is allowed, but only when it
9
+ // appears in the declared allowed set — the capturedAt values read out of
10
+ // committed ObservationSet files. That makes the check stronger rather than
11
+ // weaker: it proves no wall-clock value from the compiling machine leaked in,
12
+ // while a sourced observation timestamp still passes.
13
+ import { AgentDocError, CODES } from "./codes.mjs";
14
+ import { homedir, tmpdir } from "node:os";
15
+
16
+ function escapeRe(s) {
17
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
18
+ }
19
+
20
+ const TIMESTAMP_RE = /\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\b/g;
21
+
22
+ export function buildMachinePatterns(extra = []) {
23
+ const pats = [
24
+ // A value that STARTS with a well-known system root. Anchoring matters: an
25
+ // unanchored pattern also matches a repository that happens to contain a
26
+ // directory called `root/`, `opt/` or `home/`, which would make those
27
+ // repositories impossible to compile.
28
+ { re: /"(?:\/[A-Za-z]:\\|\\\\)[^"]*"/g, why: "absolute Windows path" },
29
+ { re: /"(?:\/home\/|\/Users\/|\/root\/|\/var\/folders\/|\/private\/tmp\/|\/opt\/)[^"]*"/g, why: "absolute host path" },
30
+ { re: /\bprocess\.pid\b/, why: "process identity" },
31
+ { re: /\bnode_modules\/\.\.(?:\/)?/, why: "escaped path" },
32
+ ];
33
+ for (const d of [homedir(), tmpdir()].filter(Boolean)) {
34
+ if (d && d !== "/") pats.push({ re: new RegExp(escapeRe(d) + "[^\\s\"']*", "g"), why: "host-specific path" });
35
+ }
36
+ for (const e of extra) pats.push(e);
37
+ return pats;
38
+ }
39
+
40
+ export function scanMachineValues(text, extra = []) {
41
+ const out = [];
42
+ for (const p of buildMachinePatterns(extra)) {
43
+ p.re.lastIndex = 0;
44
+ const m = p.re.exec(text);
45
+ if (m) out.push({ why: p.why, sample: String(m[0]).slice(0, 120) });
46
+ }
47
+ return out;
48
+ }
49
+
50
+ // Free-text fields are opaque values copied from committed files. They are a
51
+ // pure function of the checkout by construction, and they legitimately contain
52
+ // anything a repository author wrote — including a /tmp path in a CI script. The
53
+ // guard therefore inspects the graph's *structure*: refs, ids, paths, hashes,
54
+ // keys, provenance and the source block. A compiler-introduced machine value
55
+ // can only appear there.
56
+ export const FREE_TEXT_KEYS = new Set([
57
+ "description", "semantics", "detail", "reason", "reviewWhen", "command", "role",
58
+ "notes", "rationale", "value", "locator", "summary", "provenance", "title",
59
+ "reviewer", "collectedAt",
60
+ ]);
61
+
62
+ export function structuralProjection(node, key) {
63
+ if (node === null || typeof node !== "object") return FREE_TEXT_KEYS.has(key) ? "x" : node;
64
+ if (Array.isArray(node)) return node.map((v) => structuralProjection(v, key));
65
+ const out = {};
66
+ for (const [k, v] of Object.entries(node)) out[k] = structuralProjection(v, k);
67
+ return out;
68
+ }
69
+
70
+ export function assertDeterministic(text, { allowedTimestamps = [], extra = [], what = "graph", structural = null } = {}) {
71
+ const haystack = structural === null ? text : JSON.stringify(structural, null, 2);
72
+ const found = scanMachineValues(haystack, extra);
73
+ // Every timestamp in the output must be one the compiler read from a
74
+ // committed source file. Anything else came from the compiling machine.
75
+ const allowed = new Set(allowedTimestamps);
76
+ const bad = new Set();
77
+ let m;
78
+ TIMESTAMP_RE.lastIndex = 0;
79
+ while ((m = TIMESTAMP_RE.exec(haystack)) !== null) {
80
+ if (!allowed.has(m[0])) bad.add(m[0]);
81
+ }
82
+ if (bad.size) {
83
+ found.push({
84
+ why: "timestamp not present in any committed source (" + [...bad].slice(0, 3).join(", ") + ")",
85
+ sample: [...bad][0],
86
+ });
87
+ }
88
+ if (found.length) {
89
+ throw new AgentDocError(
90
+ CODES.NONDETERMINISTIC,
91
+ "compiled " + what + " contains machine-specific values, which would break byte-stability: " +
92
+ found.map((f) => f.why + " (" + f.sample + ")").join("; ")
93
+ );
94
+ }
95
+ }