agent-doc-system 1.2.0 → 1.4.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 (47) hide show
  1. package/CHANGELOG.md +188 -1
  2. package/README.md +1 -1
  3. package/adapters/cache.mjs +5 -4
  4. package/adapters/database.mjs +155 -6
  5. package/adapters/deploy-config.mjs +3 -2
  6. package/adapters/dockerfile.mjs +3 -2
  7. package/adapters/generic.mjs +2 -1
  8. package/adapters/github-actions.mjs +2 -1
  9. package/adapters/go.mjs +4 -3
  10. package/adapters/node.mjs +3 -2
  11. package/adapters/rust.mjs +3 -0
  12. package/adapters/turborepo.mjs +3 -2
  13. package/adapters/typescript.mjs +7 -5
  14. package/adapters/wrangler.mjs +3 -2
  15. package/bin/agentdoc.mjs +9 -2
  16. package/bin/usage.txt +2 -0
  17. package/core/audit.mjs +7 -5
  18. package/core/codes.mjs +2 -0
  19. package/core/compile.mjs +12 -6
  20. package/core/derive.mjs +4 -3
  21. package/core/descriptors.mjs +52 -2
  22. package/core/discover.mjs +3 -2
  23. package/core/fsx.mjs +174 -42
  24. package/core/graph.mjs +1 -1
  25. package/core/impact.mjs +84 -14
  26. package/core/paths.mjs +71 -0
  27. package/core/query.mjs +58 -23
  28. package/core/scaffold.mjs +130 -23
  29. package/core/sourcescan.mjs +4 -1
  30. package/docs/ADAPTERS.md +49 -5
  31. package/docs/BENCHMARK.md +83 -21
  32. package/docs/CI.md +9 -1
  33. package/docs/EVALS.md +209 -17
  34. package/docs/MIGRATION.md +22 -4
  35. package/docs/PROVENANCE.md +6 -0
  36. package/docs/QUERY.md +50 -7
  37. package/docs/SPEC.md +89 -2
  38. package/docs/ci/github-actions.yml +1 -1
  39. package/evals/baseline-search.mjs +272 -0
  40. package/evals/facts.mjs +28 -0
  41. package/evals/harness.mjs +120 -116
  42. package/package.json +4 -2
  43. package/schemas/resource.schema.json +9 -1
  44. package/skills/agent-doc-system/SKILL.md +2 -1
  45. package/skills/agent-doc-system/references/cli.md +6 -1
  46. package/skills/agent-doc-system/references/create-migrate.md +8 -2
  47. package/templates/ci.yml +1 -1
package/core/query.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  import { AgentDocError, CODES } from "./codes.mjs";
11
11
  import { assertNoSecretsInText } from "./secrets.mjs";
12
12
  import { verificationForChange } from "./dependents.mjs";
13
+ import { canonicalPath, longestOwner, ownsPath, REPO_ROOT } from "./paths.mjs";
13
14
 
14
15
  const DEFAULT_BUDGET = {
15
16
  relations: 40,
@@ -28,13 +29,13 @@ export class ContextIndex {
28
29
  constructor(graph) {
29
30
  this.graph = graph;
30
31
  this.byRef = new Map(graph.entities.map((e) => [e.ref, e]));
32
+ // Keyed by canonical source path, so a root unit's "." and any other
33
+ // spelling of the same directory land on one key.
31
34
  this.byPath = new Map();
32
35
  for (const e of graph.entities) {
33
- const sp = e.derived && e.derived.sourcePath;
34
- if (!sp) continue;
36
+ const sp = e.derived && canonicalPath(e.derived.sourcePath);
37
+ if (sp === null || sp === undefined) continue;
35
38
  this.byPath.set(sp, e);
36
- // Longest path first so a nested path resolves to its own component
37
- // rather than to a parent.
38
39
  }
39
40
  // Documentation and constraint files a descriptor links resolve to their
40
41
  // component, even when they live outside the source root. Following a
@@ -49,9 +50,12 @@ export class ContextIndex {
49
50
  ...(e.derived || {}).constraints || [],
50
51
  ...(e.derived || {}).runbooks || [],
51
52
  ];
52
- for (const p of ctxPaths) if (!this.byDoc.has(p)) this.byDoc.set(p, e);
53
+ for (const raw of ctxPaths) {
54
+ const p = canonicalPath(raw) ?? raw;
55
+ if (!this.byDoc.has(p)) this.byDoc.set(p, e);
56
+ }
53
57
  }
54
- this.paths = [...this.byPath.keys()].sort((a, b) => b.length - a.length);
58
+ this.paths = [...this.byPath.keys()];
55
59
  this.relationsByRef = new Map();
56
60
  for (const r of graph.relations) {
57
61
  if (!this.relationsByRef.has(r.sourceRef)) this.relationsByRef.set(r.sourceRef, []);
@@ -62,34 +66,64 @@ export class ContextIndex {
62
66
  }
63
67
 
64
68
  // A contract file resolves to the API entity that claims it, so "change this
65
- // endpoint" routes to the boundary rather than to nothing.
69
+ // endpoint" routes to the boundary rather than to nothing. A contract ref
70
+ // names a file, a directory or a module prefix (`events` covering
71
+ // `events.go`, `events.proto`), the same coverage `Repo.filesWithPrefix`
72
+ // gives it — never a raw string prefix such as `events2/x`.
66
73
  contractFor(needle) {
67
- const norm = needle.replace(/^\.\//, "").replace(/\/+$/, "");
74
+ const norm = canonicalPath(needle);
75
+ if (norm === null) return null;
68
76
  let best = null;
69
77
  for (const c of (this.graph.contracts || [])) {
70
- if (norm === c.ref || norm.startsWith(c.ref)) {
78
+ if (contractCovers(c.ref, norm)) {
71
79
  if (!best || c.ref.length > best.ref.length) best = c;
72
80
  }
73
81
  }
74
82
  return best;
75
83
  }
84
+ // The component whose source root most specifically owns a repository path:
85
+ // the longest owning sourcePath, with a root component (".") as the
86
+ // lowest-precedence fallback. Null when no component owns the path.
87
+ ownerOf(file) {
88
+ const p = longestOwner(this.paths, file);
89
+ return p === null ? null : this.byPath.get(p);
90
+ }
76
91
  resolve(needle) {
77
92
  if (this.byRef.has(needle)) return this.byRef.get(needle);
78
- const norm = needle.replace(/^\.\//, "").replace(/\/+$/, "");
93
+ if (looksLikeRef(needle)) return null;
94
+ // One canonical spelling for every path: `./a/b`, `a//b` and `a/b/` are
95
+ // all `a/b`. A path escaping the repository resolves to nothing.
96
+ const norm = canonicalPath(needle);
97
+ if (norm === null) return null;
79
98
  if (this.byDoc.has(norm)) return this.byDoc.get(norm);
80
- if (this.byPath.has(norm)) return this.byPath.get(norm);
81
- for (const p of this.paths) {
82
- if (norm === p || norm.startsWith(p + "/")) return this.byPath.get(p);
83
- }
84
- for (const p of this.paths) {
85
- if (needle.startsWith(p + "/")) return this.byPath.get(p);
86
- }
87
- if (this.byDoc.has(needle)) return this.byDoc.get(needle);
88
- const c = this.contractFor(needle);
89
- return c ? this.byRef.get(c.apiRef) || null : null;
99
+ // Precedence, most specific first: a linked document, a component source
100
+ // root, a contract's API, and only then a root component. The root owns
101
+ // every path, so it is the fallback for files nothing else claims; it
102
+ // must not take a contract file away from the API that declares it.
103
+ const owner = this.ownerOf(norm);
104
+ if (owner && canonicalPath(owner.derived.sourcePath) !== REPO_ROOT) return owner;
105
+ const c = this.contractFor(norm);
106
+ const api = c ? this.byRef.get(c.apiRef) || null : null;
107
+ return api || owner || null;
90
108
  }
91
109
  }
92
110
 
111
+ export function contractCovers(ref, file) {
112
+ const r = canonicalPath(ref);
113
+ const f = canonicalPath(file);
114
+ if (r === null || f === null) return false;
115
+ if (ownsPath(r, f)) return true;
116
+ const cut = r.lastIndexOf("/");
117
+ const dir = r.slice(0, cut + 1);
118
+ return f.startsWith(dir) && f.slice(dir.length).startsWith(r.slice(cut + 1) + ".");
119
+ }
120
+
121
+ // An entity ref is `kind:namespace/name`. A needle shaped like one that names
122
+ // no entity is an unresolved ref, not a repository path a root component owns.
123
+ function looksLikeRef(needle) {
124
+ return /^[A-Za-z][\w-]*:/.test(needle);
125
+ }
126
+
93
127
  function contractsOf(graph) {
94
128
  return graph.contracts || [];
95
129
  }
@@ -136,14 +170,15 @@ export function queryContext(graph, needle, opts = {}) {
136
170
  const apiProvider = entity.kind === "API" ? providerOf(ref) : null;
137
171
 
138
172
  const apiRefs = new Set([...providedApis, ...consumedApis]);
139
- const srcPath = (entity.derived && entity.derived.sourcePath) || null;
140
173
  const events = g.interfaces.events.filter(
141
174
  (e) =>
142
175
  e.producers.includes(ref) ||
143
176
  e.consumers.includes(ref) ||
144
177
  // A contract library that ships the subject catalog owns those subjects,
145
- // even when another component is the one that subscribes to them.
146
- (srcPath && e.contract && e.contract.ref.startsWith(srcPath + "/"))
178
+ // even when another component is the one that subscribes to them. The
179
+ // owner is the component that most specifically owns the contract file,
180
+ // so a root component does not claim a nested library's catalog.
181
+ (e.contract && e.contract.ref && idx.ownerOf(e.contract.ref) === entity)
147
182
  );
148
183
  const externals = g.externalDependencies.filter((e) => e.componentRef === ref);
149
184
  const capabilities = g.capabilities.filter((e) => e.componentRef === ref);
package/core/scaffold.mjs CHANGED
@@ -13,7 +13,8 @@ import fs from "node:fs";
13
13
  import path from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { AgentDocError, CODES } from "./codes.mjs";
16
- import { Repo } from "./fsx.mjs";
16
+ import { Repo, assertRepoPath } from "./fsx.mjs";
17
+ import { CONFIG_PATH } from "./descriptors.mjs";
17
18
  import { discoverUnits, isNotUnitRoot } from "./discover.mjs";
18
19
  import { classifyContractFile } from "./contracts.mjs";
19
20
  import { parse as parseToml } from "./toml.mjs";
@@ -45,23 +46,96 @@ function sentence(s) {
45
46
  return /[.!?]$/.test(t) ? t : t + ".";
46
47
  }
47
48
 
48
- export function installTemplates(repo, cfg, { write, root, force = false, eligible = null } = {}) {
49
+ // Where the documentation skeleton goes when no configuration exists yet. Once
50
+ // a configuration exists, it is the only authority: `docs.*` is what audit and
51
+ // compile read, so the scaffolder writes there and nowhere else (issue #49 —
52
+ // `docs/` is often the product's own user documentation).
53
+ //
54
+ // The default lives under `agentdoc/` (issue #59): authored AgentDoc material
55
+ // lives in `agentdoc/`, generated output in `.agentdoc/`. Not `docs/`, which is
56
+ // the product's user documentation (and a MkDocs/Docusaurus/Pages source), and
57
+ // not `.agentdoc/`, which the compiler skips and many repositories git-ignore.
58
+ export const DEFAULT_DOCS = Object.freeze({
59
+ product: "agentdoc/docs/PRODUCT.md",
60
+ architecture: "agentdoc/docs/ARCHITECTURE.md",
61
+ constraints: "agentdoc/docs/CONSTRAINTS.md",
62
+ adrDir: "agentdoc/docs/adr",
63
+ });
64
+
65
+ // The skeleton roles that have a template. `runbookDir` and `normative` are
66
+ // honoured by never writing anything for them: there is no runbook template,
67
+ // and normative documents are the project's own.
68
+ const SKELETON_ROLES = [
69
+ { key: "product", template: "PRODUCT.md", file: (p) => p },
70
+ { key: "architecture", template: "ARCHITECTURE.md", file: (p) => p },
71
+ { key: "constraints", template: "CONSTRAINTS.md", file: (p) => p },
72
+ { key: "adrDir", template: "ADR.md", file: (p) => p.replace(/\/+$/, "") + "/README.md" },
73
+ ];
74
+
75
+ // Resolve the documentation skeleton for a loaded configuration (`null` when
76
+ // there is none). A configured role is written to its configured path; a role
77
+ // a configuration leaves out is not written anywhere and is reported, so an
78
+ // omitted role can never fall back into `docs/`.
79
+ export function docsSkeleton(cfg) {
80
+ const docs = cfg ? cfg.docs || {} : DEFAULT_DOCS;
81
+ const targets = [];
82
+ const notConfigured = [];
83
+ for (const role of SKELETON_ROLES) {
84
+ const configured = docs[role.key];
85
+ if (!configured) { notConfigured.push("docs." + role.key); continue; }
86
+ // loadConfig already validated these; re-asserting keeps this function
87
+ // safe for any caller that hands it an unvalidated object.
88
+ assertRepoPath(configured, CONFIG_PATH);
89
+ const rel = role.file(configured);
90
+ assertRepoPath(rel, CONFIG_PATH);
91
+ targets.push({ key: role.key, path: rel, template: role.template });
92
+ }
93
+ return { targets, notConfigured, docs: cfg ? docs : DEFAULT_DOCS };
94
+ }
95
+
96
+ // Refuse a write target that leaves the repository, including through a
97
+ // symlinked directory or a (possibly dangling) symlink at the target itself.
98
+ // Lexical checks alone (`isRepoPath`) cannot see a symlink.
99
+ function assertWritableInside(root, rel) {
100
+ const abs = path.resolve(root, rel);
101
+ const inside = path.relative(path.resolve(root), abs);
102
+ if (!inside || inside === ".." || inside.startsWith(".." + path.sep) || path.isAbsolute(inside)) {
103
+ throw new AgentDocError(CODES.PATH_ESCAPE, "path escapes repository: " + rel, { path: rel });
104
+ }
105
+ const realRoot = fs.realpathSync(root);
106
+ let cur = path.resolve(root);
107
+ for (const seg of inside.split(path.sep)) {
108
+ cur = path.join(cur, seg);
109
+ let st;
110
+ try { st = fs.lstatSync(cur); } catch { return; } // nothing further exists yet
111
+ if (!st.isSymbolicLink()) continue;
112
+ let real = null;
113
+ try { real = fs.realpathSync(cur); } catch { /* dangling */ }
114
+ if (real === null || (real !== realRoot && !real.startsWith(realRoot + path.sep))) {
115
+ throw new AgentDocError(CODES.PATH_ESCAPE, "refusing to write through a symlink that leaves the repository: " + rel, { path: rel });
116
+ }
117
+ }
118
+ }
119
+
120
+ // `cfg` is the loaded configuration (scaffold) or null (init). `docsConfig`
121
+ // overrides the configuration the documentation paths are read from: init
122
+ // writes no component descriptors, but it must still honour an existing
123
+ // configuration's `docs.*` paths.
124
+ export function installTemplates(repo, cfg, { write, root, force = false, eligible = null, docsConfig } = {}) {
49
125
  const written = [];
50
126
  const skipped = [];
51
127
  const targetRoot = root || (repo ? repo.root : process.cwd());
52
128
  const r = repo || new Repo(targetRoot, { noGit: false });
129
+ const docsSource = docsConfig === undefined ? cfg : docsConfig;
130
+ const skeleton = docsSkeleton(docsSource);
53
131
 
54
- const put = (rel, content) => {
55
- const abs = path.join(targetRoot, rel);
56
- if (fs.existsSync(abs) && !force) { skipped.push(rel); return; }
57
- if (!write) { written.push(rel + " (dry run)"); return; }
58
- fs.mkdirSync(path.dirname(abs), { recursive: true });
59
- fs.writeFileSync(abs, content.endsWith("\n") ? content : content + "\n");
60
- written.push(rel);
61
- };
132
+ // Plan every file first and check every target before writing anything, so
133
+ // a refused path leaves the repository exactly as it was.
134
+ const plan = [];
135
+ const put = (rel, content) => { plan.push([rel, content]); };
62
136
 
63
137
  // ── configuration ───────────────────────────────────────────────────────
64
- const config = buildConfig(scanLayout(r, cfg));
138
+ const config = buildConfig(scanLayout(r, cfg), skeleton.docs);
65
139
  put("agentdoc/agentdoc.config.yaml", config);
66
140
  put("agentdoc/journeys.yaml", "schemaVersion: agentdoc.dev/journeys/v1\njourneys: []\n");
67
141
  for (const f of ["domains.yaml", "systems.yaml", "apis.yaml", "resources.yaml"]) {
@@ -69,12 +143,10 @@ export function installTemplates(repo, cfg, { write, root, force = false, eligib
69
143
  }
70
144
  put("agentdoc/observations/README.md", OBSERVATIONS_README);
71
145
 
72
- // ── documentation hierarchy ──────────────────────────────────────────────
73
- for (const f of ["PRODUCT.md", "ARCHITECTURE.md", "CONSTRAINTS.md"]) {
74
- const tpl = fs.readFileSync(path.join(TEMPLATES_DIR, f), "utf8");
75
- put("docs/" + f, tpl);
146
+ // ── documentation hierarchy (configured docs.* paths) ───────────────────
147
+ for (const t of skeleton.targets) {
148
+ put(t.path, fs.readFileSync(path.join(TEMPLATES_DIR, t.template), "utf8"));
76
149
  }
77
- put("docs/adr/README.md", fs.readFileSync(path.join(TEMPLATES_DIR, "ADR.md"), "utf8"));
78
150
  put(".github/workflows/agentdoc.yml", fs.readFileSync(path.join(TEMPLATES_DIR, "ci.yml"), "utf8"));
79
151
 
80
152
  // ── per-unit component descriptors ──────────────────────────────────────
@@ -88,7 +160,30 @@ export function installTemplates(repo, cfg, { write, root, force = false, eligib
88
160
  put(u.root + "/" + cfg.discovery.componentDescriptorName, componentDescriptor(u));
89
161
  }
90
162
  }
91
- return { written, skipped, note: write ? "files written" : "dry run: pass --write to apply" };
163
+
164
+ const seen = new Set();
165
+ for (const [rel] of plan) assertWritableInside(targetRoot, rel);
166
+ for (const [rel, content] of plan) {
167
+ if (seen.has(rel)) continue; // two roles configured onto one file: first wins
168
+ seen.add(rel);
169
+ const abs = path.join(targetRoot, rel);
170
+ if (fs.existsSync(abs) && !force) { skipped.push(rel); continue; }
171
+ if (!write) { written.push(rel + " (dry run)"); continue; }
172
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
173
+ fs.writeFileSync(abs, content.endsWith("\n") ? content : content + "\n");
174
+ written.push(rel);
175
+ }
176
+ return {
177
+ written,
178
+ skipped,
179
+ ...(docsSource && skeleton.notConfigured.length
180
+ ? {
181
+ notConfigured: skeleton.notConfigured,
182
+ notConfiguredNote: "the configuration declares no path for these roles, so no skeleton was written for them; add them under docs: to scaffold them",
183
+ }
184
+ : {}),
185
+ note: write ? "files written" : "dry run: pass --write to apply",
186
+ };
92
187
  }
93
188
 
94
189
  // Layout inference for CREATE mode: which directories look like unit roots.
@@ -108,6 +203,7 @@ function scanLayout(repo, cfg) {
108
203
  for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
109
204
  if (e.isSymbolicLink() || skip.has(e.name)) continue;
110
205
  const rel = dir ? dir + "/" + e.name : e.name;
206
+ if (repo.isIgnored && repo.isIgnored(rel)) continue;
111
207
  if (e.isDirectory()) {
112
208
  if (e.name.startsWith(".")) continue;
113
209
  walk(rel, depth + 1);
@@ -269,7 +365,7 @@ const ADAPTER_FOR_MARKER = {
269
365
  platform: "wrangler",
270
366
  };
271
367
 
272
- function buildConfig(layout) {
368
+ function buildConfig(layout, docs = DEFAULT_DOCS) {
273
369
  const roots = layout.map((l) => l.root);
274
370
  const groups = new Map();
275
371
  for (const r of roots) {
@@ -346,15 +442,26 @@ function buildConfig(layout) {
346
442
  " graph: .agentdoc/graph.json",
347
443
  " observations: agentdoc/observations",
348
444
  "",
349
- "docs:",
350
- " product: docs/PRODUCT.md",
351
- " architecture: docs/ARCHITECTURE.md",
352
- " constraints: docs/CONSTRAINTS.md",
353
- " adrDir: docs/adr",
445
+ ...docsSection(docs),
354
446
  "",
355
447
  ].join("\n");
356
448
  }
357
449
 
450
+ // The `docs:` block of a generated configuration. It repeats the configured
451
+ // paths (init --force regenerates the file and must not move documentation
452
+ // elsewhere), or the agentdoc/docs/ defaults when there is no configuration yet.
453
+ function docsSection(docs) {
454
+ const scalar = (v) => (/^[A-Za-z0-9._/-]+$/.test(v) ? v : yamlStr(v));
455
+ const lines = [];
456
+ for (const key of ["product", "architecture", "constraints", "adrDir", "runbookDir"]) {
457
+ if (docs[key]) lines.push(" " + key + ": " + scalar(docs[key]));
458
+ }
459
+ if (Array.isArray(docs.normative) && docs.normative.length) {
460
+ lines.push(" normative:", ...docs.normative.map((g) => " - " + yamlStr(g)));
461
+ }
462
+ return lines.length ? ["docs:", ...lines] : ["# docs: {} # no documentation roles configured"];
463
+ }
464
+
358
465
  const OBSERVATIONS_README = `# Runtime observations
359
466
 
360
467
  An ObservationSet records what an authoritative external system actually said,
@@ -7,6 +7,7 @@
7
7
  // knows the namespace convention.
8
8
  import path from "node:path";
9
9
  import fs from "node:fs";
10
+ import { joinPath } from "./paths.mjs";
10
11
 
11
12
  const SPECIFIER_RE = /["'`]([A-Za-z@][A-Za-z0-9@._/+-]*(?:\.[A-Za-z0-9@._/+-]+)*(?:\/[A-Za-z0-9@._+*-]+)*)["'`]/g;
12
13
 
@@ -66,6 +67,7 @@ export function findMigrationDirs(repo, root, maxDepth = 5) {
66
67
  const abs = path.join(dirAbs, e.name);
67
68
  const rel = path.relative(repo.root, abs).split(path.sep).join("/");
68
69
  if (!repo.ownsUnitPath(root, rel)) continue;
70
+ if (repo.isIgnored && repo.isIgnored(rel)) continue;
69
71
  if (e.name === "migrations" || e.name === "migration") {
70
72
  out.push(rel);
71
73
  continue;
@@ -86,7 +88,8 @@ export function findMigrationDirs(repo, root, maxDepth = 5) {
86
88
 
87
89
  export function firstExistingFile(repo, root, names) {
88
90
  for (const n of names) {
89
- if (repo.ownsUnitPath(root, root + "/" + n) && repo.exists(root + "/" + n)) return root + "/" + n;
91
+ const p = joinPath(root, n);
92
+ if (repo.ownsUnitPath(root, p) && repo.exists(p)) return p;
90
93
  }
91
94
  return null;
92
95
  }
package/docs/ADAPTERS.md CHANGED
@@ -107,7 +107,7 @@ cannot name the file a fact came from, you cannot add the fact.
107
107
  | `dockerfile` | `Dockerfile`, `Containerfile` | deployable evidence |
108
108
  | `deploy-config` | `fly.toml`, `Procfile`, … | deployable evidence |
109
109
  | `wrangler` | `wrangler.toml/json` | bindings, crons, service bindings, trigger targets |
110
- | `database` | migrations, ORM config, pool evidence | logical-database ownership, or a candidate |
110
+ | `database` | migrations, ORM config, pool evidence | logical-database ownership, or a candidate; the unit's migration directories |
111
111
  | `object-store` | bucket literals | logical-dataset edges, or an ambiguity |
112
112
  | `cache` | cache client evidence | cache bindings |
113
113
  | `events` | subject literals, declared constants, event-contract files | producers, consumers, reserved subjects |
@@ -220,7 +220,7 @@ Workspace roots come from every `Cargo.toml` that has `[workspace]`, not only
220
220
  the one at the repository root. The search is bounded to five directories
221
221
  down, stays inside the repository, and does not enter `discovery.notUnitRoots`
222
222
  or their descendants (the authored-descriptor exception is described below),
223
- `discovery.generatedDirs`, dot-directories, or `test` / `tests` / `examples` /
223
+ `discovery.generatedDirs`, git-ignored directories, dot-directories, or `test` / `tests` / `examples` /
224
224
  `example`. `members` minus `exclude` are still relative to the manifest that
225
225
  declares them; `*` matches one path segment, and a member that leaves the
226
226
  repository is ignored. `workspace = true` resolves against the nearest
@@ -312,6 +312,45 @@ the result is `AGENTDOC_BINDING_AMBIGUOUS` and no edge.
312
312
  file pulled in with `-r` / `-c`; a lockfile's resolved versions; a PDM
313
313
  workspace; a workspace manifest deeper than the search bound.
314
314
 
315
+ ### `database`
316
+
317
+ Migration authority and a connection string in one unit resolve to an authored
318
+ logical-database Resource (by name: `<component>-postgres`, `-database`,
319
+ `-db`, or the single Resource whose name starts with the component's).
320
+ Migrations alone give a candidate and `AGENTDOC_RESOURCE_CANDIDATE`, never an
321
+ edge.
322
+
323
+ The adapter also records where the unit's migrations live, as
324
+ `derived.migrationDirs` (`path`, `via`, `provenanceIds`). `impact` classifies a
325
+ change under one of them as a resource change. Two routes, each with its own
326
+ evidence:
327
+
328
+ | `via` | directory | evidence |
329
+ |---|---|---|
330
+ | `directory-name` | a directory named `migrations` or `migration` inside the unit, at most five levels down; Prisma Migrate's `prisma/migrations` is one | the first file in it |
331
+ | `alembic-versions` | `alembic/versions` | the first file in it |
332
+ | `drizzle-config` | Drizzle Kit's `out` in the unit-root `drizzle.config.ts`, `.js` or `.mjs`; `./drizzle` when `out` is absent | the config file |
333
+
334
+ The Drizzle config is code and is never executed. `out` is read only as a
335
+ single string literal (a template literal without `${...}` counts), after
336
+ comments are removed, and resolved against the config's directory. An `out`
337
+ that is an expression, an environment variable, a shorthand property, set more
338
+ than once, or possibly brought in by a spread is not guessed: the adapter
339
+ records nothing and reports `AGENTDOC_MIGRATION_DIR_UNRESOLVED`, a warning that
340
+ can be accepted. An `out` outside the unit is reported the same way. An `out`
341
+ directory that does not exist yet records nothing; the existence probe is an
342
+ input, so generating the first migration invalidates the graph.
343
+
344
+ A recorded directory is migration authority, not ownership: it creates no
345
+ binding. A migration change reaches a database only through the enclosing
346
+ component's existing `usesResource` relations, or through a Resource that
347
+ declares the directory in `spec.migrationPaths` (see `SPEC.md`).
348
+
349
+ **What it cannot see:** a Drizzle config passed with `--config` under another
350
+ name, a config below the unit root, and the migration layouts of Knex, Goose,
351
+ Sequelize or TypeORM outside a `migrations` directory. Declare those with
352
+ `spec.migrationPaths`.
353
+
315
354
  `asyncapi` is a contract **type** (declared on an API descriptor, validated by
316
355
  the contract layer), not an adapter — there is no `asyncapi` entry in
317
356
  `adapters: [...]`.
@@ -373,9 +412,14 @@ places, and those are the honest exceptions worth naming:
373
412
  part of the input hash. A repository that does not enable `python` is not
374
413
  probed, and its graph does not change.
375
414
  - `core/impact.mjs` classifies a changed file by consulting the compiled graph —
376
- its contracts, its provenance paths, its extractor identities — rather than a
377
- hand-written list of technology filenames. A list would silently under-report
378
- for every repository that is not Node-and-Go.
415
+ its contracts, its provenance paths, its extractor identities, its declared
416
+ and detected migration directories — rather than a hand-written list of
417
+ technology filenames. A list would silently under-report for every
418
+ repository that is not Node-and-Go. One name rule remains as a fallback: a
419
+ path under a `migrations/`, `migration/` or `alembic/versions/` segment is a
420
+ resource change even where the graph records no migration directory (the
421
+ `database` adapter is off, or the directory is deeper than its walk), so
422
+ those repositories keep the answer they had.
379
423
  - `core/discover.mjs` excludes `agentdoc`, `docs`, `scripts`, `node_modules`, and
380
424
  `.github` by default. `discovery.notUnitRoots` replaces that list (`[]`
381
425
  disables it). These roots and their descendants cannot be inferred from
package/docs/BENCHMARK.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # BENCHMARK
2
2
 
3
- Two measurements back the numbers in the README. Both are reproducible from a
4
- checkout. Neither involves a model, so phrasing cannot move them.
3
+ Two measurements are reproducible from a checkout. Neither involves a model;
4
+ both depend on the measurement rules described here.
5
5
 
6
6
  ## Routing quality: `node evals/run-all.mjs`
7
7
 
@@ -9,25 +9,87 @@ The routing evaluation is described in [EVALS.md](EVALS.md). It has 30 scenarios
9
9
  on six repositories (Node, TypeScript, Go, Rust and Python), with thresholds
10
10
  registered on 2026-09-28 and not moved since. For each scenario, it compares
11
11
  the context `agentdoc query` + `agentdoc impact` returns with what an agent
12
- gets by reading only the component it is changing.
13
-
14
- | | reading the touched component | AgentDoc |
15
- |---|---|---|
16
- | facts the change needs, retrieved | 58% | **100%** |
17
- | retrieved facts that are unrelated (noise) | 40% | **8.7%** |
18
- | share of the repository's checks returned (selectivity) | 100% | **46%** (29% on the golden corpus) |
19
- | context size vs. reading the whole catalog | — | **−70%** |
20
-
21
- Every check a scenario requires is in AgentDoc's answer (verification recall
22
- 1.0). The baseline never misses one either, because it runs every check in the
23
- repository. Selectivity is therefore the meaningful comparison: the share of
24
- the repository's checks the answer contains, counting everything the agent is
25
- shown. It is reported, not gated; small corpora inflate it, and a
26
- one-component corpus scores 100% because there is nothing to narrow.
27
-
28
- The size reduction is measured on the golden corpus (`examples/koda`). The
29
- smaller fixtures hold fewer than 100 facts, so the harness reports their
30
- reduction but does not aggregate it.
12
+ gets without routing.
13
+
14
+ The measured systems are catalog routing, a corrected graph-assisted targeted
15
+ reference, and working-tree search. Their current results appear below. The
16
+ pre-registered thresholds and scenario truth are unchanged.
17
+
18
+ ### Correction: the fact-recall comparison is withdrawn
19
+
20
+ Until 2026-10-27 this page, the README and the 1.2.0 release notes reported
21
+ that AgentDoc retrieves 100% of the facts a change needs, against 58% for a
22
+ baseline that reads only the touched component, with noise under 9% against
23
+ 40%. That comparison does not hold, and it is withdrawn.
24
+
25
+ - The targeted baseline already holds almost every expected fact, under a
26
+ different label from the ground truth. It records `usesResource:<ref>` where
27
+ the truth says `uses:<ref>`, and `buildDependencyOf:<ref>` where the truth
28
+ names the component `<ref>`. Relabelled, it retrieves about 99% of the
29
+ expected facts.
30
+ - The baseline also takes those relations from the compiled graph, which an
31
+ agent without AgentDoc does not have. A fair baseline has to find them with
32
+ search over the repository's files.
33
+
34
+ The search comparison is now measured. The targeted reference's corrected
35
+ micro-recall is 100/101 (99.01%); its scenario-mean recall is 99.17%. The
36
+ 100%-versus-58% comparison remains withdrawn.
37
+
38
+ ### Search baseline
39
+
40
+ `baseline-search` starts with touched files, searches source/manifests for
41
+ import names and local paths in both directions to a fixed point, and reads
42
+ the reached components' docs and checks. Contracts, endpoint strings, event
43
+ subjects and resource evidence are found in working-tree files. Catalog
44
+ descriptors supply only component identity and path ownership. Retrieval never
45
+ reads the compiled graph or compiler relations. All naming guesses, exclusions
46
+ and check conventions are listed in [EVALS.md](EVALS.md#search-baseline-heuristics).
47
+
48
+ | Corpus (scenarios) | System | Mean recall | Noise | Check selectivity |
49
+ |---|---|---:|---:|---:|
50
+ | Koda (9) | baseline-targeted (corrected) | 100.00% | 41.23% | 100.00% |
51
+ | Koda (9) | baseline-search | 71.90% | 71.55% | 100.00% |
52
+ | Koda (9) | catalog | 100.00% | 4.02% | 28.79% |
53
+ | Node/TS (3) | baseline-targeted (corrected) | 100.00% | 17.86% | 100.00% |
54
+ | Node/TS (3) | baseline-search | 91.67% | 30.64% | 83.33% |
55
+ | Node/TS (3) | catalog | 100.00% | 0.00% | 100.00% |
56
+ | Go (4) | baseline-targeted (corrected) | 93.75% | 28.94% | 100.00% |
57
+ | Go (4) | baseline-search | 83.33% | 26.01% | 100.00% |
58
+ | Go (4) | catalog | 100.00% | 5.88% | 66.67% |
59
+ | Mixed monorepo (4) | baseline-targeted (corrected) | 100.00% | 37.82% | 100.00% |
60
+ | Mixed monorepo (4) | baseline-search | 100.00% | 41.25% | 100.00% |
61
+ | Mixed monorepo (4) | catalog | 100.00% | 5.88% | 54.17% |
62
+ | Rust (4) | baseline-targeted (corrected) | 100.00% | 48.25% | 100.00% |
63
+ | Rust (4) | baseline-search | 100.00% | 32.81% | 83.33% |
64
+ | Rust (4) | catalog | 100.00% | 0.00% | 25.00% |
65
+ | Python (6) | baseline-targeted (corrected) | 100.00% | 46.70% | 100.00% |
66
+ | Python (6) | baseline-search | 100.00% | 23.68% | 41.67% |
67
+ | Python (6) | catalog | 100.00% | 0.00% | 41.67% |
68
+ | Aggregate (30) | baseline-targeted (corrected) | 99.17% | 38.83% | 100.00% |
69
+ | Aggregate (30) | baseline-search | 88.51% | 42.61% | 84.44% |
70
+ | Aggregate (30) | catalog | 100.00% | 2.77% | 46.42% |
71
+
72
+ These are scenario means, aggregated by scenario count. Search micro-recall is
73
+ 85/101 (84.16%), compared with 100/101 (99.01%) for corrected targeted and
74
+ 101/101 for catalog routing. All three systems retrieve every scenario-required
75
+ verification expectation. Selectivity counts distinct registered check IDs,
76
+ including repository gates, after matching search's discovered scoped commands.
77
+
78
+ Search matches catalog fact recall in 21 of 30 scenarios, including all Rust,
79
+ Python and mixed-monorepo scenarios. It beats catalog recall in none. The
80
+ remaining misses include authored placement, authority conflicts/runtime
81
+ observations, and API identities/roles not recoverable by these lexical rules.
82
+ Its broad closure can also overreach through shared endpoint strings. This is
83
+ a comparison of deterministic retrieval rules on authored corpora, not an agent
84
+ session, a limit on search quality, or evidence of time/token savings. Noise is
85
+ scored against the catalog's one-hop graph closure and can penalize useful
86
+ source facts absent from that graph.
87
+
88
+ The unchanged relative recall gate passes: corrected targeted ties catalog in
89
+ five corpora and trails it in Go. All applicable gates pass. Normalization also
90
+ changes noise and deduplicates fact counts: the full Koda inventory now has 86
91
+ facts, below the unchanged 100-fact size floor. Aggregate retrieval reduction
92
+ is **not measured**, so the former 70% size figure is not a current result.
31
93
 
32
94
  ## Fresh setup on public repositories: `node scripts/bench/run.mjs`
33
95
 
package/docs/CI.md CHANGED
@@ -20,7 +20,7 @@ jobs:
20
20
  with: { fetch-depth: 0 }
21
21
  - uses: actions/setup-node@v4
22
22
  with: { node-version: '24' }
23
- # Pin a tag for reproducibility, e.g. github:lbframe/agent-doc-system#v1.2.0 —
23
+ # Pin a tag for reproducibility, e.g. github:lbframe/agent-doc-system#v1.4.0 —
24
24
  # a compiler upgrade intentionally invalidates every committed graph.
25
25
  - run: npm install -g github:lbframe/agent-doc-system
26
26
  - run: agentdoc validate
@@ -47,6 +47,12 @@ compare the graph's recorded commit with `HEAD`: committing the graph changes
47
47
  The commit is recorded as provenance — which checkout produced this graph — not
48
48
  as a gate.
49
49
 
50
+ Files git ignores are not inputs, so a graph compiled after a local build
51
+ (`astro build`, `next build`, anything writing into an ignored directory) is the
52
+ graph CI reproduces from a clean checkout. No `git clean -fdX` is needed before
53
+ `compile`. An untracked file that is *not* ignored still counts, and marks the
54
+ graph dirty: commit it or ignore it.
55
+
50
56
  Two things to decide per repository:
51
57
 
52
58
  - **Commit the graph or ignore it.** Both work. Committing it makes the
@@ -87,6 +93,8 @@ YAML or schema error names its file and line and needs no table.
87
93
  | `AGENTDOC_COMPILER_MISMATCH` | the compiler was upgraded | `compile` and commit |
88
94
  | `AGENTDOC_OVERRIDE_STALE` | a reviewed override's cited evidence changed | review the interpretation, then `pin-evidence` and update the digest |
89
95
  | `AGENTDOC_VERIFICATION_UNTIERED` | a check task has no recognisable tier | name the task, or extend the adapter |
96
+ | `AGENTDOC_MIGRATION_PATH` | a Resource's `spec.migrationPaths` is a glob, is on a non-database Resource, or is shared by two Resources | declare the directory itself, once, on the logical database it migrates |
97
+ | `AGENTDOC_MIGRATION_DIR_UNRESOLVED` | a Drizzle config's `out` is not a single string literal inside the unit | declare the directory with `spec.migrationPaths`, or accept the warning |
90
98
 
91
99
  ## Ordering inside `check`
92
100