agent-doc-system 1.3.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.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,58 @@ refused by the next with `AGENTDOC_COMPILER_MISMATCH` until it is recompiled.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.4.0] - 2026-11-10
13
+
14
+ `impact` now treats a change under a Drizzle migration directory as a change
15
+ to the database it migrates. It reaches every component bound to that
16
+ database, not only the component that holds the file. Any other ORM layout can
17
+ be declared on the database Resource with the new `spec.migrationPaths`.
18
+ Existing `migrations/` and `alembic/versions/` paths give the same answer as
19
+ before.
20
+
21
+ Before you upgrade:
22
+
23
+ - **Recompile committed graphs.** As with every release, a graph compiled by
24
+ 1.3.0 is refused with `AGENTDOC_COMPILER_MISMATCH` until you run
25
+ `agentdoc compile` again. Graphs of repositories that enable the `database`
26
+ adapter also gain `derived.migrationDirs` and the provenance behind it.
27
+ - **A Drizzle config whose `out` is not a plain string** (an environment
28
+ variable, an expression, a spread) now raises the warning
29
+ `AGENTDOC_MIGRATION_DIR_UNRESOLVED`. Declare the directory with
30
+ `spec.migrationPaths`, or accept the warning ([#63]).
31
+
32
+ ### Added
33
+
34
+ - A `logical-database` Resource can declare where its schema migrations live,
35
+ with `spec.migrationPaths` ([#63]). A change under a declared directory is a
36
+ change to that Resource, whichever component owns the file, so `impact`
37
+ reaches every component bound to the database. Paths are whole-segment
38
+ prefixes, not globs: `frontend/apps/web/drizzle` covers `0036_x.sql` and
39
+ `meta/_journal.json`, and never `drizzle-extra/`. A glob, a declaration on
40
+ another Resource type, or one path declared by two Resources fails with the
41
+ new `AGENTDOC_MIGRATION_PATH`; a path outside the repository fails with
42
+ `AGENTDOC_PATH_ESCAPE`, a missing one with `AGENTDOC_PATH_UNRESOLVED`. The
43
+ declaration never creates a binding.
44
+
45
+ ### Fixed
46
+
47
+ - `impact` now classifies a change under a Drizzle migration directory as a
48
+ database change ([#63]). Only `migrations/`, `migration/` and
49
+ `alembic/versions/` used to count, so `impact
50
+ frontend/apps/web/drizzle/0036_email_locale.sql` reported the web component
51
+ alone and missed every other component bound to the same database. The
52
+ `database` adapter now reads Drizzle Kit's `out` from `drizzle.config.ts`
53
+ (`.js`, `.mjs`) as a string literal, without running it, and defaults to
54
+ `./drizzle`. An `out` it cannot read statically is reported with the
55
+ acceptable warning `AGENTDOC_MIGRATION_DIR_UNRESOLVED` instead of being
56
+ guessed. Detected directories, including `migrations` and `alembic/versions`,
57
+ are recorded in the graph as `derived.migrationDirs` with provenance, and
58
+ `impact` classifies from them. A detected directory still reaches a database
59
+ only through the component's existing bindings. The answer for an existing
60
+ `migrations/` or `alembic/versions/` path is unchanged. Committed graphs of
61
+ repositories that enable the `database` adapter gain `migrationDirs` and
62
+ must be recompiled.
63
+
12
64
  ## [1.3.0] - 2026-11-07
13
65
 
14
66
  A graph compiled on your machine now passes `check` in CI and in a clean
@@ -367,7 +419,8 @@ First public release.
367
419
  for project health.
368
420
  - An Agent Skill under `skills/agent-doc-system/`.
369
421
 
370
- [Unreleased]: https://github.com/lbframe/agent-doc-system/compare/v1.3.0...HEAD
422
+ [Unreleased]: https://github.com/lbframe/agent-doc-system/compare/v1.4.0...HEAD
423
+ [1.4.0]: https://github.com/lbframe/agent-doc-system/compare/v1.3.0...v1.4.0
371
424
  [1.3.0]: https://github.com/lbframe/agent-doc-system/compare/v1.2.0...v1.3.0
372
425
  [1.2.0]: https://github.com/lbframe/agent-doc-system/compare/v1.1.0...v1.2.0
373
426
  [1.1.0]: https://github.com/lbframe/agent-doc-system/compare/v1.0.0...v1.1.0
@@ -400,3 +453,4 @@ First public release.
400
453
  [#51]: https://github.com/lbframe/agent-doc-system/issues/51
401
454
  [#55]: https://github.com/lbframe/agent-doc-system/issues/55
402
455
  [#59]: https://github.com/lbframe/agent-doc-system/issues/59
456
+ [#63]: https://github.com/lbframe/agent-doc-system/issues/63
@@ -9,7 +9,7 @@
9
9
  // library shipped migrations but explicitly did not own a database.
10
10
  import { Adapter } from "./registry.mjs";
11
11
  import { firstExistingFile, findMigrationDirs } from "../core/sourcescan.mjs";
12
- import { joinPath } from "../core/paths.mjs";
12
+ import { canonicalPath, joinPath } from "../core/paths.mjs";
13
13
 
14
14
  const ORM_CONFIG = [
15
15
  "drizzle.config.ts", "drizzle.config.js", "drizzle.config.mjs",
@@ -48,6 +48,12 @@ export class DatabaseAdapter extends Adapter {
48
48
  }
49
49
  }
50
50
  const pr = prov.add("observed", evidencePath, "migration-extractor", ["der:" + compRef + ":/bindings"]);
51
+ // Where this unit's migrations live, recorded in the graph so `impact`
52
+ // classifies a change from the graph rather than from a list of names.
53
+ // Authority over a directory, never ownership of a database: these records
54
+ // create no binding.
55
+ const recorded = recordMigrationDirs(ctx, unit, migrationDirs, ormConfig, pr);
56
+ const withDirs = (out) => (recorded.length ? { ...out, migrationDirs: recorded } : out);
51
57
 
52
58
  // A component that both migrates and opens a connection owns the database.
53
59
  const opensConnection = hasConnectionEvidence(ctx, unit);
@@ -66,7 +72,7 @@ export class DatabaseAdapter extends Adapter {
66
72
  evidenceClass: "DERIVED", confidence: "candidate", provRecs: [pr],
67
73
  semantics: "a schema exists here, but which component owns the logical database is unresolved",
68
74
  });
69
- return { migrationAuthority: evidencePath };
75
+ return withDirs({ migrationAuthority: evidencePath });
70
76
  }
71
77
 
72
78
  const name = compRef.split("/")[1];
@@ -89,7 +95,7 @@ export class DatabaseAdapter extends Adapter {
89
95
  evidenceClass: "DERIVED", confidence: "candidate", provRecs: [pr],
90
96
  semantics: "this component opens a database connection, but no authored Resource claims ownership",
91
97
  });
92
- return { migrationAuthority: evidencePath };
98
+ return withDirs({ migrationAuthority: evidencePath });
93
99
  }
94
100
  ctx.addFact(compRef, "resource.ownership", target.name, {
95
101
  evidenceClass: "DERIVED", confidence: "deterministic", provRecs: [pr],
@@ -101,10 +107,10 @@ export class DatabaseAdapter extends Adapter {
101
107
  const client = ctx.clientKeyForResourceType("logical-database");
102
108
  const attrs = client ? { via: "migrations+connection", client } : { via: "migrations+connection" };
103
109
  ctx.addEdge("usesResource", compRef, target.ref, attrs, [pr], "DERIVED");
104
- return {
110
+ return withDirs({
105
111
  migrationAuthority: evidencePath,
106
112
  bindings: [{ kind: "database", logicalResourceRef: target.ref, resolution: "resolved", provenanceIds: null, _provs: new Set([pr]) }],
107
- };
113
+ });
108
114
  }
109
115
  }
110
116
 
@@ -129,3 +135,145 @@ function findDatabaseResource(ctx, name) {
129
135
  );
130
136
  return cands.length === 1 ? cands[0] : null;
131
137
  }
138
+
139
+ // ── migration directories ───────────────────────────────────────────────────
140
+ //
141
+ // Two routes, each with its own evidence:
142
+ // - a directory named by convention (`migrations`, `migration`,
143
+ // `alembic/versions`), evidenced by a file inside it;
144
+ // - Drizzle Kit's `out`, evidenced by the config file that states it.
145
+ // Prisma Migrate writes to `prisma/migrations`, which the first route covers.
146
+ function recordMigrationDirs(ctx, unit, conventionDirs, ormConfig, pr) {
147
+ const { repo, prov, compRef } = ctx;
148
+ const subject = "der:" + compRef + ":/migrationDirs";
149
+ const byPath = new Map();
150
+ const add = (dir, via, rec) => {
151
+ if (!byPath.has(dir)) byPath.set(dir, { path: dir, via: new Set(), _provs: new Set() });
152
+ byPath.get(dir).via.add(via);
153
+ byPath.get(dir)._provs.add(rec);
154
+ };
155
+ for (const dir of conventionDirs) {
156
+ // A directory with no file in the input set has nothing a change could
157
+ // touch, and nothing to cite as evidence.
158
+ const file = repo.walkUnit(unit.root, dir)[0];
159
+ if (!file) continue;
160
+ let rec;
161
+ if (file === pr.path) {
162
+ rec = pr;
163
+ pr.subjects.add(subject);
164
+ } else {
165
+ rec = prov.add("observed", file, "migration-extractor", [subject]);
166
+ }
167
+ add(dir, /(^|\/)alembic\/versions$/.test(dir) ? "alembic-versions" : "directory-name", rec);
168
+ }
169
+ if (ormConfig && /(^|\/)drizzle\.config\.[cm]?[jt]s$/.test(ormConfig)) {
170
+ const dir = drizzleOutDir(ctx, unit, ormConfig);
171
+ if (dir) add(dir, "drizzle-config", prov.add("observed", ormConfig, "drizzle-config-extractor", [subject]));
172
+ }
173
+ return [...byPath.values()]
174
+ .sort((a, b) => (a.path < b.path ? -1 : 1))
175
+ .map((d) => ({ path: d.path, via: [...d.via].sort(), provenanceIds: null, _provs: d._provs }));
176
+ }
177
+
178
+ // Drizzle Kit writes migrations to `out`, `./drizzle` when absent. The config
179
+ // is code, and it is never executed: only a string literal is read. Anything
180
+ // else — an expression, an environment variable, a spread that could carry
181
+ // `out`, several `out` keys — is reported and not guessed, because a wrong
182
+ // directory would route migrations to the wrong database silently.
183
+ function drizzleOutDir(ctx, unit, configPath) {
184
+ const { repo, compRef } = ctx;
185
+ const unresolved = (why) => {
186
+ ctx.diagnostics.push({
187
+ severity: "warning",
188
+ code: "AGENTDOC_MIGRATION_DIR_UNRESOLVED",
189
+ subject: compRef,
190
+ refs: [compRef],
191
+ message:
192
+ "Drizzle migration directory in '" + configPath + "' is not recorded: " + why + ". " +
193
+ "Declare it with spec.migrationPaths on the logical-database Resource it migrates",
194
+ paths: [configPath],
195
+ });
196
+ return null;
197
+ };
198
+ const parsed = drizzleOut(repo.readText(configPath));
199
+ if (parsed.error) return unresolved(parsed.error);
200
+ const value = parsed.value ?? "./drizzle";
201
+ // Relative to the directory drizzle-kit runs from, which is the config's own
202
+ // directory in the documented layout (and the unit root here).
203
+ const base = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/")) : ".";
204
+ const dir = value.startsWith("/") ? null : canonicalPath(joinPath(base, value));
205
+ if (!dir || dir === "." || dir === canonicalPath(unit.root)) {
206
+ return unresolved("out " + JSON.stringify(value) + " is not a directory inside the unit at '" + unit.root + "'");
207
+ }
208
+ if (!repo.ownsUnitPath(unit.root, dir)) {
209
+ return unresolved("out " + JSON.stringify(value) + " is outside the unit at '" + unit.root + "'");
210
+ }
211
+ // Not generated yet: nothing to classify. The probe is an input, so the
212
+ // first generated migration invalidates the graph.
213
+ if (!repo.isDir(dir)) return null;
214
+ return dir;
215
+ }
216
+
217
+ // Static read of `out` from a Drizzle config. Comments are removed and string
218
+ // literals set aside first, so neither `// out: "x"` nor a string containing
219
+ // "out:" is mistaken for the key.
220
+ export function drizzleOut(text) {
221
+ const strings = [];
222
+ let code = "";
223
+ for (let i = 0; i < text.length; ) {
224
+ const c = text[i];
225
+ if (c === "/" && text[i + 1] === "/") {
226
+ while (i < text.length && text[i] !== "\n") i++;
227
+ continue;
228
+ }
229
+ if (c === "/" && text[i + 1] === "*") {
230
+ const end = text.indexOf("*/", i + 2);
231
+ i = end < 0 ? text.length : end + 2;
232
+ code += " ";
233
+ continue;
234
+ }
235
+ if (c === '"' || c === "'" || c === "`") {
236
+ let j = i + 1;
237
+ let value = "";
238
+ let closed = false;
239
+ while (j < text.length) {
240
+ if (text[j] === "\\") {
241
+ value += text[j + 1] ?? "";
242
+ j += 2;
243
+ continue;
244
+ }
245
+ if (text[j] === c) { closed = true; break; }
246
+ if (c !== "`" && text[j] === "\n") break;
247
+ value += text[j];
248
+ j++;
249
+ }
250
+ if (!closed) return { error: "the config could not be read statically" };
251
+ // A template with a substitution is an expression, not a literal.
252
+ strings.push(c === "`" && value.includes("${") ? null : value);
253
+ code += "\u0000" + (strings.length - 1) + "\u0000";
254
+ i = j + 1;
255
+ continue;
256
+ }
257
+ code += c;
258
+ i++;
259
+ }
260
+ const hits = [];
261
+ for (const m of code.matchAll(/(?:^|[{,\s])(?:out|\u0000(\d+)\u0000)\s*(:|[,}])/g)) {
262
+ // A quoted key counts only when it says "out".
263
+ if (m[1] !== undefined && strings[Number(m[1])] !== "out") continue;
264
+ hits.push(m);
265
+ }
266
+ if (hits.length === 0) {
267
+ // A spread could bring `out` in from elsewhere; the default is then a guess.
268
+ if (/\.\.\.\s*[A-Za-z_$(]/.test(code)) return { error: "the config spreads another object that may set out" };
269
+ return { value: null };
270
+ }
271
+ if (hits.length > 1) return { error: "out is set more than once" };
272
+ const m = hits[0];
273
+ if (m[2] !== ":") return { error: "out is a shorthand property, not a string literal" };
274
+ const rest = code.slice(m.index + m[0].length);
275
+ const lit = /^\s*\u0000(\d+)\u0000\s*(?:[,}]|$)/.exec(rest);
276
+ const value = lit ? strings[Number(lit[1])] : null;
277
+ if (typeof value !== "string" || value.trim() === "") return { error: "out is not a string literal" };
278
+ return { value };
279
+ }
package/core/codes.mjs CHANGED
@@ -23,6 +23,7 @@ export const CODES = {
23
23
  GLOB: "AGENTDOC_GLOB",
24
24
  PATH_ESCAPE: "AGENTDOC_PATH_ESCAPE",
25
25
  PATH_UNRESOLVED: "AGENTDOC_PATH_UNRESOLVED",
26
+ MIGRATION_PATH: "AGENTDOC_MIGRATION_PATH",
26
27
  SECRET: "AGENTDOC_SECRET",
27
28
 
28
29
  // identity & references
@@ -98,6 +99,7 @@ export const ACCEPTABLE_WARNING_CODES = Object.freeze([
98
99
  "AGENTDOC_NORMATIVE_UNCOVERED",
99
100
  "AGENTDOC_REQUIREMENT_UNVERIFIED",
100
101
  "AGENTDOC_PYTHON_SPECIFIER",
102
+ "AGENTDOC_MIGRATION_DIR_UNRESOLVED",
101
103
  ]);
102
104
 
103
105
  export class AgentDocError extends Error {
package/core/compile.mjs CHANGED
@@ -498,6 +498,10 @@ export function compile(root, opts = {}) {
498
498
  "entf:" + e.ref + ":/metadata/description",
499
499
  ];
500
500
  if (e.doc.spec !== undefined) subjects.push("entf:" + e.ref + ":/spec");
501
+ // A declared migration path is the one authored path whose meaning reaches
502
+ // past the descriptor (impact routes every change below it to this
503
+ // Resource), so it is pointed at directly rather than through `/spec`.
504
+ if (e.doc.spec?.migrationPaths) subjects.push("entf:" + e.ref + ":/spec/migrationPaths");
501
505
  prov.add("declared", e.file, "descriptor-reader", subjects);
502
506
  }
503
507
 
@@ -777,7 +781,7 @@ export function compile(root, opts = {}) {
777
781
  return earlyOut(res, { entities: sources.entities.length, units: discovery.eligible.length });
778
782
  }
779
783
  for (const d of derivedMap.values()) {
780
- for (const list of [d.bindings || [], d.healthContracts || []]) {
784
+ for (const list of [d.bindings || [], d.healthContracts || [], d.migrationDirs || []]) {
781
785
  for (const b of list) {
782
786
  b.provenanceIds = [...(b._provs || [])].map((p) => provFinal.idOf.get(p)).filter(Boolean).sort();
783
787
  delete b._provs;
@@ -297,6 +297,7 @@ export function loadDescriptors(repo, cfg, bundle, refs) {
297
297
  throw new AgentDocError(CODES.PATH_UNRESOLVED, "context link does not resolve: " + p, { path: e.file, ref: e.ref });
298
298
  }
299
299
  }
300
+ if (e.kind === "Resource" && spec.migrationPaths) checkMigrationPaths(repo, e);
300
301
  if (e.kind === "API") {
301
302
  const prov = spec.provider;
302
303
  if (!refs.isValidRef(prov) || refs.kindOfRef(prov) !== "Component") {
@@ -335,9 +336,57 @@ export function loadDescriptors(repo, cfg, bundle, refs) {
335
336
  }, errors);
336
337
  }
337
338
 
339
+ // One directory migrates one database. Two Resources declaring the same path
340
+ // would make every migration under it ambiguous, and impact must not pick.
341
+ // Nested declarations are fine: the longest prefix wins, as for ownership.
342
+ const declaredBy = new Map();
343
+ for (const e of entities) {
344
+ if (e.kind !== "Resource") continue;
345
+ for (const p of e.doc.spec?.migrationPaths || []) {
346
+ if (!declaredBy.has(p)) declaredBy.set(p, []);
347
+ declaredBy.get(p).push(e);
348
+ }
349
+ }
350
+ for (const [p, owners] of [...declaredBy.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
351
+ if (owners.length < 2) continue;
352
+ const names = owners.map((o) => o.ref).sort();
353
+ errors.push(new AgentDocError(
354
+ CODES.MIGRATION_PATH,
355
+ "migration path " + JSON.stringify(p) + " is declared by several Resources (" + names.join(", ") + "); a migration directory migrates one logical database",
356
+ { path: owners[0].file, ref: names[0] }
357
+ ));
358
+ }
359
+
338
360
  return { entities, byRef, byKindName, componentFiles: new Set(componentFiles), errors, componentSet, centralSet };
339
361
  }
340
362
 
363
+ // `spec.migrationPaths` on a Resource: repository directories (or files) whose
364
+ // changes are changes to this database. They are whole-segment prefixes with
365
+ // the semantics of `core/paths.mjs`, never globs: a glob would need an engine
366
+ // this system does not ship, and `drizzle/**` means nothing a prefix does not.
367
+ function checkMigrationPaths(repo, e) {
368
+ if (e.doc.spec.type !== "logical-database") {
369
+ throw new AgentDocError(
370
+ CODES.MIGRATION_PATH,
371
+ "spec.migrationPaths is only meaningful on a logical-database Resource, not on type '" + e.doc.spec.type + "'",
372
+ { path: e.file, ref: e.ref }
373
+ );
374
+ }
375
+ for (const p of e.doc.spec.migrationPaths) {
376
+ assertRepoPath(p, e.file);
377
+ if (/[*?[\]{}!]/.test(p)) {
378
+ throw new AgentDocError(
379
+ CODES.MIGRATION_PATH,
380
+ "migration path " + JSON.stringify(p) + " is a glob; declare the directory itself (e.g. \"" + p.replace(/\/?[^/]*[*?[\]{}!].*$/, "") + "\"), which covers everything below it",
381
+ { path: e.file, ref: e.ref }
382
+ );
383
+ }
384
+ if (!repo.exists(p)) {
385
+ throw new AgentDocError(CODES.PATH_UNRESOLVED, "migration path does not resolve: " + p, { path: e.file, ref: e.ref });
386
+ }
387
+ }
388
+ }
389
+
341
390
  export function loadJourneys(repo, cfg, bundle, byRef) {
342
391
  const errors = [];
343
392
  const jPath = cfg.discovery.journeyDefinitions;
package/core/graph.mjs CHANGED
@@ -9,7 +9,7 @@ import { assertDeterministic, structuralProjection } from "./determinism.mjs";
9
9
  import { sha256Hex } from "./fsx.mjs";
10
10
 
11
11
  export const COMPILER_NAME = "agentdoc-compiler";
12
- export const COMPILER_VERSION = "1.3.0";
12
+ export const COMPILER_VERSION = "1.4.0";
13
13
  export const GRAPH_SCHEMA_VERSION = "agentdoc.dev/graph/v1";
14
14
 
15
15
  // Resolve symbolic provenance subjects to RFC 6901 pointers into the graph.
package/core/impact.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  // from the graph alone, so the answer is available before any exploration.
6
6
  import { ContextIndex } from "./query.mjs";
7
7
  import { dependentScope, verificationForChange } from "./dependents.mjs";
8
- import { canonicalPath, ownsPath } from "./paths.mjs";
8
+ import { canonicalPath, ownsPath, longestOwner } from "./paths.mjs";
9
9
 
10
10
  // Changed paths are compared in the graph's canonical form (git's: no "./").
11
11
  // A path that is not repository-relative is kept verbatim so it is reported
@@ -23,7 +23,10 @@ const canonical = (p) => canonicalPath(p) ?? p;
23
23
  // - every extractor identity, which names the technology -> dependency /
24
24
  // verification
25
25
  // - every documentation and constraint path on an entity -> documentation
26
- function classify(graph, p, idx) {
26
+ // - every migration directory: declared on a logical-database -> resource
27
+ // Resource (`spec.migrationPaths`), or detected per component
28
+ // by the database adapter (`derived.migrationDirs`)
29
+ function classify(graph, p, idx, migrations) {
27
30
  for (const c of graph.contracts) if (c.ref === p) return "contract";
28
31
  for (const e of graph.entities) {
29
32
  const d = e.derived || {};
@@ -32,6 +35,13 @@ function classify(graph, p, idx) {
32
35
  for (const x of d.runbooks || []) if (x === p) return "documentation";
33
36
  }
34
37
  if (/\.(md|mdx|adoc|rst|txt)$/.test(p)) return "documentation";
38
+ if (migrationHit(migrations, p)) return "resource";
39
+ // Kept as a fallback, and the one name-based rule left: before the graph
40
+ // recorded migration directories this regex was the whole classification,
41
+ // and it still has to answer where nothing is recorded — a repository that
42
+ // does not enable the database adapter, a migration directory deeper than
43
+ // the adapter's bounded walk, a graph built by hand. Dropping it would change
44
+ // the answer for those repositories; what the graph records only adds to it.
35
45
  if (/(^|\/)(migrations?|alembic\/versions)\//.test(p)) return "resource";
36
46
  // A Resource named directly — by ref, or by the descriptor file that declares
37
47
  // it — is a resource change. Without this, `impact resource:default/x` reported
@@ -46,8 +56,43 @@ function classify(graph, p, idx) {
46
56
  return null;
47
57
  }
48
58
 
59
+ // Migration directories recorded in the graph, with what made them one.
60
+ function migrationDirs(graph) {
61
+ const declared = new Map(); // path -> Resource ref
62
+ const detected = new Map(); // path -> { componentRef, via }
63
+ for (const e of graph.entities) {
64
+ if (e.kind === "Resource") {
65
+ for (const p of e.entity?.spec?.migrationPaths || []) declared.set(p, e.ref);
66
+ }
67
+ if (e.kind === "Component") {
68
+ for (const d of e.derived?.migrationDirs || []) detected.set(d.path, { componentRef: e.ref, via: d.via || [] });
69
+ }
70
+ }
71
+ return { declared, detected };
72
+ }
73
+
74
+ // The migration directory a path is under, by whole path segments, the most
75
+ // specific first. An authored declaration beats a detected directory: it names
76
+ // the database, where detection only names the component whose bindings say
77
+ // which database it is.
78
+ function migrationHit(migrations, p) {
79
+ if (!migrations) return null;
80
+ const d = longestOwner(migrations.declared.keys(), p);
81
+ if (d) return { dir: d, resourceRef: migrations.declared.get(d), how: "declared on " + migrations.declared.get(d) };
82
+ const x = longestOwner(migrations.detected.keys(), p);
83
+ if (!x) return null;
84
+ const { componentRef, via } = migrations.detected.get(x);
85
+ // A directory found by its conventional name is exactly what the fallback
86
+ // regex already classified; only a directory nothing but the graph knows
87
+ // about is worth a reason of its own.
88
+ const conventional = via.every((v) => v === "directory-name" || v === "alembic-versions");
89
+ return { dir: x, componentRef, conventional, how: "detected from " + via.join(", ") + " in " + componentRef };
90
+ }
91
+
49
92
  export function impact(graph, changedPaths) {
50
93
  const idx = new ContextIndex(graph);
94
+ const migrations = migrationDirs(graph);
95
+ const migrationReasons = new Map(); // Resource ref -> Set(reason)
51
96
  const affectedComponents = new Map(); // ref -> Set(reason)
52
97
  const add = (ref, reason) => {
53
98
  if (idx.byRef.get(ref)?.kind !== "Component") return;
@@ -72,18 +117,35 @@ export function impact(graph, changedPaths) {
72
117
  // while the components actually bound to it went unmentioned.
73
118
  if (entity && entity.kind === "Component") add(entity.ref, "direct file change inside " + entity.ref);
74
119
  else unmatched.push(p);
75
- const kind = classify(graph, p, idx);
120
+ const kind = classify(graph, p, idx, migrations);
76
121
  if (kind === "contract") contracts.add(p);
77
122
  if (kind === "documentation") docs.add(p);
78
123
  if (kind === "resource") {
79
- resources.add(entity?.kind === "Resource" ? entity.ref : p);
80
- // Migration authority is not ownership. Only follow an existing binding
81
- // from the component containing this migration to a logical database.
82
- if (entity?.kind === "Component") {
83
- for (const rel of graph.relations) {
84
- const bound = rel.type === "usesResource" && rel.sourceRef === entity.ref ? rel.targetRef
85
- : rel.type === "resourceUsedBy" && rel.targetRef === entity.ref ? rel.sourceRef : null;
86
- if (idx.byRef.get(bound)?.entity.spec.type === "logical-database") resources.add(bound);
124
+ const hit = migrationHit(migrations, p);
125
+ const because = (ref) => {
126
+ if (!hit || hit.conventional) return;
127
+ if (!migrationReasons.has(ref)) migrationReasons.set(ref, new Set());
128
+ migrationReasons.get(ref).add("migration " + p + " changes " + ref + " (migration directory " + hit.dir + ", " + hit.how + ")");
129
+ };
130
+ if (hit?.resourceRef) {
131
+ // Authored: the declaration names the database, whichever component
132
+ // owns the file and whatever that component is bound to.
133
+ resources.add(hit.resourceRef);
134
+ because(hit.resourceRef);
135
+ } else {
136
+ resources.add(entity?.kind === "Resource" ? entity.ref : p);
137
+ // Migration authority is not ownership. Only follow an existing binding
138
+ // from the component containing this migration to a logical database.
139
+ const owner = hit?.componentRef ?? (entity?.kind === "Component" ? entity.ref : null);
140
+ if (owner) {
141
+ for (const rel of graph.relations) {
142
+ const bound = rel.type === "usesResource" && rel.sourceRef === owner ? rel.targetRef
143
+ : rel.type === "resourceUsedBy" && rel.targetRef === owner ? rel.sourceRef : null;
144
+ if (idx.byRef.get(bound)?.entity.spec.type === "logical-database") {
145
+ resources.add(bound);
146
+ because(bound);
147
+ }
148
+ }
87
149
  }
88
150
  }
89
151
  }
@@ -135,6 +197,7 @@ export function impact(graph, changedPaths) {
135
197
  const user = rel.type === "usesResource" ? rel.sourceRef : rel.targetRef;
136
198
  if (r === bound) {
137
199
  add(user, "resource change touches " + bound);
200
+ for (const reason of migrationReasons.get(bound) || []) add(user, reason);
138
201
  }
139
202
  }
140
203
  }
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 |
@@ -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/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.3.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
@@ -93,6 +93,8 @@ YAML or schema error names its file and line and needs no table.
93
93
  | `AGENTDOC_COMPILER_MISMATCH` | the compiler was upgraded | `compile` and commit |
94
94
  | `AGENTDOC_OVERRIDE_STALE` | a reviewed override's cited evidence changed | review the interpretation, then `pin-evidence` and update the digest |
95
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 |
96
98
 
97
99
  ## Ordering inside `check`
98
100
 
package/docs/QUERY.md CHANGED
@@ -215,10 +215,31 @@ receives the files no other component owns. A file nothing owns is listed in
215
215
 
216
216
  Reachability rules: a contract change reaches its provider and every consumer;
217
217
  a Resource change reaches components bound to that exact Resource. A migration
218
- change (including Alembic `alembic/versions`) reports its changed path and the
219
- logical databases bound to its enclosing component through existing
220
- `usesResource` / `resourceUsedBy` relations. Migration authority alone never
221
- establishes ownership or invents a Resource binding.
218
+ change is a Resource change. Which Resource is decided from the graph, most
219
+ specific directory first, by whole path segment (`drizzle` never matches
220
+ `drizzle-extra/`):
221
+
222
+ 1. **Declared.** Under a path a logical-database Resource lists in
223
+ `spec.migrationPaths`: that Resource, whichever component owns the file and
224
+ whatever that component is bound to. An authored declaration beats a
225
+ detected directory.
226
+ 2. **Detected.** Under a directory the `database` adapter recorded in a
227
+ component's `derived.migrationDirs` (a `migrations` directory, Alembic
228
+ `alembic/versions`, Drizzle Kit's `out`): the logical databases bound to
229
+ that component through existing `usesResource` / `resourceUsedBy`
230
+ relations. The changed path is listed too.
231
+ 3. **By name.** Under a `migrations/`, `migration/` or `alembic/versions/`
232
+ segment the graph does not record (the `database` adapter is off): the same
233
+ as detected, from the enclosing component.
234
+
235
+ Every component bound to a reached Resource is affected, with the reason
236
+ `resource change touches <ref>`. When the directory was declared or found
237
+ through an ORM config, a second reason names the migration:
238
+ `migration drizzle/0001_x.sql changes resource:default/app-db (migration
239
+ directory drizzle, detected from drizzle-config in component:default/web)`.
240
+ Migration authority alone never establishes ownership or invents a Resource
241
+ binding: a detected directory whose component has no binding reaches no
242
+ Resource, and a declaration routes changes without creating a relation.
222
243
 
223
244
  A component change reaches its reverse build/test dependency closure, plus
224
245
  direct runtime-call and schedule peers. Dependencies are not affected just
package/docs/SPEC.md CHANGED
@@ -77,6 +77,47 @@ authored in exactly one file, and the location is validated.
77
77
  The namespace comes from the configuration, so two repositories in one graph
78
78
  never collide.
79
79
 
80
+ ### Resource migration paths
81
+
82
+ A `logical-database` Resource may list the repository directories that hold its
83
+ schema migrations:
84
+
85
+ ```yaml
86
+ apiVersion: agentdoc.dev/v1
87
+ kind: Resource
88
+ metadata:
89
+ name: app-postgres
90
+ description: "Primary Postgres database of the application."
91
+ spec:
92
+ type: logical-database
93
+ system: app
94
+ migrationPaths:
95
+ - frontend/apps/web/drizzle
96
+ ```
97
+
98
+ Each entry is a repository path matched as a whole-segment prefix (the rules
99
+ of `core/paths.mjs`): `frontend/apps/web/drizzle` covers every file below it,
100
+ including `meta/_journal.json`, and never `frontend/apps/web/drizzle-extra/`.
101
+ `impact` treats a change under it as a change to this Resource, whichever
102
+ component owns the file, and reaches every component bound to the Resource.
103
+ The declaration is AUTHORED: it beats a migration directory the `database`
104
+ adapter detects, and it is pointed at by its own provenance subject
105
+ (`/entities/<i>/entity/spec/migrationPaths`). It never creates a
106
+ `usesResource` relation; which components use the database is still derived.
107
+
108
+ Validation, at load time:
109
+
110
+ | case | code |
111
+ |---|---|
112
+ | absolute, backslash, `.` or `..` segment, whitespace | `AGENTDOC_PATH_ESCAPE` |
113
+ | a path that does not exist (or is git-ignored) | `AGENTDOC_PATH_UNRESOLVED` |
114
+ | a glob (`*`, `?`, `[`, `{`, `!`): declare the directory itself | `AGENTDOC_MIGRATION_PATH` |
115
+ | the field on a Resource whose type is not `logical-database` | `AGENTDOC_MIGRATION_PATH` |
116
+ | the same path declared by two Resources | `AGENTDOC_MIGRATION_PATH` |
117
+
118
+ Nested declarations on different Resources are allowed; the longest prefix
119
+ wins.
120
+
80
121
  ### Why no further entities
81
122
 
82
123
  A `Team`, `Milestone` or `Tag` entity was considered and rejected: none of them
@@ -142,6 +183,7 @@ least one provenance id and an evidence class.
142
183
  ```
143
184
  schemaVersion, source{commit, dirty, inputHash}, compiler{name, version, adapters}
144
185
  entities[] ref, kind, name, entity, source, sourcePaths[], derived?
186
+ (derived.migrationDirs[]: path, via[], provenanceIds[])
145
187
  relations[] type, sourceRef, targetRef, evidenceClass, attributes, provenanceIds[]
146
188
  interfaces.events[] pattern, producers, consumers, contract, resolution
147
189
  externalDependencies[] componentRef, name, role, mechanism, evidenceClass
@@ -21,7 +21,7 @@ jobs:
21
21
  with: { node-version: '24' }
22
22
  - name: Install the agentdoc CLI
23
23
  # Unpinned tracks main. For CI reproducibility pin a tag or commit:
24
- # github:lbframe/agent-doc-system#v1.3.0
24
+ # github:lbframe/agent-doc-system#v1.4.0
25
25
  # A compiler upgrade intentionally invalidates committed graphs.
26
26
  run: npm install -g github:lbframe/agent-doc-system
27
27
  - name: Validate and compile
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-doc-system",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "description": "Portable documentation and software-catalog system with an explicit authority model, for AI coding agents",
6
6
  "keywords": [
@@ -16,7 +16,15 @@
16
16
  "type": { "enum": ["logical-database", "logical-dataset", "event-bus", "queue", "cache", "object-store", "search-index", "secret-store", "other"] },
17
17
  "placementRationale": { "$ref": "common.json#/$defs/rationale" },
18
18
  "system": { "$ref": "common.json#/$defs/name" },
19
- "context": { "$ref": "common.json#/$defs/context" }
19
+ "context": { "$ref": "common.json#/$defs/context" },
20
+ "migrationPaths": {
21
+ "description": "Repository directories (or files) holding this logical database's schema migrations, matched as whole-segment path prefixes. Authoritative for change impact: a change under one of them is a change to this Resource, whichever component owns the file. Never a binding: which components use the database is still derived. Only a logical-database may declare them.",
22
+ "type": "array",
23
+ "minItems": 1,
24
+ "maxItems": 32,
25
+ "uniqueItems": true,
26
+ "items": { "$ref": "common.json#/$defs/repoPath" }
27
+ }
20
28
  },
21
29
  "oneOf": [
22
30
  { "required": ["system"] },
package/templates/ci.yml CHANGED
@@ -17,7 +17,7 @@ jobs:
17
17
  with: { node-version: '24' }
18
18
  - name: Install the agentdoc CLI
19
19
  # Unpinned tracks main. For CI reproducibility pin a tag or commit:
20
- # github:lbframe/agent-doc-system#v1.3.0
20
+ # github:lbframe/agent-doc-system#v1.4.0
21
21
  # A compiler upgrade intentionally invalidates committed graphs.
22
22
  run: npm install -g github:lbframe/agent-doc-system
23
23
  - name: Validate and compile