agent-doc-system 1.1.0 → 1.2.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 (52) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/README.md +38 -0
  3. package/adapters/ansible.mjs +1 -1
  4. package/adapters/cache.mjs +8 -2
  5. package/adapters/contract-consumers.mjs +1 -1
  6. package/adapters/database.mjs +18 -4
  7. package/adapters/events.mjs +1 -1
  8. package/adapters/github-actions.mjs +244 -85
  9. package/adapters/go.mjs +1 -1
  10. package/adapters/index.mjs +3 -1
  11. package/adapters/object-store.mjs +2 -2
  12. package/adapters/python.mjs +1142 -0
  13. package/adapters/resource-client.mjs +1 -1
  14. package/adapters/rust.mjs +987 -0
  15. package/adapters/typescript.mjs +1 -1
  16. package/bin/agentdoc.mjs +2 -2
  17. package/core/audit.mjs +5 -3
  18. package/core/codes.mjs +4 -0
  19. package/core/compile.mjs +124 -11
  20. package/core/dependents.mjs +42 -0
  21. package/core/discover.mjs +60 -9
  22. package/core/fsx.mjs +17 -0
  23. package/core/graph.mjs +1 -1
  24. package/core/impact.mjs +55 -44
  25. package/core/query.mjs +18 -8
  26. package/core/scaffold.mjs +87 -22
  27. package/core/sourcescan.mjs +11 -2
  28. package/core/toml.mjs +463 -140
  29. package/docs/ADAPTERS.md +196 -6
  30. package/docs/BENCHMARK.md +68 -0
  31. package/docs/CI.md +1 -1
  32. package/docs/EVALS.md +33 -2
  33. package/docs/MIGRATION.md +4 -2
  34. package/docs/QUERY.md +53 -4
  35. package/docs/SPEC.md +22 -0
  36. package/docs/ci/github-actions.yml +1 -1
  37. package/evals/harness.mjs +32 -6
  38. package/evals/scenarios/fixture-b-03-change-shared-lib.json +8 -1
  39. package/evals/scenarios/fixture-c-03-shared-vocabulary.json +7 -1
  40. package/evals/scenarios/fixture-e-01-change-shared-lib.json +14 -0
  41. package/evals/scenarios/fixture-e-02-change-manifest.json +9 -0
  42. package/evals/scenarios/fixture-e-03-local-module.json +9 -0
  43. package/evals/scenarios/fixture-e-04-ambiguous-crate.json +14 -0
  44. package/evals/scenarios/fixture-f-01-change-shared-lib.json +9 -0
  45. package/evals/scenarios/fixture-f-02-change-manifest.json +9 -0
  46. package/evals/scenarios/fixture-f-03-shadowed-import.json +9 -0
  47. package/evals/scenarios/fixture-f-04-nested-workspace.json +9 -0
  48. package/evals/scenarios/fixture-f-05-alembic-database.json +9 -0
  49. package/evals/scenarios/fixture-f-06-unittest-job.json +9 -0
  50. package/package.json +2 -1
  51. package/skills/agent-doc-system/references/create-migrate.md +13 -3
  52. package/templates/ci.yml +1 -1
package/core/impact.mjs CHANGED
@@ -3,8 +3,8 @@
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
8
 
9
9
  // The affected-file classification is derived from the compiled graph, not from
10
10
  // a hand-written list of technology filenames. A list would go stale for every
@@ -26,7 +26,7 @@ function classify(graph, p, idx) {
26
26
  for (const x of d.runbooks || []) if (x === p) return "documentation";
27
27
  }
28
28
  if (/\.(md|mdx|adoc|rst|txt)$/.test(p)) return "documentation";
29
- if (/(^|\/)migrations?\//.test(p)) return "resource";
29
+ if (/(^|\/)(migrations?|alembic\/versions)\//.test(p)) return "resource";
30
30
  // A Resource named directly — by ref, or by the descriptor file that declares
31
31
  // it — is a resource change. Without this, `impact resource:default/x` reported
32
32
  // nothing at all, and a change to a dataset's owner was invisible. The loop
@@ -44,7 +44,7 @@ export function impact(graph, changedPaths) {
44
44
  const idx = new ContextIndex(graph);
45
45
  const affectedComponents = new Map(); // ref -> Set(reason)
46
46
  const add = (ref, reason) => {
47
- if (!idx.byRef.has(ref)) return;
47
+ if (idx.byRef.get(ref)?.kind !== "Component") return;
48
48
  if (!affectedComponents.has(ref)) affectedComponents.set(ref, new Set());
49
49
  affectedComponents.get(ref).add(reason);
50
50
  };
@@ -56,6 +56,7 @@ export function impact(graph, changedPaths) {
56
56
  const dependencyPaths = new Set();
57
57
  const catalogPaths = new Set();
58
58
  const unmatched = [];
59
+ const matchedVerification = new Set();
59
60
 
60
61
  for (const raw of changedPaths) {
61
62
  const p = raw.replace(/^\.\//, "");
@@ -68,12 +69,36 @@ export function impact(graph, changedPaths) {
68
69
  const kind = classify(graph, p, idx);
69
70
  if (kind === "contract") contracts.add(p);
70
71
  if (kind === "documentation") docs.add(p);
71
- if (kind === "resource") resources.add(p);
72
+ 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);
81
+ }
82
+ }
83
+ }
72
84
  if (kind === "verification") verificationPaths.add(p);
73
85
  if (kind === "input") dependencyPaths.add(p);
74
86
  if (p.startsWith("agentdoc/")) catalogPaths.add(p);
75
87
  }
76
88
 
89
+ // A check's configuration is evidence of the components it covers, even
90
+ // when the file lives outside every component root (for example CI). Add
91
+ // these before taking the seeds so their dependents' checks follow too.
92
+ for (const v of graph.verification) {
93
+ for (const p of verificationPaths) {
94
+ if (!v.configPaths?.includes(p)) continue;
95
+ matchedVerification.add(p);
96
+ for (const ref of v.componentRefs) add(ref, "verification configuration " + p + " changed");
97
+ }
98
+ }
99
+ const gates = graph.gates.filter((g) => g.configPaths?.some((p) => verificationPaths.has(p)));
100
+ for (const g of gates) for (const p of g.configPaths) if (verificationPaths.has(p)) matchedVerification.add(p);
101
+
77
102
  // A contract change reaches its provider and every consumer.
78
103
  for (const c of contracts) {
79
104
  for (const api of graph.entities.filter((e) => e.kind === "API")) {
@@ -84,36 +109,6 @@ export function impact(graph, changedPaths) {
84
109
  }
85
110
  }
86
111
 
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
112
  // A documentation change reaches the component that owns it.
118
113
  for (const d of docs) {
119
114
  for (const e of graph.entities) {
@@ -125,43 +120,59 @@ export function impact(graph, changedPaths) {
125
120
  }
126
121
  }
127
122
 
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.
123
+ // A resource change reaches every component bound to that exact Resource.
124
+ // Never match a name as a substring of a migration path.
131
125
  for (const r of resources) {
132
- const refMatch = r.match(/^[a-z]+:[^/]+\/(.+)$/);
133
- const refName = refMatch ? refMatch[1] : null;
134
126
  for (const rel of graph.relations) {
135
127
  if (rel.type !== "usesResource" && rel.type !== "resourceUsedBy") continue;
136
128
  const bound = rel.type === "usesResource" ? rel.targetRef : rel.sourceRef;
137
129
  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)) {
130
+ if (r === bound) {
140
131
  add(user, "resource change touches " + bound);
141
132
  }
142
133
  }
143
134
  }
144
135
 
136
+ // Preserve the existing direct runtime/schedule context, then follow only
137
+ // reverse build/test edges. Dependencies remain context in query's explicitly
138
+ // typed relations; they are never affected components just for being inputs.
139
+ const directlyAffected = [...affectedComponents.keys()];
140
+ for (const start of directlyAffected) {
141
+ for (const r of graph.relations) {
142
+ if (r.type === "schedules" || r.type === "scheduledBy" || r.type === "runtimeCalls" || r.type === "runtimeCalledBy") {
143
+ if (r.sourceRef === start) add(r.targetRef, "connected to a changed component by " + r.type);
144
+ if (r.targetRef === start) add(r.sourceRef, "connected to a changed component by " + r.type);
145
+ }
146
+ }
147
+ }
148
+ const seeds = [...affectedComponents.keys()];
149
+ for (const ref of dependentScope(graph, seeds).keys()) {
150
+ if (!affectedComponents.has(ref)) add(ref, "depends on a changed component");
151
+ }
152
+
145
153
  const refs = [...affectedComponents.keys()].sort();
146
- const verification = graph.verification.filter((v) => v.componentRefs.some((r) => refs.includes(r)));
154
+ const verification = verificationForChange(graph, seeds, directlyAffected);
147
155
  const conflicts = graph.conflicts.filter((c) => refs.includes(c.subject));
148
156
  const journeys = graph.journeys.filter((j) => j.components.some((r) => refs.includes(r)));
149
157
 
150
158
  return {
151
159
  changedFiles: changedPaths.slice().sort(),
152
- unmatchedFiles: unmatched.sort(),
160
+ unmatchedFiles: unmatched.filter((p) => !matchedVerification.has(p)).sort(),
153
161
  affectedComponents: refs.map((ref) => ({ ref, reasons: [...affectedComponents.get(ref)].sort() })),
154
162
  affectedContracts: [...contracts].sort(),
155
163
  affectedDocs: [...docs].sort(),
156
164
  affectedResources: [...resources].sort(),
157
165
  affectedInputs: [...dependencyPaths].sort(),
158
166
  affectedVerification: [...verificationPaths].sort(),
167
+ affectedGates: gates.map((v) => ({ id: v.id, tier: v.tier, command: v.command })),
159
168
  affectedCatalog: [...catalogPaths].sort(),
160
169
  catalogSourceChanged: catalogPaths.size > 0,
161
170
  // Any file the compiler read is a graph input: changing it invalidates the
162
171
  // committed graph even when no descriptor changed.
163
172
  graphWillBeStale: graphAffected(graph, changedPaths),
164
- recommendedVerification: verification.map((v) => ({ id: v.id, tier: v.tier, command: v.command })).slice(0, 25),
173
+ recommendedVerification: [...verification, ...gates].map((v) => ({ id: v.id, tier: v.tier, command: v.command })).slice(0, 25),
174
+ recommendedVerificationTotal: verification.length + gates.length,
175
+ recommendedVerificationTruncated: verification.length + gates.length > 25,
165
176
  openConflicts: conflicts.map((c) => ({ id: c.id, subject: c.subject, key: c.key, status: c.status, kind: c.kind })),
166
177
  relatedJourneys: journeys.map((j) => ({ id: j.id, description: j.description })),
167
178
  };
package/core/query.mjs CHANGED
@@ -9,6 +9,7 @@
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";
12
13
 
13
14
  const DEFAULT_BUDGET = {
14
15
  relations: 40,
@@ -151,7 +152,7 @@ export function queryContext(graph, needle, opts = {}) {
151
152
  const verificationScope = entity.kind === "API"
152
153
  ? [ref, ...apiConsumers, ...(apiProvider ? [apiProvider] : [])]
153
154
  : [ref];
154
- const verification = g.verification.filter((e) => e.componentRefs.some((r) => verificationScope.includes(r)));
155
+ const verification = verificationForChange(g, verificationScope);
155
156
  const journeys = g.journeys.filter((j) => j.components.includes(ref));
156
157
  const contracts = g.contracts.filter((c) => c.apiRef && apiRefs.has(c.apiRef));
157
158
  const conflicts = g.conflicts.filter((c) => c.subject === ref);
@@ -231,9 +232,11 @@ export function queryContext(graph, needle, opts = {}) {
231
232
  ),
232
233
  capabilities: take(capabilities.map((c) => ({ name: c.name, evidenceClass: c.evidenceClass })), budget.capabilities),
233
234
  constraints: {
234
- docs: (entity.derived && entity.derived.docs) || [],
235
- constraints: (entity.derived && entity.derived.constraints) || [],
236
- runbooks: (entity.derived && entity.derived.runbooks) || [],
235
+ // Components derive these lists (including conventional files); central
236
+ // entities such as Resources and Systems carry them only in their spec.
237
+ docs: entity.derived?.docs || entity.entity.spec?.context?.docs || [],
238
+ constraints: entity.derived?.constraints || entity.entity.spec?.context?.constraints || [],
239
+ runbooks: entity.derived?.runbooks || entity.entity.spec?.context?.runbooks || [],
237
240
  },
238
241
  verification: take(
239
242
  verification.map((v) => ({ id: v.id, tier: v.tier, command: v.command, configPaths: v.configPaths })),
@@ -329,7 +332,7 @@ function contractRef(graph, apiRef) {
329
332
  }
330
333
 
331
334
  // Compact markdown rendering for a prompt or a human. Same routing, less tokens.
332
- export function renderContext(ctx) {
335
+ export function renderContext(ctx, opts = {}) {
333
336
  const L = [];
334
337
  const e = ctx.entity;
335
338
  L.push("# " + e.ref + " (" + e.kind + ")");
@@ -345,9 +348,16 @@ export function renderContext(ctx) {
345
348
  if (ctx.events.items.length) L.push("- events: " + ctx.events.items.map((x) => x.pattern + " [" + x.resolution + "]").join(", "));
346
349
  if (ctx.externalDependencies.items.length) L.push("- external: " + ctx.externalDependencies.items.map((x) => x.name + " (" + x.role + ")").join(", "));
347
350
  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(", "));
351
+ for (const key of ["constraints", "docs", "runbooks"]) {
352
+ const paths = ctx.constraints[key];
353
+ if (!paths.length) continue;
354
+ // Constraints are critical routing context; cap them only on request.
355
+ const limit = opts.budget?.[key] ?? (key === "constraints" ? paths.length : 12);
356
+ const shown = paths.slice(0, limit);
357
+ const label = key === "constraints" ? "CONSTRAINTS" : key;
358
+ L.push("- " + label + ": " + shown.join(", ") +
359
+ (shown.length < paths.length ? " [truncated: " + shown.length + " of " + paths.length + "]" : ""));
360
+ }
351
361
  if (ctx.verification.items.length) {
352
362
  L.push("- verify:");
353
363
  for (const v of ctx.verification.items) L.push(" - [" + v.tier + "] " + v.command);
package/core/scaffold.mjs CHANGED
@@ -14,7 +14,12 @@ import path from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { AgentDocError, CODES } from "./codes.mjs";
16
16
  import { Repo } from "./fsx.mjs";
17
+ import { discoverUnits, isNotUnitRoot } from "./discover.mjs";
17
18
  import { classifyContractFile } from "./contracts.mjs";
19
+ import { parse as parseToml } from "./toml.mjs";
20
+ import { cargoUnitShape, isExcludedWorkspaceMember, isVirtualWorkspaceManifest } from "../adapters/rust.mjs";
21
+ import { readPythonUnit } from "../adapters/python.mjs";
22
+ import { loadAdapters } from "../adapters/index.mjs";
18
23
 
19
24
  const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "templates");
20
25
 
@@ -56,7 +61,7 @@ export function installTemplates(repo, cfg, { write, root, force = false, eligib
56
61
  };
57
62
 
58
63
  // ── configuration ───────────────────────────────────────────────────────
59
- const config = buildConfig(scanLayout(r));
64
+ const config = buildConfig(scanLayout(r, cfg));
60
65
  put("agentdoc/agentdoc.config.yaml", config);
61
66
  put("agentdoc/journeys.yaml", "schemaVersion: agentdoc.dev/journeys/v1\njourneys: []\n");
62
67
  for (const f of ["domains.yaml", "systems.yaml", "apis.yaml", "resources.yaml"]) {
@@ -87,17 +92,17 @@ export function installTemplates(repo, cfg, { write, root, force = false, eligib
87
92
  }
88
93
 
89
94
  // Layout inference for CREATE mode: which directories look like unit roots.
90
- // Uses only file-presence heuristics that any repository can answer.
91
- function scanLayout(repo) {
95
+ // Combines shallow marker heuristics with the compiler's adapter discovery.
96
+ function scanLayout(repo, cfg) {
92
97
  const candidates = new Map();
93
98
  const note = (root, kind) => {
94
99
  if (!candidates.has(root)) candidates.set(root, { root, kinds: new Set(), evidence: [] });
95
100
  candidates.get(root).kinds.add(kind);
96
101
  candidates.get(root).evidence.push(kind);
97
102
  };
98
- const skip = new Set(["node_modules", ".git", "dist", "build", "vendor", "target", "agentdoc", "docs", "scripts", ".github", "test", "tests", "examples", "example"]);
103
+ const skip = new Set(["node_modules", ".git", "dist", "build", "vendor", "target", "test", "tests", "examples", "example"]);
99
104
  const walk = (dir, depth) => {
100
- if (depth > 3) return;
105
+ if (depth > 3 || isNotUnitRoot(dir, cfg)) return;
101
106
  let entries;
102
107
  try { entries = fs.readdirSync(path.join(repo.root, dir), { withFileTypes: true }); } catch { return; }
103
108
  for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
@@ -114,15 +119,62 @@ function scanLayout(repo) {
114
119
  const parent = () => rel.slice(0, -("/" + e.name).length);
115
120
  if (e.name === "package.json") note(parent(), "node");
116
121
  if (e.name === "go.mod") note(parent(), "go");
122
+ if (e.name === "Cargo.toml" && cargoUnitShape(repo, parent()) && !isExcludedWorkspaceMember(repo, parent())) note(parent(), "rust");
123
+ if (e.name === "pyproject.toml" || e.name === "setup.py" || e.name === "setup.cfg") note(parent(), "python");
124
+ if (/^requirements.*\.txt$/.test(e.name)) note(parent(), "python");
117
125
  if (e.name === "Dockerfile") note(parent(), "docker");
118
126
  if (/^wrangler\.(toml|json|jsonc)$/.test(e.name)) note(parent(), "platform");
119
127
  if (e.name === "go.mod") note(rel.slice(0, -"/go.mod".length), "go");
120
128
  }
121
129
  };
122
130
  walk("", 0);
131
+ // Directory-name/depth heuristics must not hide units declared by adapters
132
+ // (for example a uv or Cargo member under examples/). During init there is
133
+ // no config yet: probe supported marker adapters so those members can also
134
+ // select their adapter in the generated configuration.
135
+ const discoveryCfg = cfg || { discovery: { componentDescriptors: [] } };
136
+ const adapters = loadAdapters(cfg ? cfg.adapters : Object.values(ADAPTER_FOR_MARKER));
137
+ for (const unit of discoverUnits(repo, discoveryCfg, adapters).eligible) {
138
+ if (!unit.root || unit.root === "." || isNotUnitRoot(unit.root, cfg)) continue;
139
+ for (const [kind, adapter] of Object.entries(ADAPTER_FOR_MARKER)) {
140
+ if (unit.markers.some((marker) => marker.adapter === adapter)) note(unit.root, kind);
141
+ }
142
+ }
143
+ for (const u of [...candidates.values()]) {
144
+ // Layout-only candidates need the same artifact evidence as units already
145
+ // discovered by the compiler. A technology name alone proves no shape.
146
+ u.deployable = false;
147
+ u.importable = false;
148
+ for (const adapter of loadAdapters([...u.kinds].map((k) => ADAPTER_FOR_MARKER[k]))) {
149
+ const marker = adapter.detect({ repo, cfg }, u.root || ".");
150
+ if (!marker) continue;
151
+ const shape = marker.shape || "both";
152
+ u.deployable ||= shape === "deployable" || shape === "both";
153
+ u.importable ||= shape === "importable" || shape === "both";
154
+ }
155
+ if (!u.kinds.has("python")) continue;
156
+ const info = readPythonUnit(repo, u.root);
157
+ // A uv workspace root with no distribution is not a unit. Scaffolding it
158
+ // would invent a component for the virtual manifest.
159
+ if (!info.dist && !info.deployable && !info.importable && !info.error && virtualUvWorkspace(repo, u.root)) {
160
+ candidates.delete(u.root);
161
+ }
162
+ }
123
163
  return [...candidates.values()].sort((a, b) => (a.root < b.root ? -1 : 1));
124
164
  }
125
165
 
166
+ function virtualUvWorkspace(repo, root) {
167
+ const file = !root || root === "." ? "pyproject.toml" : root + "/pyproject.toml";
168
+ if (!repo.exists(file)) return false;
169
+ try {
170
+ const doc = parseToml(repo.readText(file), file);
171
+ const ws = doc.tool && doc.tool.uv && doc.tool.uv.workspace;
172
+ return Boolean(ws && typeof ws === "object" && !Array.isArray(ws));
173
+ } catch {
174
+ return false;
175
+ }
176
+ }
177
+
126
178
  function discoverUnitNames(repo, cfg, eligible = null) {
127
179
  const out = [];
128
180
  const nameFor = (root) => {
@@ -138,6 +190,18 @@ function discoverUnitNames(repo, cfg, eligible = null) {
138
190
  const m = /^module\s+(\S+)/m.exec(repo.readText(gomod));
139
191
  if (m) return kebab(m[1].split("/").pop());
140
192
  }
193
+ const cargo = root + "/Cargo.toml";
194
+ if (repo.exists(cargo)) {
195
+ try {
196
+ const doc = parseToml(repo.readText(cargo));
197
+ const n = doc && doc.package && typeof doc.package.name === "string" ? doc.package.name : "";
198
+ // `_` becomes `-u-` before kebab, so `foo_bar` does not collapse into
199
+ // the same component name as `foo-bar`.
200
+ if (n) return kebab(n.replace(/_/g, "-u-"));
201
+ } catch { /* a manifest that does not parse is not a name */ }
202
+ }
203
+ const python = readPythonUnit(repo, root);
204
+ if (python.dist) return kebab(python.dist);
141
205
  return kebab(path.basename(root));
142
206
  };
143
207
  const seen = new Set();
@@ -152,27 +216,32 @@ function discoverUnitNames(repo, cfg, eligible = null) {
152
216
  deployable: Boolean(u.deployable),
153
217
  importable: Boolean(u.importable),
154
218
  })),
155
- ...scanLayout(repo).map((u) => ({ root: u.root, kinds: [...u.kinds].sort(), deployable: false, importable: false })),
219
+ ...scanLayout(repo, cfg).map((u) => ({
220
+ root: u.root,
221
+ kinds: [...u.kinds].sort(),
222
+ deployable: Boolean(u.deployable),
223
+ importable: Boolean(u.importable),
224
+ })),
156
225
  ];
157
- for (const u of units) {
158
- if (u.root === "." || !u.root || seenRoots.has(u.root)) continue;
226
+ for (const found of units) {
227
+ if (found.root === "." || !found.root || seenRoots.has(found.root)) continue;
228
+ if (isNotUnitRoot(found.root, cfg)) continue;
229
+ if (isVirtualWorkspaceManifest(repo, found.root)) continue;
230
+ const u = found;
159
231
  seenRoots.add(u.root);
160
232
  const name = nameFor(u.root);
161
233
  if (seen.has(name)) continue;
162
234
  seen.add(name);
163
235
  out.push({ ...u, name });
164
236
  }
165
- void cfg;
166
237
  return out;
167
238
  }
168
239
 
169
240
  function componentDescriptor(u) {
170
- const deployable = u.deployable || u.kinds.some((k) => k === "docker" || k === "platform");
171
- const importable = u.importable || u.kinds.some((k) => k === "node" || k === "go");
172
241
  // "library" and "service" both have to be corroborated by artifact evidence
173
242
  // (AGENTDOC_ARTIFACT_TYPE); a unit the compiler only knows from configuration
174
243
  // shows neither, so claiming a concrete type would manufacture an error.
175
- const type = deployable ? "service" : importable ? "library" : "other";
244
+ const type = u.deployable ? "service" : u.importable ? "library" : "other";
176
245
  return [
177
246
  "apiVersion: agentdoc.dev/v1",
178
247
  "kind: Component",
@@ -194,6 +263,8 @@ function componentDescriptor(u) {
194
263
  const ADAPTER_FOR_MARKER = {
195
264
  node: "node",
196
265
  go: "go",
266
+ rust: "rust",
267
+ python: "python",
197
268
  docker: "dockerfile",
198
269
  platform: "wrangler",
199
270
  };
@@ -206,13 +277,6 @@ function buildConfig(layout) {
206
277
  if (!groups.has(head)) groups.set(head, []);
207
278
  groups.get(head).push(r);
208
279
  }
209
- // A manifest at the repository root yields an empty head; emitting
210
- // "/agentdoc.yaml" would be an absolute glob, which the loader rejects.
211
- const componentGlobs = [...groups.entries()]
212
- .filter(([head]) => head.length > 0)
213
- .sort((a, b) => (a[0] < b[0] ? -1 : 1))
214
- .map(([head, list]) => (list.length === 1 && list[0] === head ? head + "/agentdoc.yaml" : head + "/**/agentdoc.yaml"));
215
- if (!componentGlobs.length) componentGlobs.push("*/**/agentdoc.yaml");
216
280
 
217
281
  const adapters = new Set(["generic"]);
218
282
  for (const l of layout) {
@@ -239,9 +303,10 @@ function buildConfig(layout) {
239
303
  "discovery:",
240
304
  " componentDescriptorName: agentdoc.yaml",
241
305
  " componentDescriptors:",
242
- // Globs are quoted: an unquoted leading "*" is an alias in strict YAML,
243
- // and a configuration the compiler cannot parse is worse than none.
244
- ...componentGlobs.map((g) => " - " + yamlStr(g)),
306
+ // Discover every scaffolded descriptor, including adapter roots outside
307
+ // the shallow layout and units added after init. Keep the leading glob
308
+ // quoted because an unquoted "*" is an alias in strict YAML.
309
+ ' - "*/**/agentdoc.yaml"',
245
310
  " centralDescriptors:",
246
311
  " - agentdoc/domains.yaml",
247
312
  " - agentdoc/systems.yaml",
@@ -27,7 +27,7 @@ export function sourceFiles(repo, root, { test = false, extensions } = {}) {
27
27
  const DEFAULT_EXT = /\.(ts|tsx|js|jsx|mts|cts|mjs|cjs|go|py|rb|java|kt|rs|ex|exs|cs|php|swift|scala|sh)$/;
28
28
  const re = extensions || DEFAULT_EXT;
29
29
  const out = [];
30
- for (const f of repo.walk(root)) {
30
+ for (const f of repo.walkUnit(root)) {
31
31
  if (!re.test(f)) continue;
32
32
  const isTest = TEST_PATH.test(f) || TEST_FILE.test(f) || /_test\.[a-z]+$/.test(f);
33
33
  if (isTest !== test) continue;
@@ -64,7 +64,16 @@ export function findMigrationDirs(repo, root, maxDepth = 5) {
64
64
  if (!e.isDirectory() || e.isSymbolicLink()) continue;
65
65
  if (["node_modules", ".git", "vendor", "target", "dist", "build", ".next", "__pycache__"].includes(e.name)) continue;
66
66
  const abs = path.join(dirAbs, e.name);
67
+ const rel = path.relative(repo.root, abs).split(path.sep).join("/");
68
+ if (!repo.ownsUnitPath(root, rel)) continue;
67
69
  if (e.name === "migrations" || e.name === "migration") {
70
+ out.push(rel);
71
+ continue;
72
+ }
73
+ // Alembic keeps revisions in versions/ under a directory named alembic.
74
+ // The directory is the migration root; the adapter still reads alembic.ini
75
+ // (or [tool.alembic]) before treating it as ownership evidence.
76
+ if (e.name === "versions" && path.basename(dirAbs) === "alembic") {
68
77
  out.push(path.relative(repo.root, abs).split(path.sep).join("/"));
69
78
  continue;
70
79
  }
@@ -77,7 +86,7 @@ export function findMigrationDirs(repo, root, maxDepth = 5) {
77
86
 
78
87
  export function firstExistingFile(repo, root, names) {
79
88
  for (const n of names) {
80
- if (repo.exists(root + "/" + n)) return root + "/" + n;
89
+ if (repo.ownsUnitPath(root, root + "/" + n) && repo.exists(root + "/" + n)) return root + "/" + n;
81
90
  }
82
91
  return null;
83
92
  }