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/fsx.mjs CHANGED
@@ -5,11 +5,21 @@
5
5
  // is what the graph's inputHash is computed over, so freshness detection can
6
6
  // never miss an input. This is the single most important property of the
7
7
  // original system and is preserved verbatim.
8
+ //
9
+ // The input set is the checkout as git sees it: paths git ignores are not part
10
+ // of the repository (issue #45). A local build writing into an ignored
11
+ // directory — `astro build` filling `.astro/`, a `build/` tree, a `*.log` —
12
+ // must not move the inputHash, or a graph compiled after that build can never
13
+ // be reproduced by a clean checkout or by CI. Git itself is the oracle (see
14
+ // `ignoredPaths`); nothing here re-implements gitignore matching. Every read,
15
+ // probe and listing below consults the same oracle, and `check` recomputes the
16
+ // hash by running the same compile, so compile and check cannot disagree.
8
17
  import { createHash } from "node:crypto";
9
18
  import { execFileSync } from "node:child_process";
10
19
  import fs from "node:fs";
11
20
  import path from "node:path";
12
21
  import { AgentDocError, CODES } from "./codes.mjs";
22
+ import { canonicalPath, ownsPath, REPO_ROOT } from "./paths.mjs";
13
23
 
14
24
  export function sha256Hex(buf) {
15
25
  return createHash("sha256").update(buf).digest("hex");
@@ -39,6 +49,9 @@ export function assertRepoPath(p, ctx) {
39
49
 
40
50
  // Build/tooling output is never repository authority. Configurable per install
41
51
  // so a project with e.g. "target" or "vendor" trees keeps them out too.
52
+ // This list applies everywhere, and is the *only* exclusion when there is no
53
+ // git oracle (no repository, no git binary, or `noGit`). Inside a git
54
+ // checkout, git-ignored paths are excluded in addition — see `ignoredPaths`.
42
55
  // Build and tooling output. Tooling caches and dependency directories only: a
43
56
  // name like `build`, `out` or `tmp` is far more likely to be a real unit in some
44
57
  // repository than a generated directory, and a repository with a deployable
@@ -62,6 +75,11 @@ export class Repo {
62
75
  this.nestedUnitRoots = new Map();
63
76
  this.generatedDirs = new Set(opts.generatedDirs || DEFAULT_GENERATED_DIRS);
64
77
  this.noGit = Boolean(opts.noGit);
78
+ // Per-Repo git caches. undefined = not asked yet; null = no answer.
79
+ this._hasGit = undefined;
80
+ this._prefix = undefined;
81
+ this._status = undefined;
82
+ this._ignored = undefined;
65
83
  }
66
84
  abs(rel) {
67
85
  const a = path.resolve(this.root, rel);
@@ -70,14 +88,71 @@ export class Repo {
70
88
  }
71
89
  return a;
72
90
  }
91
+ // The paths git ignores under this.root, asked once per Repo and cached, so
92
+ // every read, probe and listing in a run sees the same snapshot.
93
+ //
94
+ // `git status --ignored=matching` is the oracle (git-status(1)): it reports
95
+ // exactly the untracked paths that match an ignore pattern, and an ignored
96
+ // directory as one `dir/` entry without its contents. That gives git's own
97
+ // semantics for free:
98
+ // - tracked files are never reported, so a tracked file that matches a
99
+ // pattern stays an input ("tracked always wins", gitignore(5));
100
+ // - nested .gitignore files, $GIT_DIR/info/exclude and core.excludesFile
101
+ // apply with gitignore(5) precedence, negations included, and a file
102
+ // whose parent directory is excluded cannot be re-included;
103
+ // - a directory that is not itself ignored is never collapsed, only its
104
+ // ignored contents are listed. (`git ls-files -o -i --directory` gets
105
+ // this wrong: it also prints a non-ignored parent whose only content is
106
+ // ignored, and aborts when run from inside an ignored directory.)
107
+ //
108
+ // The user's global core.excludesFile deliberately counts. An ignored
109
+ // untracked file is, by definition, absent from every clean checkout, so
110
+ // whichever ignore source matched it, dropping it moves this machine's input
111
+ // set *towards* what CI sees, never away from it — no ignore rule can hide a
112
+ // tracked file. Ignoring core.excludesFile would bring back issue #45 for
113
+ // anything a developer ignores globally (editor swap files, `.envrc`): the
114
+ // file would enter the hash while `git status`, and therefore the graph's
115
+ // `dirty` flag, stays silent about it.
116
+ //
117
+ // The snapshot comes from `statusRecords`, the same single `git status`
118
+ // call the compile's dirty check reads, so the input set and the `dirty`
119
+ // flag describe the same moment. Paths come back relative to the git
120
+ // toplevel and are re-keyed by `toRepoPaths`; an ignored *ancestor* of
121
+ // this.root is dropped there, so a project the user pointed the compiler at
122
+ // is never blanked out by an outer .gitignore.
123
+ //
124
+ // null when there is no oracle — `noGit`, not a git checkout, git missing
125
+ // or failing. The input set is then the filesystem minus generatedDirs,
126
+ // exactly as before #45.
127
+ ignoredPaths() {
128
+ if (this._ignored !== undefined) return this._ignored;
129
+ const recs = this.statusRecords();
130
+ const prefix = recs ? this.gitPrefix() : null;
131
+ this._ignored = recs && prefix !== null
132
+ ? new Set(this.toRepoPaths(recs.filter((r) => r.xy === "!!").map((r) => r.path), prefix))
133
+ : null;
134
+ return this._ignored;
135
+ }
136
+ // True when git ignores `rel` or one of its parent directories. Directory
137
+ // entries carry a trailing "/" in git's output.
138
+ isIgnored(rel) {
139
+ const set = this.ignoredPaths();
140
+ if (!set || set.size === 0 || !rel || rel === ".") return false;
141
+ if (set.has(rel) || set.has(rel + "/")) return true;
142
+ for (let i = rel.indexOf("/"); i >= 0; i = rel.indexOf("/", i + 1)) {
143
+ if (set.has(rel.slice(0, i + 1))) return true;
144
+ }
145
+ return false;
146
+ }
147
+ // An ignored path is probed as absent: that is what a clean checkout sees.
73
148
  exists(rel) {
74
- const r = fs.existsSync(this.abs(rel));
149
+ const r = !this.isIgnored(rel) && fs.existsSync(this.abs(rel));
75
150
  this.probes.set(rel, r);
76
151
  return r;
77
152
  }
78
153
  isDir(rel) {
79
154
  try {
80
- const r = fs.statSync(this.abs(rel)).isDirectory();
155
+ const r = !this.isIgnored(rel) && fs.statSync(this.abs(rel)).isDirectory();
81
156
  this.probes.set(rel, r);
82
157
  return r;
83
158
  } catch {
@@ -87,6 +162,16 @@ export class Repo {
87
162
  }
88
163
  readBytes(rel, { track = true } = {}) {
89
164
  const a = this.abs(rel);
165
+ // A tracked (input) read of an ignored path fails exactly as a read of a
166
+ // missing file does, so a descriptor that points into build output breaks
167
+ // locally the same way it breaks in CI. Untracked reads are not inputs.
168
+ if (track && this.isIgnored(rel)) {
169
+ throw new AgentDocError(
170
+ CODES.PATH_UNRESOLVED,
171
+ "path is ignored by git, so it is not a repository input: " + rel,
172
+ { path: rel }
173
+ );
174
+ }
90
175
  let buf;
91
176
  try {
92
177
  buf = fs.readFileSync(a);
@@ -114,10 +199,13 @@ export class Repo {
114
199
  return JSON.parse(this.readText(rel, { track: false }));
115
200
  }
116
201
  // Recursive listing, byte-sorted, never following symlinks out of the repo.
202
+ // Ignored directories are pruned, not descended: an ignored `node_modules`
203
+ // or `.astro` costs one Set lookup.
117
204
  walk(relDir, { exclude = () => false } = {}) {
118
205
  const base = relDir ? this.abs(relDir) : this.root;
119
206
  const out = [];
120
- const stack = [base];
207
+ const ignored = this.ignoredPaths();
208
+ const stack = relDir && this.isIgnored(relDir) ? [] : [base];
121
209
  while (stack.length) {
122
210
  const dir = stack.pop();
123
211
  let entries;
@@ -132,6 +220,9 @@ export class Repo {
132
220
  const full = path.join(dir, ent.name);
133
221
  const rel = path.relative(this.root, full).split(path.sep).join("/");
134
222
  if (ent.isSymbolicLink()) continue;
223
+ // Parents are already known not to be ignored, so the entry itself is
224
+ // the only lookup needed.
225
+ if (ignored && ignored.has(ent.isDirectory() ? rel + "/" : rel)) continue;
135
226
  if (exclude(rel, ent)) continue;
136
227
  if (ent.isDirectory()) stack.push(full);
137
228
  else if (ent.isFile()) out.push(rel);
@@ -144,12 +235,11 @@ export class Repo {
144
235
  // Per-unit scans use the discovery result, never filesystem heuristics.
145
236
  // Keep walk() repository-wide for discovery, contracts and authored inputs.
146
237
  ownsUnitPath(unitRoot, rel) {
147
- const normalize = (p) => path.posix.normalize(p || ".").replace(/^\.$/, "");
148
- const root = normalize(unitRoot);
149
- const target = normalize(rel);
150
- if (root && target !== root && !target.startsWith(root + "/")) return false;
151
- for (const child of this.nestedUnitRoots.get(root) || []) {
152
- if (target === child || target.startsWith(child + "/")) return false;
238
+ if (!ownsPath(unitRoot, rel)) return false;
239
+ // nestedUnitRoots is keyed with the root spelled "" (see discoverUnits).
240
+ const root = canonicalPath(unitRoot);
241
+ for (const child of this.nestedUnitRoots.get(root === REPO_ROOT ? "" : root) || []) {
242
+ if (ownsPath(child, rel)) return false;
153
243
  }
154
244
  return true;
155
245
  }
@@ -179,12 +269,17 @@ export class Repo {
179
269
  // A directory listing is a compiler input like any other, and is recorded so
180
270
  // the input hash covers it. An untracked listing would let a change to a
181
271
  // directory's contents escape freshness detection.
272
+ // Ignored entries are left out, as if absent.
182
273
  listDir(relDir) {
183
274
  let names;
184
275
  try {
185
- names = fs.readdirSync(this.abs(relDir), { withFileTypes: true })
186
- .map((e) => (e.isDirectory() ? e.name + "/" : e.name))
187
- .sort();
276
+ const prefix = relDir && relDir !== "." ? relDir + "/" : "";
277
+ names = this.isIgnored(relDir)
278
+ ? []
279
+ : fs.readdirSync(this.abs(relDir), { withFileTypes: true })
280
+ .map((e) => (e.isDirectory() ? e.name + "/" : e.name))
281
+ .filter((n) => !this.isIgnored(prefix + n.replace(/\/$/, "")))
282
+ .sort();
188
283
  } catch {
189
284
  names = [];
190
285
  }
@@ -204,17 +299,21 @@ export class Repo {
204
299
  });
205
300
  return trim ? out.trim() : out;
206
301
  }
302
+ // Cached per Repo, like gitPrefix: whether this.root is a git checkout does
303
+ // not change during a run, and each probe is a process spawn.
207
304
  hasGit() {
208
305
  if (this.noGit) return false;
306
+ if (this._hasGit !== undefined) return this._hasGit;
209
307
  try {
210
308
  const probe = execFileSync("git", ["rev-parse", "--git-dir"], {
211
309
  cwd: this.root,
212
310
  stdio: ["ignore", "pipe", "ignore"],
213
311
  });
214
- return probe.toString().trim().length > 0;
312
+ this._hasGit = probe.toString().trim().length > 0;
215
313
  } catch {
216
- return false;
314
+ this._hasGit = false;
217
315
  }
316
+ return this._hasGit;
218
317
  }
219
318
  // A checkout without git — a source tarball, a vendored copy, a CI runner with
220
319
  // no VCS — still compiles. Freshness then rests on the input hash alone, and
@@ -236,14 +335,15 @@ export class Repo {
236
335
  // tracker uses.
237
336
  gitPrefix() {
238
337
  if (this.noGit || !this.hasGit()) return null;
338
+ if (this._prefix !== undefined) return this._prefix;
239
339
  try {
240
- return this.git(["rev-parse", "--show-prefix"]);
340
+ this._prefix = this.git(["rev-parse", "--show-prefix"]);
241
341
  } catch {
242
- return null;
342
+ this._prefix = null;
243
343
  }
344
+ return this._prefix;
244
345
  }
245
- toRepoPaths(gitPaths) {
246
- const prefix = this.gitPrefix();
346
+ toRepoPaths(gitPaths, prefix = this.gitPrefix()) {
247
347
  if (prefix === null) return [];
248
348
  const out = [];
249
349
  for (const p of gitPaths) {
@@ -253,31 +353,46 @@ export class Repo {
253
353
  }
254
354
  return out;
255
355
  }
256
- dirtyPaths() {
257
- if (this.noGit || !this.hasGit()) return [];
258
- let raw;
259
- try {
260
- // Untrimmed: a porcelain record is `XY <path>` with fixed columns, and an
261
- // unstaged change has X = " ". Trimming would eat that space from the
262
- // first record, so `.slice(3)` would drop the path's first character —
263
- // ` M .agentdoc/graph.json` became `agentdoc/graph.json`, slipped past
264
- // the output exclusion and counted as a dirty input (issue #11).
265
- raw = this.git(["status", "--porcelain", "-z", "--untracked-files=all"], { trim: false });
266
- } catch {
267
- return [];
268
- }
269
- const paths = [];
270
- let skipNext = false;
271
- for (const rec of raw.split("\0")) {
272
- if (!rec) continue;
273
- // In -z format a rename/copy is two records: `XY <to>` then a bare
274
- // `<from>` with no status. The bare record must be consumed, not parsed
275
- // as a status line.
276
- if (skipNext) { skipNext = false; continue; }
277
- if (rec[0] === "R" || rec[0] === "C" || rec[1] === "R" || rec[1] === "C") skipNext = true;
278
- paths.push(rec.slice(3));
356
+ // One `git status` for both questions a compile asks git about the
357
+ // working tree: what is dirty (`XY` records) and what is ignored (`!!`).
358
+ // `--untracked-files=all` lists untracked files individually, so they can
359
+ // be matched against the read tracker; `--ignored=matching` adds the
360
+ // ignored paths without descending into ignored directories.
361
+ // `--no-optional-locks` keeps it read-only: no refreshed index is written
362
+ // back, so it never contends with a git command the user is running.
363
+ // `-- .` scopes the scan to this.root, which may sit inside a larger
364
+ // checkout. Cached unless `fresh`: the ignore oracle must answer from one
365
+ // snapshot for the whole run.
366
+ statusRecords({ fresh = false } = {}) {
367
+ if (!fresh && this._status !== undefined) return this._status;
368
+ let recs = null;
369
+ if (!this.noGit && this.hasGit()) {
370
+ try {
371
+ // Untrimmed: a porcelain record is `XY <path>` with fixed columns, and
372
+ // an unstaged change has X = " ". Trimming would eat that space from
373
+ // the first record, so `.slice(3)` would drop the path's first
374
+ // character — ` M .agentdoc/graph.json` became `agentdoc/graph.json`,
375
+ // slipped past the output exclusion and counted as a dirty input
376
+ // (issue #11).
377
+ recs = parseStatusZ(this.git(
378
+ ["--no-optional-locks", "status", "--porcelain", "-z",
379
+ "--untracked-files=all", "--ignored=matching", "--", "."],
380
+ { trim: false }
381
+ ));
382
+ } catch {
383
+ recs = null;
384
+ }
279
385
  }
280
- return this.toRepoPaths(paths);
386
+ if (!fresh) this._status = recs;
387
+ return recs;
388
+ }
389
+ // Fresh by default, so a caller holding a Repo sees the current tree. The
390
+ // compile passes `snapshot: true` to read the same status the ignore oracle
391
+ // used — one git call, and a dirty flag consistent with the input set.
392
+ dirtyPaths({ snapshot = false } = {}) {
393
+ const recs = this.statusRecords({ fresh: !snapshot });
394
+ if (!recs) return [];
395
+ return this.toRepoPaths(recs.filter((r) => r.xy !== "!!").map((r) => r.path));
281
396
  }
282
397
  // null on failure (bad ref, missing object store) so callers can tell
283
398
  // "the diff failed" apart from "the diff is empty".
@@ -291,6 +406,23 @@ export class Repo {
291
406
  }
292
407
  }
293
408
 
409
+ // `git status --porcelain -z` records (git-status(1), "Porcelain Format
410
+ // Version 1"): `XY <path>`, NUL-terminated, never quoted.
411
+ function parseStatusZ(raw) {
412
+ const out = [];
413
+ let skipNext = false;
414
+ for (const rec of raw.split("\0")) {
415
+ if (!rec) continue;
416
+ // In -z format a rename/copy is two records: `XY <to>` then a bare
417
+ // `<from>` with no status. The bare record must be consumed, not parsed
418
+ // as a status line.
419
+ if (skipNext) { skipNext = false; continue; }
420
+ if (rec[0] === "R" || rec[0] === "C" || rec[1] === "R" || rec[1] === "C") skipNext = true;
421
+ out.push({ xy: rec.slice(0, 2), path: rec.slice(3) });
422
+ }
423
+ return out;
424
+ }
425
+
294
426
  // Minimal glob: '*' matches within one segment, '**' matches any depth.
295
427
  export function globToRegExp(glob) {
296
428
  const segs = glob.split("/");
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.2.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,6 +5,12 @@
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, longestOwner } from "./paths.mjs";
9
+
10
+ // Changed paths are compared in the graph's canonical form (git's: no "./").
11
+ // A path that is not repository-relative is kept verbatim so it is reported
12
+ // as unmatched rather than silently rewritten.
13
+ const canonical = (p) => canonicalPath(p) ?? p;
8
14
 
9
15
  // The affected-file classification is derived from the compiled graph, not from
10
16
  // a hand-written list of technology filenames. A list would go stale for every
@@ -17,7 +23,10 @@ import { dependentScope, verificationForChange } from "./dependents.mjs";
17
23
  // - every extractor identity, which names the technology -> dependency /
18
24
  // verification
19
25
  // - every documentation and constraint path on an entity -> documentation
20
- 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) {
21
30
  for (const c of graph.contracts) if (c.ref === p) return "contract";
22
31
  for (const e of graph.entities) {
23
32
  const d = e.derived || {};
@@ -26,6 +35,13 @@ function classify(graph, p, idx) {
26
35
  for (const x of d.runbooks || []) if (x === p) return "documentation";
27
36
  }
28
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.
29
45
  if (/(^|\/)(migrations?|alembic\/versions)\//.test(p)) return "resource";
30
46
  // A Resource named directly — by ref, or by the descriptor file that declares
31
47
  // it — is a resource change. Without this, `impact resource:default/x` reported
@@ -40,8 +56,43 @@ function classify(graph, p, idx) {
40
56
  return null;
41
57
  }
42
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
+
43
92
  export function impact(graph, changedPaths) {
44
93
  const idx = new ContextIndex(graph);
94
+ const migrations = migrationDirs(graph);
95
+ const migrationReasons = new Map(); // Resource ref -> Set(reason)
45
96
  const affectedComponents = new Map(); // ref -> Set(reason)
46
97
  const add = (ref, reason) => {
47
98
  if (idx.byRef.get(ref)?.kind !== "Component") return;
@@ -59,31 +110,48 @@ export function impact(graph, changedPaths) {
59
110
  const matchedVerification = new Set();
60
111
 
61
112
  for (const raw of changedPaths) {
62
- const p = raw.replace(/^\.\//, "");
113
+ const p = canonical(raw);
63
114
  const entity = idx.resolve(p);
64
115
  // A Resource is not a component. Adding it to `affectedComponents` put a
65
116
  // `resource:` ref in a list an agent reads as "components you must check",
66
117
  // while the components actually bound to it went unmentioned.
67
118
  if (entity && entity.kind === "Component") add(entity.ref, "direct file change inside " + entity.ref);
68
119
  else unmatched.push(p);
69
- const kind = classify(graph, p, idx);
120
+ const kind = classify(graph, p, idx, migrations);
70
121
  if (kind === "contract") contracts.add(p);
71
122
  if (kind === "documentation") docs.add(p);
72
123
  if (kind === "resource") {
73
- resources.add(entity?.kind === "Resource" ? entity.ref : p);
74
- // Migration authority is not ownership. Only follow an existing binding
75
- // from the component containing this migration to a logical database.
76
- if (entity?.kind === "Component") {
77
- for (const rel of graph.relations) {
78
- const bound = rel.type === "usesResource" && rel.sourceRef === entity.ref ? rel.targetRef
79
- : rel.type === "resourceUsedBy" && rel.targetRef === entity.ref ? rel.sourceRef : null;
80
- 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
+ }
81
149
  }
82
150
  }
83
151
  }
84
152
  if (kind === "verification") verificationPaths.add(p);
85
153
  if (kind === "input") dependencyPaths.add(p);
86
- if (p.startsWith("agentdoc/")) catalogPaths.add(p);
154
+ if (ownsPath("agentdoc", p)) catalogPaths.add(p);
87
155
  }
88
156
 
89
157
  // A check's configuration is evidence of the components it covers, even
@@ -129,6 +197,7 @@ export function impact(graph, changedPaths) {
129
197
  const user = rel.type === "usesResource" ? rel.sourceRef : rel.targetRef;
130
198
  if (r === bound) {
131
199
  add(user, "resource change touches " + bound);
200
+ for (const reason of migrationReasons.get(bound) || []) add(user, reason);
132
201
  }
133
202
  }
134
203
  }
@@ -180,7 +249,8 @@ export function impact(graph, changedPaths) {
180
249
 
181
250
  function graphAffected(graph, changedPaths) {
182
251
  const inputs = new Set(graph.provenance.map((p) => p.path));
183
- for (const p of changedPaths) if (inputs.has(p)) return true;
252
+ const paths = changedPaths.map(canonical);
253
+ for (const p of paths) if (inputs.has(p)) return true;
184
254
  const idx = new ContextIndex(graph);
185
- return changedPaths.some((p) => idx.resolve(p));
255
+ return paths.some((p) => idx.resolve(p));
186
256
  }
package/core/paths.mjs ADDED
@@ -0,0 +1,71 @@
1
+ // Repository-relative path semantics, in one place.
2
+ //
3
+ // Every path the catalog stores or matches is in one canonical form, the one
4
+ // git uses for pathspecs and `ls-files` output: relative to the repository
5
+ // root, '/'-separated, with no "./", no empty or "." segments and no trailing
6
+ // slash. The repository root itself is the single value REPO_ROOT (".", which
7
+ // is what a root unit's `sourcePath` has always recorded).
8
+ //
9
+ // Ownership is decided on whole path segments, never on raw string prefixes:
10
+ // `src` owns `src` and `src/x.ts`, never `src2/x.ts`. The root owns every
11
+ // repository path, which makes it the lowest-precedence owner: when several
12
+ // owners match, the longest one wins, as in a routing table or CODEOWNERS.
13
+
14
+ export const REPO_ROOT = ".";
15
+
16
+ // Canonical form of a repository-relative path, or null for a path that is
17
+ // not one: absolute, drive-qualified, backslash-separated or escaping the
18
+ // repository through "..". Those are rejected here rather than resolved, the
19
+ // same rule `isRepoPath` applies to authored paths.
20
+ export function canonicalPath(p) {
21
+ if (typeof p !== "string") return null;
22
+ if (p.startsWith("/") || /^[A-Za-z]:/.test(p) || p.includes("\\")) return null;
23
+ const segs = [];
24
+ for (const seg of p.split("/")) {
25
+ if (seg === "" || seg === ".") continue;
26
+ if (seg === "..") return null;
27
+ segs.push(seg);
28
+ }
29
+ return segs.length ? segs.join("/") : REPO_ROOT;
30
+ }
31
+
32
+ export function isRepoRoot(p) {
33
+ return canonicalPath(p) === REPO_ROOT;
34
+ }
35
+
36
+ // True when `owner` (a directory such as a unit root or a component's
37
+ // sourcePath) contains `file`, segment by segment.
38
+ export function ownsPath(owner, file) {
39
+ const o = canonicalPath(owner);
40
+ const f = canonicalPath(file);
41
+ if (o === null || f === null) return false;
42
+ if (o === REPO_ROOT) return true;
43
+ return f === o || f.startsWith(o + "/");
44
+ }
45
+
46
+ // The most specific owner of `file` among `owners`, or null. Specificity is
47
+ // segment depth, so the root (depth 0) only wins when nothing else matches —
48
+ // a one-character sibling such as `a` must not tie with ".".
49
+ export function longestOwner(owners, file) {
50
+ let best = null;
51
+ let bestDepth = -1;
52
+ for (const o of owners) {
53
+ if (!ownsPath(o, file)) continue;
54
+ const c = canonicalPath(o);
55
+ const depth = c === REPO_ROOT ? 0 : c.split("/").length;
56
+ if (depth > bestDepth) {
57
+ best = o;
58
+ bestDepth = depth;
59
+ }
60
+ }
61
+ return best;
62
+ }
63
+
64
+ // Join a unit root and a path below it without ever producing "./x": a root
65
+ // unit's manifest is `package.json`, exactly as git and every other path in
66
+ // the graph spell it.
67
+ export function joinPath(root, rel) {
68
+ const r = canonicalPath(root);
69
+ const base = r === null ? String(root).replace(/\/+$/, "") : r;
70
+ return base === REPO_ROOT || base === "" ? rel : base + "/" + rel;
71
+ }