agent-doc-system 1.1.0 → 1.3.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 (67) hide show
  1. package/CHANGELOG.md +402 -0
  2. package/README.md +38 -0
  3. package/adapters/ansible.mjs +1 -1
  4. package/adapters/cache.mjs +9 -2
  5. package/adapters/contract-consumers.mjs +1 -1
  6. package/adapters/database.mjs +19 -4
  7. package/adapters/deploy-config.mjs +3 -2
  8. package/adapters/dockerfile.mjs +3 -2
  9. package/adapters/events.mjs +1 -1
  10. package/adapters/generic.mjs +2 -1
  11. package/adapters/github-actions.mjs +245 -85
  12. package/adapters/go.mjs +5 -4
  13. package/adapters/index.mjs +3 -1
  14. package/adapters/node.mjs +3 -2
  15. package/adapters/object-store.mjs +2 -2
  16. package/adapters/python.mjs +1142 -0
  17. package/adapters/resource-client.mjs +1 -1
  18. package/adapters/rust.mjs +990 -0
  19. package/adapters/turborepo.mjs +3 -2
  20. package/adapters/typescript.mjs +7 -5
  21. package/adapters/wrangler.mjs +3 -2
  22. package/bin/agentdoc.mjs +11 -4
  23. package/bin/usage.txt +2 -0
  24. package/core/audit.mjs +12 -8
  25. package/core/codes.mjs +4 -0
  26. package/core/compile.mjs +131 -16
  27. package/core/dependents.mjs +42 -0
  28. package/core/derive.mjs +4 -3
  29. package/core/descriptors.mjs +3 -2
  30. package/core/discover.mjs +61 -9
  31. package/core/fsx.mjs +185 -36
  32. package/core/graph.mjs +1 -1
  33. package/core/impact.mjs +66 -48
  34. package/core/paths.mjs +71 -0
  35. package/core/query.mjs +76 -31
  36. package/core/scaffold.mjs +216 -44
  37. package/core/sourcescan.mjs +14 -2
  38. package/core/toml.mjs +463 -140
  39. package/docs/ADAPTERS.md +196 -6
  40. package/docs/BENCHMARK.md +130 -0
  41. package/docs/CI.md +7 -1
  42. package/docs/EVALS.md +239 -16
  43. package/docs/MIGRATION.md +26 -6
  44. package/docs/PROVENANCE.md +6 -0
  45. package/docs/QUERY.md +78 -7
  46. package/docs/SPEC.md +69 -2
  47. package/docs/ci/github-actions.yml +1 -1
  48. package/evals/baseline-search.mjs +272 -0
  49. package/evals/facts.mjs +28 -0
  50. package/evals/harness.mjs +149 -119
  51. package/evals/scenarios/fixture-b-03-change-shared-lib.json +8 -1
  52. package/evals/scenarios/fixture-c-03-shared-vocabulary.json +7 -1
  53. package/evals/scenarios/fixture-e-01-change-shared-lib.json +14 -0
  54. package/evals/scenarios/fixture-e-02-change-manifest.json +9 -0
  55. package/evals/scenarios/fixture-e-03-local-module.json +9 -0
  56. package/evals/scenarios/fixture-e-04-ambiguous-crate.json +14 -0
  57. package/evals/scenarios/fixture-f-01-change-shared-lib.json +9 -0
  58. package/evals/scenarios/fixture-f-02-change-manifest.json +9 -0
  59. package/evals/scenarios/fixture-f-03-shadowed-import.json +9 -0
  60. package/evals/scenarios/fixture-f-04-nested-workspace.json +9 -0
  61. package/evals/scenarios/fixture-f-05-alembic-database.json +9 -0
  62. package/evals/scenarios/fixture-f-06-unittest-job.json +9 -0
  63. package/package.json +5 -2
  64. package/skills/agent-doc-system/SKILL.md +2 -1
  65. package/skills/agent-doc-system/references/cli.md +6 -1
  66. package/skills/agent-doc-system/references/create-migrate.md +21 -5
  67. package/templates/ci.yml +1 -1
package/core/impact.mjs CHANGED
@@ -3,8 +3,14 @@
3
3
  // When an agent edits a file, the question is not "what does this file do" but
4
4
  // "what must I keep consistent, and which gate proves it". This answers that
5
5
  // from the graph alone, so the answer is available before any exploration.
6
- import path from "node:path";
7
6
  import { ContextIndex } from "./query.mjs";
7
+ import { dependentScope, verificationForChange } from "./dependents.mjs";
8
+ import { canonicalPath, ownsPath } 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
@@ -26,7 +32,7 @@ function classify(graph, p, idx) {
26
32
  for (const x of d.runbooks || []) if (x === p) return "documentation";
27
33
  }
28
34
  if (/\.(md|mdx|adoc|rst|txt)$/.test(p)) return "documentation";
29
- if (/(^|\/)migrations?\//.test(p)) return "resource";
35
+ if (/(^|\/)(migrations?|alembic\/versions)\//.test(p)) return "resource";
30
36
  // A Resource named directly — by ref, or by the descriptor file that declares
31
37
  // it — is a resource change. Without this, `impact resource:default/x` reported
32
38
  // nothing at all, and a change to a dataset's owner was invisible. The loop
@@ -44,7 +50,7 @@ export function impact(graph, changedPaths) {
44
50
  const idx = new ContextIndex(graph);
45
51
  const affectedComponents = new Map(); // ref -> Set(reason)
46
52
  const add = (ref, reason) => {
47
- if (!idx.byRef.has(ref)) return;
53
+ if (idx.byRef.get(ref)?.kind !== "Component") return;
48
54
  if (!affectedComponents.has(ref)) affectedComponents.set(ref, new Set());
49
55
  affectedComponents.get(ref).add(reason);
50
56
  };
@@ -56,9 +62,10 @@ export function impact(graph, changedPaths) {
56
62
  const dependencyPaths = new Set();
57
63
  const catalogPaths = new Set();
58
64
  const unmatched = [];
65
+ const matchedVerification = new Set();
59
66
 
60
67
  for (const raw of changedPaths) {
61
- const p = raw.replace(/^\.\//, "");
68
+ const p = canonical(raw);
62
69
  const entity = idx.resolve(p);
63
70
  // A Resource is not a component. Adding it to `affectedComponents` put a
64
71
  // `resource:` ref in a list an agent reads as "components you must check",
@@ -68,11 +75,35 @@ export function impact(graph, changedPaths) {
68
75
  const kind = classify(graph, p, idx);
69
76
  if (kind === "contract") contracts.add(p);
70
77
  if (kind === "documentation") docs.add(p);
71
- if (kind === "resource") resources.add(p);
78
+ 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);
87
+ }
88
+ }
89
+ }
72
90
  if (kind === "verification") verificationPaths.add(p);
73
91
  if (kind === "input") dependencyPaths.add(p);
74
- if (p.startsWith("agentdoc/")) catalogPaths.add(p);
92
+ if (ownsPath("agentdoc", p)) catalogPaths.add(p);
93
+ }
94
+
95
+ // A check's configuration is evidence of the components it covers, even
96
+ // when the file lives outside every component root (for example CI). Add
97
+ // these before taking the seeds so their dependents' checks follow too.
98
+ for (const v of graph.verification) {
99
+ for (const p of verificationPaths) {
100
+ if (!v.configPaths?.includes(p)) continue;
101
+ matchedVerification.add(p);
102
+ for (const ref of v.componentRefs) add(ref, "verification configuration " + p + " changed");
103
+ }
75
104
  }
105
+ const gates = graph.gates.filter((g) => g.configPaths?.some((p) => verificationPaths.has(p)));
106
+ for (const g of gates) for (const p of g.configPaths) if (verificationPaths.has(p)) matchedVerification.add(p);
76
107
 
77
108
  // A contract change reaches its provider and every consumer.
78
109
  for (const c of contracts) {
@@ -84,36 +115,6 @@ export function impact(graph, changedPaths) {
84
115
  }
85
116
  }
86
117
 
87
- // Changing a component reaches the components that depend on it, and the
88
- // components it schedules or is scheduled by. Two hops: a direct dependent
89
- // and a dependent-of-a-dependent is the practical blast radius; beyond that
90
- // the graph stops being a routing aid and becomes a dump.
91
- for (const start of [...affectedComponents.keys()]) {
92
- for (let hop = 0; hop < 2; hop++) {
93
- for (const r of graph.relations) {
94
- if (r.type === "buildDependencyOf" || r.type === "testDependencyOf" || r.type === "buildDependsOn" || r.type === "testDependsOn") {
95
- if (r.targetRef === start) add(r.sourceRef, "depends on a changed component");
96
- if (r.sourceRef === start) add(r.targetRef, "a dependent of a changed component");
97
- }
98
- if (r.type === "schedules" || r.type === "scheduledBy" || r.type === "runtimeCalls" || r.type === "runtimeCalledBy") {
99
- if (r.sourceRef === start) add(r.targetRef, "connected to a changed component by " + r.type);
100
- if (r.targetRef === start) add(r.sourceRef, "connected to a changed component by " + r.type);
101
- }
102
- }
103
- }
104
- }
105
-
106
- // A dependency manifest change reaches both directions of the edge.
107
- for (const d of dependencyPaths) {
108
- for (const r of graph.relations) {
109
- if (r.type !== "buildDependsOn" && r.type !== "testDependsOn") continue;
110
- if (r.attributes?.package === path.basename(d)) {
111
- add(r.sourceRef, "dependency manifest " + d + " changed");
112
- add(r.targetRef, "depended on by a component whose manifest changed");
113
- }
114
- }
115
- }
116
-
117
118
  // A documentation change reaches the component that owns it.
118
119
  for (const d of docs) {
119
120
  for (const e of graph.entities) {
@@ -125,43 +126,59 @@ export function impact(graph, changedPaths) {
125
126
  }
126
127
  }
127
128
 
128
- // A resource change reaches every component bound to it. Matched on the full
129
- // ref or on a path that lies under the Resource's own source root, so a
130
- // Resource ref and its migration directory both reach the same components.
129
+ // A resource change reaches every component bound to that exact Resource.
130
+ // Never match a name as a substring of a migration path.
131
131
  for (const r of resources) {
132
- const refMatch = r.match(/^[a-z]+:[^/]+\/(.+)$/);
133
- const refName = refMatch ? refMatch[1] : null;
134
132
  for (const rel of graph.relations) {
135
133
  if (rel.type !== "usesResource" && rel.type !== "resourceUsedBy") continue;
136
134
  const bound = rel.type === "usesResource" ? rel.targetRef : rel.sourceRef;
137
135
  const user = rel.type === "usesResource" ? rel.sourceRef : rel.targetRef;
138
- const name = bound.split("/").slice(1).join("/");
139
- if ((refName && name === refName) || r.includes(name)) {
136
+ if (r === bound) {
140
137
  add(user, "resource change touches " + bound);
141
138
  }
142
139
  }
143
140
  }
144
141
 
142
+ // Preserve the existing direct runtime/schedule context, then follow only
143
+ // reverse build/test edges. Dependencies remain context in query's explicitly
144
+ // typed relations; they are never affected components just for being inputs.
145
+ const directlyAffected = [...affectedComponents.keys()];
146
+ for (const start of directlyAffected) {
147
+ for (const r of graph.relations) {
148
+ if (r.type === "schedules" || r.type === "scheduledBy" || r.type === "runtimeCalls" || r.type === "runtimeCalledBy") {
149
+ if (r.sourceRef === start) add(r.targetRef, "connected to a changed component by " + r.type);
150
+ if (r.targetRef === start) add(r.sourceRef, "connected to a changed component by " + r.type);
151
+ }
152
+ }
153
+ }
154
+ const seeds = [...affectedComponents.keys()];
155
+ for (const ref of dependentScope(graph, seeds).keys()) {
156
+ if (!affectedComponents.has(ref)) add(ref, "depends on a changed component");
157
+ }
158
+
145
159
  const refs = [...affectedComponents.keys()].sort();
146
- const verification = graph.verification.filter((v) => v.componentRefs.some((r) => refs.includes(r)));
160
+ const verification = verificationForChange(graph, seeds, directlyAffected);
147
161
  const conflicts = graph.conflicts.filter((c) => refs.includes(c.subject));
148
162
  const journeys = graph.journeys.filter((j) => j.components.some((r) => refs.includes(r)));
149
163
 
150
164
  return {
151
165
  changedFiles: changedPaths.slice().sort(),
152
- unmatchedFiles: unmatched.sort(),
166
+ unmatchedFiles: unmatched.filter((p) => !matchedVerification.has(p)).sort(),
153
167
  affectedComponents: refs.map((ref) => ({ ref, reasons: [...affectedComponents.get(ref)].sort() })),
154
168
  affectedContracts: [...contracts].sort(),
155
169
  affectedDocs: [...docs].sort(),
156
170
  affectedResources: [...resources].sort(),
157
171
  affectedInputs: [...dependencyPaths].sort(),
158
172
  affectedVerification: [...verificationPaths].sort(),
173
+ affectedGates: gates.map((v) => ({ id: v.id, tier: v.tier, command: v.command })),
159
174
  affectedCatalog: [...catalogPaths].sort(),
160
175
  catalogSourceChanged: catalogPaths.size > 0,
161
176
  // Any file the compiler read is a graph input: changing it invalidates the
162
177
  // committed graph even when no descriptor changed.
163
178
  graphWillBeStale: graphAffected(graph, changedPaths),
164
- recommendedVerification: verification.map((v) => ({ id: v.id, tier: v.tier, command: v.command })).slice(0, 25),
179
+ recommendedVerification: [...verification, ...gates].map((v) => ({ id: v.id, tier: v.tier, command: v.command })).slice(0, 25),
180
+ recommendedVerificationTotal: verification.length + gates.length,
181
+ recommendedVerificationTruncated: verification.length + gates.length > 25,
165
182
  openConflicts: conflicts.map((c) => ({ id: c.id, subject: c.subject, key: c.key, status: c.status, kind: c.kind })),
166
183
  relatedJourneys: journeys.map((j) => ({ id: j.id, description: j.description })),
167
184
  };
@@ -169,7 +186,8 @@ export function impact(graph, changedPaths) {
169
186
 
170
187
  function graphAffected(graph, changedPaths) {
171
188
  const inputs = new Set(graph.provenance.map((p) => p.path));
172
- for (const p of changedPaths) if (inputs.has(p)) return true;
189
+ const paths = changedPaths.map(canonical);
190
+ for (const p of paths) if (inputs.has(p)) return true;
173
191
  const idx = new ContextIndex(graph);
174
- return changedPaths.some((p) => idx.resolve(p));
192
+ return paths.some((p) => idx.resolve(p));
175
193
  }
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
+ }
package/core/query.mjs CHANGED
@@ -9,6 +9,8 @@
9
9
  // than silently dropped, so an agent can see that it is seeing a slice.
10
10
  import { AgentDocError, CODES } from "./codes.mjs";
11
11
  import { assertNoSecretsInText } from "./secrets.mjs";
12
+ import { verificationForChange } from "./dependents.mjs";
13
+ import { canonicalPath, longestOwner, ownsPath, REPO_ROOT } from "./paths.mjs";
12
14
 
13
15
  const DEFAULT_BUDGET = {
14
16
  relations: 40,
@@ -27,13 +29,13 @@ export class ContextIndex {
27
29
  constructor(graph) {
28
30
  this.graph = graph;
29
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.
30
34
  this.byPath = new Map();
31
35
  for (const e of graph.entities) {
32
- const sp = e.derived && e.derived.sourcePath;
33
- if (!sp) continue;
36
+ const sp = e.derived && canonicalPath(e.derived.sourcePath);
37
+ if (sp === null || sp === undefined) continue;
34
38
  this.byPath.set(sp, e);
35
- // Longest path first so a nested path resolves to its own component
36
- // rather than to a parent.
37
39
  }
38
40
  // Documentation and constraint files a descriptor links resolve to their
39
41
  // component, even when they live outside the source root. Following a
@@ -48,9 +50,12 @@ export class ContextIndex {
48
50
  ...(e.derived || {}).constraints || [],
49
51
  ...(e.derived || {}).runbooks || [],
50
52
  ];
51
- 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
+ }
52
57
  }
53
- this.paths = [...this.byPath.keys()].sort((a, b) => b.length - a.length);
58
+ this.paths = [...this.byPath.keys()];
54
59
  this.relationsByRef = new Map();
55
60
  for (const r of graph.relations) {
56
61
  if (!this.relationsByRef.has(r.sourceRef)) this.relationsByRef.set(r.sourceRef, []);
@@ -61,34 +66,64 @@ export class ContextIndex {
61
66
  }
62
67
 
63
68
  // A contract file resolves to the API entity that claims it, so "change this
64
- // 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`.
65
73
  contractFor(needle) {
66
- const norm = needle.replace(/^\.\//, "").replace(/\/+$/, "");
74
+ const norm = canonicalPath(needle);
75
+ if (norm === null) return null;
67
76
  let best = null;
68
77
  for (const c of (this.graph.contracts || [])) {
69
- if (norm === c.ref || norm.startsWith(c.ref)) {
78
+ if (contractCovers(c.ref, norm)) {
70
79
  if (!best || c.ref.length > best.ref.length) best = c;
71
80
  }
72
81
  }
73
82
  return best;
74
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
+ }
75
91
  resolve(needle) {
76
92
  if (this.byRef.has(needle)) return this.byRef.get(needle);
77
- 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;
78
98
  if (this.byDoc.has(norm)) return this.byDoc.get(norm);
79
- if (this.byPath.has(norm)) return this.byPath.get(norm);
80
- for (const p of this.paths) {
81
- if (norm === p || norm.startsWith(p + "/")) return this.byPath.get(p);
82
- }
83
- for (const p of this.paths) {
84
- if (needle.startsWith(p + "/")) return this.byPath.get(p);
85
- }
86
- if (this.byDoc.has(needle)) return this.byDoc.get(needle);
87
- const c = this.contractFor(needle);
88
- 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;
89
108
  }
90
109
  }
91
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
+
92
127
  function contractsOf(graph) {
93
128
  return graph.contracts || [];
94
129
  }
@@ -135,14 +170,15 @@ export function queryContext(graph, needle, opts = {}) {
135
170
  const apiProvider = entity.kind === "API" ? providerOf(ref) : null;
136
171
 
137
172
  const apiRefs = new Set([...providedApis, ...consumedApis]);
138
- const srcPath = (entity.derived && entity.derived.sourcePath) || null;
139
173
  const events = g.interfaces.events.filter(
140
174
  (e) =>
141
175
  e.producers.includes(ref) ||
142
176
  e.consumers.includes(ref) ||
143
177
  // A contract library that ships the subject catalog owns those subjects,
144
- // even when another component is the one that subscribes to them.
145
- (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)
146
182
  );
147
183
  const externals = g.externalDependencies.filter((e) => e.componentRef === ref);
148
184
  const capabilities = g.capabilities.filter((e) => e.componentRef === ref);
@@ -151,7 +187,7 @@ export function queryContext(graph, needle, opts = {}) {
151
187
  const verificationScope = entity.kind === "API"
152
188
  ? [ref, ...apiConsumers, ...(apiProvider ? [apiProvider] : [])]
153
189
  : [ref];
154
- const verification = g.verification.filter((e) => e.componentRefs.some((r) => verificationScope.includes(r)));
190
+ const verification = verificationForChange(g, verificationScope);
155
191
  const journeys = g.journeys.filter((j) => j.components.includes(ref));
156
192
  const contracts = g.contracts.filter((c) => c.apiRef && apiRefs.has(c.apiRef));
157
193
  const conflicts = g.conflicts.filter((c) => c.subject === ref);
@@ -231,9 +267,11 @@ export function queryContext(graph, needle, opts = {}) {
231
267
  ),
232
268
  capabilities: take(capabilities.map((c) => ({ name: c.name, evidenceClass: c.evidenceClass })), budget.capabilities),
233
269
  constraints: {
234
- docs: (entity.derived && entity.derived.docs) || [],
235
- constraints: (entity.derived && entity.derived.constraints) || [],
236
- runbooks: (entity.derived && entity.derived.runbooks) || [],
270
+ // Components derive these lists (including conventional files); central
271
+ // entities such as Resources and Systems carry them only in their spec.
272
+ docs: entity.derived?.docs || entity.entity.spec?.context?.docs || [],
273
+ constraints: entity.derived?.constraints || entity.entity.spec?.context?.constraints || [],
274
+ runbooks: entity.derived?.runbooks || entity.entity.spec?.context?.runbooks || [],
237
275
  },
238
276
  verification: take(
239
277
  verification.map((v) => ({ id: v.id, tier: v.tier, command: v.command, configPaths: v.configPaths })),
@@ -329,7 +367,7 @@ function contractRef(graph, apiRef) {
329
367
  }
330
368
 
331
369
  // Compact markdown rendering for a prompt or a human. Same routing, less tokens.
332
- export function renderContext(ctx) {
370
+ export function renderContext(ctx, opts = {}) {
333
371
  const L = [];
334
372
  const e = ctx.entity;
335
373
  L.push("# " + e.ref + " (" + e.kind + ")");
@@ -345,9 +383,16 @@ export function renderContext(ctx) {
345
383
  if (ctx.events.items.length) L.push("- events: " + ctx.events.items.map((x) => x.pattern + " [" + x.resolution + "]").join(", "));
346
384
  if (ctx.externalDependencies.items.length) L.push("- external: " + ctx.externalDependencies.items.map((x) => x.name + " (" + x.role + ")").join(", "));
347
385
  if (ctx.capabilities.items.length) L.push("- capabilities: " + ctx.capabilities.items.map((x) => x.name).join(", "));
348
- if (ctx.constraints.constraints.length) L.push("- CONSTRAINTS: " + ctx.constraints.constraints.join(", "));
349
- if (ctx.constraints.docs.length) L.push("- docs: " + ctx.constraints.docs.join(", "));
350
- if (ctx.constraints.runbooks.length) L.push("- runbooks: " + ctx.constraints.runbooks.join(", "));
386
+ for (const key of ["constraints", "docs", "runbooks"]) {
387
+ const paths = ctx.constraints[key];
388
+ if (!paths.length) continue;
389
+ // Constraints are critical routing context; cap them only on request.
390
+ const limit = opts.budget?.[key] ?? (key === "constraints" ? paths.length : 12);
391
+ const shown = paths.slice(0, limit);
392
+ const label = key === "constraints" ? "CONSTRAINTS" : key;
393
+ L.push("- " + label + ": " + shown.join(", ") +
394
+ (shown.length < paths.length ? " [truncated: " + shown.length + " of " + paths.length + "]" : ""));
395
+ }
351
396
  if (ctx.verification.items.length) {
352
397
  L.push("- verify:");
353
398
  for (const v of ctx.verification.items) L.push(" - [" + v.tier + "] " + v.command);