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
@@ -0,0 +1,990 @@
1
+ // Rust adapter: Cargo.toml as the unit marker, workspace members as roots,
2
+ // and crate dependencies as build relations. Nothing here runs cargo, reads
3
+ // Cargo.lock, or talks to crates.io. A sibling edge comes from a path, a
4
+ // workspace = true entry, or a [patch]/[replace] path that stays inside the
5
+ // repository. A source import can only add provenance to an edge the manifest
6
+ // already resolved: Rust cannot name a crate the manifest does not declare.
7
+ import fs from "node:fs";
8
+ import { Adapter } from "./registry.mjs";
9
+ import { AgentDocError, CODES } from "../core/codes.mjs";
10
+ import { globToRegExp } from "../core/fsx.mjs";
11
+ import { parse } from "../core/toml.mjs";
12
+ import { DEFAULT_NOT_UNIT_ROOTS } from "../core/discover.mjs";
13
+
14
+ // How far below the repository root a workspace manifest may sit. Deep enough
15
+ // for a polyglot layout (`services/<name>/rust/Cargo.toml`) and bounded so
16
+ // discovery does not walk a repository without a limit.
17
+ const WORKSPACE_MANIFEST_DEPTH = 5;
18
+ // Directory names that are not searched for a workspace manifest. A member
19
+ // glob of a workspace that was found still expands into these names: the skip
20
+ // applies to the search, not to an explicit member list.
21
+ const WORKSPACE_SKIP_DIRS = new Set(["test", "tests", "examples", "example"]);
22
+
23
+ const SKIP_IDENTS = new Set(["self", "super", "crate"]);
24
+
25
+ export function manifestPath(root) {
26
+ if (!root || root === ".") return "Cargo.toml";
27
+ return root.replace(/\/$/, "") + "/Cargo.toml";
28
+ }
29
+
30
+ export function unitDir(root) {
31
+ if (!root || root === ".") return "";
32
+ return root.replace(/\/$/, "");
33
+ }
34
+
35
+ export function rustIdent(name) {
36
+ return String(name || "").replace(/-/g, "_");
37
+ }
38
+
39
+ // A Cargo.toml the TOML reader rejects must not throw out of the compiler.
40
+ // The caller turns `error` into an AGENTDOC_* diagnostic. `text` is kept so a
41
+ // `[package]` header can still mark a unit when the body does not parse.
42
+ export function readCargo(repo, rel) {
43
+ if (!repo.exists(rel)) return { missing: true, doc: null, error: null, text: null };
44
+ let text;
45
+ try {
46
+ text = repo.readText(rel);
47
+ } catch (e) {
48
+ if (e instanceof AgentDocError) return { missing: false, doc: null, error: e, text: null };
49
+ throw e;
50
+ }
51
+ try {
52
+ return { missing: false, doc: parse(text, rel), error: null, text };
53
+ } catch (e) {
54
+ // The TOML reader throws AgentDocError for a line it rejects, and a plain
55
+ // SyntaxError for a string it hands to JSON.parse. Either one is a
56
+ // diagnostic. Neither may escape the compiler.
57
+ if (e instanceof AgentDocError) return { missing: false, doc: null, error: e, text };
58
+ return {
59
+ missing: false,
60
+ doc: null,
61
+ error: new AgentDocError(CODES.YAML_PARSE, "Cargo.toml could not be parsed" + (e && e.message ? ": " + e.message : ""), { path: rel }),
62
+ text,
63
+ };
64
+ }
65
+ }
66
+
67
+ export function crateNameOf(doc) {
68
+ const n = doc && doc.package && doc.package.name;
69
+ return typeof n === "string" && n.trim() ? n.trim() : null;
70
+ }
71
+
72
+ export function hasPackageHeader(text) {
73
+ return /^\s*\[package\]\s*$/m.test(text || "");
74
+ }
75
+
76
+ export function hasWorkspaceHeader(text) {
77
+ return /^\s*\[workspace\]\s*$/m.test(text || "");
78
+ }
79
+
80
+ export function isCargoPackage(parsed) {
81
+ if (!parsed || parsed.missing) return false;
82
+ if (crateNameOf(parsed.doc)) return true;
83
+ return !parsed.doc && hasPackageHeader(parsed.text);
84
+ }
85
+
86
+ export function crateShape(repo, root, parsed) {
87
+ const doc = parsed && parsed.doc;
88
+ const text = (parsed && parsed.text) || "";
89
+ const prefix = unitDir(root);
90
+ const at = (rel) => (prefix ? prefix + "/" + rel : rel);
91
+ const binTable = Boolean(doc && Array.isArray(doc.bin) && doc.bin.length > 0);
92
+ const libTable = Boolean(doc && doc.lib && typeof doc.lib === "object" && !Array.isArray(doc.lib));
93
+ const binText = !doc && /^\s*\[\[bin\]\]\s*$/m.test(text);
94
+ const libText = !doc && /^\s*\[lib\]\s*$/m.test(text);
95
+ const deployable = binTable || binText || repo.exists(at("src/main.rs")) || repo.isDir(at("src/bin"));
96
+ const importable = libTable || libText || repo.exists(at("src/lib.rs"));
97
+ if (deployable && importable) return "both";
98
+ if (deployable) return "deployable";
99
+ if (importable) return "importable";
100
+ return "package";
101
+ }
102
+
103
+ // null when this directory is not a Cargo package (missing, virtual workspace,
104
+ // or a file that does not parse and has no [package] header). Scaffold uses
105
+ // this so a virtual manifest never becomes a component descriptor.
106
+ export function cargoUnitShape(repo, root) {
107
+ const parsed = readCargo(repo, manifestPath(root));
108
+ if (!isCargoPackage(parsed)) return null;
109
+ return crateShape(repo, root, parsed);
110
+ }
111
+
112
+ export function isVirtualWorkspaceManifest(repo, root) {
113
+ const parsed = readCargo(repo, manifestPath(root));
114
+ if (parsed.missing || isCargoPackage(parsed)) return false;
115
+ const ws = parsed.doc && parsed.doc.workspace;
116
+ if (ws && typeof ws === "object" && !Array.isArray(ws)) return true;
117
+ return hasWorkspaceHeader(parsed.text);
118
+ }
119
+
120
+ export function findWorkspace(repo, root) {
121
+ let dir = unitDir(root);
122
+ const seen = new Set();
123
+ while (!seen.has(dir)) {
124
+ seen.add(dir);
125
+ const rel = dir ? dir + "/Cargo.toml" : "Cargo.toml";
126
+ if (repo.exists(rel)) {
127
+ const parsed = readCargo(repo, rel);
128
+ const ws = parsed.doc && parsed.doc.workspace;
129
+ if (ws && typeof ws === "object" && !Array.isArray(ws)) return { root: dir, path: rel, doc: parsed.doc };
130
+ }
131
+ if (!dir) return null;
132
+ const i = dir.lastIndexOf("/");
133
+ dir = i < 0 ? "" : dir.slice(0, i);
134
+ }
135
+ return null;
136
+ }
137
+
138
+ export function loadCargoUnit(repo, unit) {
139
+ const rel = manifestPath(unit.root);
140
+ const parsed = readCargo(repo, rel);
141
+ unit.cargoManifest = rel;
142
+ unit.cargoDoc = parsed.doc;
143
+ unit.cargoError = parsed.error || null;
144
+ unit.cargoText = parsed.text;
145
+ unit.cargoPackage = isCargoPackage(parsed);
146
+ unit.crateName = crateNameOf(parsed.doc);
147
+ unit.cargoWorkspace = unit.cargoPackage ? findWorkspace(repo, unit.root) : null;
148
+ return unit;
149
+ }
150
+
151
+ function asStringList(v) {
152
+ if (typeof v === "string") return [v];
153
+ if (!Array.isArray(v)) return [];
154
+ return v.filter((x) => typeof x === "string");
155
+ }
156
+
157
+ // `fromDir` and `rel` stay inside the repository. `..` that would leave it
158
+ // resolves to null rather than to a path the compiler must not read.
159
+ export function resolveInside(fromDir, rel) {
160
+ if (typeof rel !== "string" || rel.startsWith("/") || rel.includes("\\") || rel.includes("\0")) return null;
161
+ const out = [];
162
+ const base = unitDir(fromDir);
163
+ if (base) {
164
+ for (const seg of base.split("/")) {
165
+ if (!seg || seg === ".") continue;
166
+ if (seg === "..") return null;
167
+ out.push(seg);
168
+ }
169
+ }
170
+ for (const seg of rel.split("/")) {
171
+ if (!seg || seg === ".") continue;
172
+ if (seg === "..") {
173
+ if (!out.length) return null;
174
+ out.pop();
175
+ continue;
176
+ }
177
+ out.push(seg);
178
+ }
179
+ return out.join("/");
180
+ }
181
+
182
+ function expandMember(repo, workspaceRoot, pattern) {
183
+ if (typeof pattern !== "string") return [];
184
+ const rel = pattern.replace(/^\.\//, "").replace(/\/$/, "");
185
+ if (!rel || rel === "." || rel.split("/").includes("..") || rel.startsWith("/") || rel.includes("\\")) return [];
186
+ const full = workspaceRoot ? workspaceRoot + "/" + rel : rel;
187
+ if (full.split("/").includes("..")) return [];
188
+ if (!/[*?]/.test(full)) return repo.isDir(full) ? [full] : [];
189
+ const re = globToRegExp(full);
190
+ const star = full.search(/[*?]/);
191
+ const prefix = full.slice(0, star);
192
+ const baseDir = prefix.includes("/") ? prefix.slice(0, prefix.lastIndexOf("/")) : "";
193
+ const dirs = new Set();
194
+ for (const f of repo.walk(baseDir)) {
195
+ const parts = f.split("/");
196
+ for (let i = 1; i < parts.length; i++) {
197
+ const d = parts.slice(0, i).join("/");
198
+ if (re.test(d)) dirs.add(d);
199
+ }
200
+ }
201
+ return [...dirs].sort();
202
+ }
203
+
204
+ export function isExcludedWorkspaceMember(repo, dir) {
205
+ if (!dir) return false;
206
+ const ws = findWorkspace(repo, dir);
207
+ if (!ws || !ws.doc || !ws.doc.workspace || ws.root === dir) return false;
208
+ const excluded = new Set();
209
+ for (const pat of asStringList(ws.doc.workspace.exclude)) {
210
+ for (const d of expandMember(repo, ws.root, pat)) excluded.add(d);
211
+ }
212
+ return excluded.has(dir);
213
+ }
214
+
215
+ function childDirectoryNames(repo, dir) {
216
+ let entries;
217
+ try {
218
+ entries = fs.readdirSync(repo.abs(dir || "."), { withFileTypes: true });
219
+ } catch {
220
+ return [];
221
+ }
222
+ const generated = repo.generatedDirs || new Set();
223
+ const names = [];
224
+ for (const ent of entries) {
225
+ if (ent.isSymbolicLink() || !ent.isDirectory()) continue;
226
+ if (!ent.name || ent.name.startsWith(".")) continue;
227
+ if (generated.has(ent.name) || WORKSPACE_SKIP_DIRS.has(ent.name)) continue;
228
+ // Each child is probed for Cargo.toml, and a probe is an input: a
229
+ // git-ignored directory must not even be visited (issue #45).
230
+ if (repo.isIgnored && repo.isIgnored(dir ? dir + "/" + ent.name : ent.name)) continue;
231
+ names.push(ent.name);
232
+ }
233
+ names.sort();
234
+ return names;
235
+ }
236
+
237
+ function notUnitRootsOf(cfg) {
238
+ const list = cfg && cfg.discovery && cfg.discovery.notUnitRoots;
239
+ return new Set(Array.isArray(list) ? list : DEFAULT_NOT_UNIT_ROOTS);
240
+ }
241
+
242
+ function coveredByNotUnit(rel, notUnit) {
243
+ if (!rel) return false;
244
+ if (notUnit.has(rel)) return true;
245
+ for (const n of notUnit) {
246
+ if (!n || n === ".") continue;
247
+ if (rel === n || rel.startsWith(n + "/")) return true;
248
+ }
249
+ return false;
250
+ }
251
+
252
+ // Workspace manifests at or below the repository root, shallowest paths first.
253
+ // A file that does not parse is included when it still claims [workspace], so
254
+ // the failure can be reported instead of looking like an empty repository.
255
+ export function workspaceManifests(repo, cfg) {
256
+ const notUnit = notUnitRootsOf(cfg);
257
+ const found = [];
258
+ const walk = (dir, depth) => {
259
+ if (coveredByNotUnit(dir, notUnit)) return;
260
+ const rel = dir ? dir + "/Cargo.toml" : "Cargo.toml";
261
+ if (repo.exists(rel)) {
262
+ const parsed = readCargo(repo, rel);
263
+ const ws = parsed.doc && parsed.doc.workspace;
264
+ const claims = (ws && typeof ws === "object" && !Array.isArray(ws)) || hasWorkspaceHeader(parsed.text);
265
+ if (claims) found.push({ root: dir, path: rel, doc: parsed.doc, error: parsed.error || null });
266
+ }
267
+ if (depth >= WORKSPACE_MANIFEST_DEPTH) return;
268
+ // Directory names are read here, not through repo.listDir. listDir records
269
+ // every name, including `.agentdoc`, and the graph file appearing between
270
+ // two compiles would change the input hash. Generated directories are not
271
+ // inputs. Each child is still probed for Cargo.toml, so a new manifest
272
+ // changes the hash.
273
+ for (const base of childDirectoryNames(repo, dir)) {
274
+ walk(dir ? dir + "/" + base : base, depth + 1);
275
+ }
276
+ };
277
+ walk("", 0);
278
+ found.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
279
+ return found;
280
+ }
281
+
282
+ export function memberDirs(repo, workspaceRoot, doc) {
283
+ const ws = doc && doc.workspace;
284
+ if (!ws || typeof ws !== "object" || Array.isArray(ws)) return [];
285
+ const excluded = new Set();
286
+ for (const pat of asStringList(ws.exclude)) {
287
+ for (const dir of expandMember(repo, workspaceRoot, pat)) excluded.add(dir);
288
+ }
289
+ const out = [];
290
+ const seen = new Set();
291
+ for (const pat of asStringList(ws.members)) {
292
+ for (const dir of expandMember(repo, workspaceRoot, pat)) {
293
+ if (!dir || excluded.has(dir) || seen.has(dir)) continue;
294
+ seen.add(dir);
295
+ const rel = dir + "/Cargo.toml";
296
+ if (!repo.exists(rel)) continue;
297
+ out.push(dir);
298
+ }
299
+ }
300
+ return out.sort();
301
+ }
302
+
303
+ function depsOf(doc, kind) {
304
+ const tables = [];
305
+ if (doc && doc[kind] && typeof doc[kind] === "object" && !Array.isArray(doc[kind])) tables.push(doc[kind]);
306
+ const target = doc && doc.target;
307
+ if (target && typeof target === "object" && !Array.isArray(target)) {
308
+ for (const key of Object.keys(target).sort()) {
309
+ const table = target[key];
310
+ if (table && typeof table === "object" && !Array.isArray(table) && table[kind] && typeof table[kind] === "object" && !Array.isArray(table[kind])) {
311
+ tables.push(table[kind]);
312
+ }
313
+ }
314
+ }
315
+ const out = [];
316
+ for (const table of tables) {
317
+ for (const key of Object.keys(table).sort()) {
318
+ const dep = normalizeDep(key, table[key]);
319
+ if (dep) out.push(dep);
320
+ }
321
+ }
322
+ return out;
323
+ }
324
+
325
+ function normalizeDep(key, value) {
326
+ if (typeof value === "string" || typeof value === "number") {
327
+ return { key, packageName: key, workspace: false, path: null };
328
+ }
329
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
330
+ const packageName = typeof value.package === "string" && value.package ? value.package : key;
331
+ return {
332
+ key,
333
+ packageName,
334
+ workspace: value.workspace === true,
335
+ path: typeof value.path === "string" ? value.path : null,
336
+ };
337
+ }
338
+
339
+ function lookupUnit(unitsByRoot, dir) {
340
+ if (dir == null) return null;
341
+ if (unitsByRoot.has(dir)) return unitsByRoot.get(dir);
342
+ if (dir === "" && unitsByRoot.has(".")) return unitsByRoot.get(".");
343
+ if (dir === "." && unitsByRoot.has("")) return unitsByRoot.get("");
344
+ return null;
345
+ }
346
+
347
+ function workspaceEntry(ws, dep) {
348
+ const table = ws && ws.doc && ws.doc.workspace && ws.doc.workspace.dependencies;
349
+ if (!table || typeof table !== "object" || Array.isArray(table)) return { entries: [] };
350
+ const entries = [];
351
+ if (Object.prototype.hasOwnProperty.call(table, dep.key)) entries.push(table[dep.key]);
352
+ if (dep.packageName !== dep.key && Object.prototype.hasOwnProperty.call(table, dep.packageName)) entries.push(table[dep.packageName]);
353
+ return { entries };
354
+ }
355
+
356
+ function entryPath(entry) {
357
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
358
+ return typeof entry.path === "string" ? entry.path : null;
359
+ }
360
+
361
+ function sameCrate(a, b) {
362
+ return a === b || rustIdent(a) === rustIdent(b);
363
+ }
364
+
365
+ // [patch.<registry>] and [replace] path redirects. Paths are relative to the
366
+ // manifest that declares them. A URL replace key names no package, so it is
367
+ // not a redirect this reader can apply.
368
+ function redirectEntries(doc) {
369
+ const out = [];
370
+ if (!doc) return out;
371
+ const patch = doc.patch;
372
+ if (patch && typeof patch === "object" && !Array.isArray(patch)) {
373
+ for (const registry of Object.keys(patch).sort()) {
374
+ const table = patch[registry];
375
+ if (!table || typeof table !== "object" || Array.isArray(table)) continue;
376
+ for (const name of Object.keys(table).sort()) {
377
+ const entry = table[name];
378
+ const path = entryPath(entry);
379
+ if (!path) continue;
380
+ const packageName = entry && typeof entry === "object" && !Array.isArray(entry) && typeof entry.package === "string" && entry.package
381
+ ? entry.package
382
+ : name;
383
+ out.push({ packageName, path });
384
+ }
385
+ }
386
+ }
387
+ const replace = doc.replace;
388
+ if (replace && typeof replace === "object" && !Array.isArray(replace)) {
389
+ // The TOML reader splits every dotted key, so "engine:0.1.0" arrives as
390
+ // nested tables. Join them back before taking the package name.
391
+ for (const entry of flattenPathEntries(replace)) {
392
+ const packageName = entry.packageName || replaceKeyPackage(entry.key);
393
+ if (!packageName || !entry.path) continue;
394
+ out.push({ packageName, path: entry.path });
395
+ }
396
+ }
397
+ return out;
398
+ }
399
+
400
+ function flattenPathEntries(table, prefix = "") {
401
+ const out = [];
402
+ if (!table || typeof table !== "object" || Array.isArray(table)) return out;
403
+ for (const key of Object.keys(table).sort()) {
404
+ const next = prefix ? prefix + "." + key : key;
405
+ const child = table[key];
406
+ if (!child || typeof child !== "object" || Array.isArray(child)) continue;
407
+ if (typeof child.path === "string") {
408
+ const packageName = typeof child.package === "string" && child.package ? child.package : null;
409
+ out.push({ key: next, path: child.path, packageName });
410
+ } else {
411
+ out.push(...flattenPathEntries(child, next));
412
+ }
413
+ }
414
+ return out;
415
+ }
416
+
417
+ function replaceKeyPackage(key) {
418
+ if (typeof key !== "string" || !key || key.includes("://")) return null;
419
+ const colon = key.indexOf(":");
420
+ return (colon < 0 ? key : key.slice(0, colon)).trim() || null;
421
+ }
422
+
423
+ function resolveCargoTarget(dep, fromDir, ws, unitsByRoot, ownDoc) {
424
+ const hits = [];
425
+ if (dep.path) {
426
+ const dir = resolveInside(fromDir, dep.path);
427
+ if (dir == null) return { target: null, ambiguous: false, escaped: true };
428
+ hits.push(lookupUnit(unitsByRoot, dir));
429
+ }
430
+ if (dep.workspace && ws) {
431
+ const { entries } = workspaceEntry(ws, dep);
432
+ const dirs = [];
433
+ for (const entry of entries) {
434
+ const path = entryPath(entry);
435
+ if (!path) continue;
436
+ const dir = resolveInside(ws.root, path);
437
+ if (dir == null) return { target: null, ambiguous: false, escaped: true };
438
+ dirs.push(dir);
439
+ }
440
+ const unique = [...new Set(dirs)];
441
+ if (unique.length > 1) return { target: null, ambiguous: true, escaped: false };
442
+ if (unique.length === 1) hits.push(lookupUnit(unitsByRoot, unique[0]));
443
+ }
444
+ const sources = [];
445
+ if (ownDoc) sources.push({ doc: ownDoc, base: fromDir });
446
+ if (ws && ws.doc && ws.doc !== ownDoc) sources.push({ doc: ws.doc, base: ws.root });
447
+ for (const src of sources) {
448
+ for (const redir of redirectEntries(src.doc)) {
449
+ if (!sameCrate(redir.packageName, dep.packageName)) continue;
450
+ const dir = resolveInside(src.base, redir.path);
451
+ // A redirect that leaves the repository is not a sibling. It does not
452
+ // erase a path that already resolved inside the repo.
453
+ if (dir == null) continue;
454
+ hits.push(lookupUnit(unitsByRoot, dir));
455
+ }
456
+ }
457
+ const units = [];
458
+ const seen = new Set();
459
+ for (const u of hits) {
460
+ if (!u || seen.has(u)) continue;
461
+ seen.add(u);
462
+ units.push(u);
463
+ }
464
+ if (units.length > 1) return { target: null, ambiguous: true, escaped: false };
465
+ return { target: units[0] || null, ambiguous: false, escaped: false };
466
+ }
467
+
468
+ export function analyzeCargoDeps(unit, unitsByRoot) {
469
+ const doc = unit.cargoDoc;
470
+ if (!doc) return { prodNames: [], resolved: [] };
471
+ const fromDir = unitDir(unit.root);
472
+ const resolved = [];
473
+ const prod = [];
474
+ const seen = new Set();
475
+ const consider = (dep, kind) => {
476
+ if (kind !== "test" && !seen.has(dep.packageName)) {
477
+ seen.add(dep.packageName);
478
+ prod.push(dep.packageName);
479
+ }
480
+ const hit = resolveCargoTarget(dep, fromDir, unit.cargoWorkspace, unitsByRoot, doc);
481
+ resolved.push({ dep, kind, ...hit });
482
+ };
483
+ for (const dep of depsOf(doc, "dependencies")) consider(dep, "build");
484
+ for (const dep of depsOf(doc, "build-dependencies")) consider(dep, "build");
485
+ for (const dep of depsOf(doc, "dev-dependencies")) consider(dep, "test");
486
+ prod.sort();
487
+ return { prodNames: prod, resolved };
488
+ }
489
+
490
+ function noteAmbiguity(ctx, key, message, paths, refs) {
491
+ if (!ctx.rustAmbiguity) ctx.rustAmbiguity = new Map();
492
+ let rec = ctx.rustAmbiguity.get(key);
493
+ if (!rec) {
494
+ rec = { message, paths: new Set(), refs: new Set() };
495
+ ctx.rustAmbiguity.set(key, rec);
496
+ }
497
+ for (const p of paths) if (p) rec.paths.add(p);
498
+ for (const r of refs) if (r) rec.refs.add(r);
499
+ }
500
+
501
+ function unitsMatchingIdent(units, ident) {
502
+ const out = [];
503
+ for (const u of units) {
504
+ if (!u.crateName || !u.component) continue;
505
+ if (rustIdent(u.crateName) === ident) out.push(u);
506
+ }
507
+ return out.sort((a, b) => (a.root < b.root ? -1 : a.root > b.root ? 1 : 0));
508
+ }
509
+
510
+ function shellCrate(name) {
511
+ return typeof name === "string" && /^[A-Za-z0-9_-]+$/.test(name) ? name : null;
512
+ }
513
+
514
+ export class RustAdapter extends Adapter {
515
+ static adapterName = "rust";
516
+ constructor() {
517
+ super({ name: "rust", version: "1.0.1", kind: "unit-marker" });
518
+ }
519
+
520
+ roots(ctx) {
521
+ const notUnit = notUnitRootsOf(ctx.cfg);
522
+ const out = [];
523
+ const seen = new Set();
524
+ const consider = (dir) => {
525
+ if (dir == null || seen.has(dir) || coveredByNotUnit(dir, notUnit)) return;
526
+ const manifest = readCargo(ctx.repo, manifestPath(dir));
527
+ if (!isCargoPackage(manifest)) return;
528
+ seen.add(dir);
529
+ const shape = crateShape(ctx.repo, dir, manifest);
530
+ out.push({
531
+ root: dir,
532
+ deployable: shape === "deployable" || shape === "both",
533
+ importable: shape === "importable" || shape === "both",
534
+ });
535
+ };
536
+ for (const ws of workspaceManifests(ctx.repo, ctx.cfg)) {
537
+ if (!ws.doc) continue;
538
+ // A workspace root that also has [package] is a member of itself.
539
+ // A virtual manifest has no package and is not a unit.
540
+ consider(ws.root);
541
+ for (const dir of memberDirs(ctx.repo, ws.root, ws.doc)) consider(dir);
542
+ }
543
+ return out.sort((a, b) => (a.root < b.root ? -1 : a.root > b.root ? 1 : 0));
544
+ }
545
+
546
+ detect(ctx, root) {
547
+ const rel = manifestPath(root);
548
+ const parsed = readCargo(ctx.repo, rel);
549
+ if (!isCargoPackage(parsed)) return null;
550
+ return { path: rel, shape: crateShape(ctx.repo, root, parsed) };
551
+ }
552
+
553
+ extract(ctx, unit) {
554
+ if (!unit.cargoPackage) return {};
555
+ const { repo, prov, compRef } = ctx;
556
+ const rel = unit.cargoManifest || manifestPath(unit.root);
557
+ const derived = { languages: new Set(["rust"]), runtimes: new Set(["rust"]) };
558
+ if (unit.crateName) derived.crateName = unit.crateName;
559
+ const pr = prov.add("observed", rel, "cargo-extractor", [
560
+ "der:" + compRef + ":/runtimes",
561
+ "der:" + compRef + ":/languages",
562
+ ]);
563
+
564
+ if (unit.cargoError) {
565
+ ctx.diagnostics.push({
566
+ severity: "warning",
567
+ code: unit.cargoError.code || "AGENTDOC_YAML_PARSE",
568
+ message: "Cargo.toml could not be parsed; crate dependencies were not derived: " + unit.cargoError.message,
569
+ paths: [rel],
570
+ refs: [compRef],
571
+ subject: compRef,
572
+ });
573
+ }
574
+
575
+ const unitsByRoot = new Map((ctx.discovery.eligible || []).map((u) => [u.root, u]));
576
+ const { prodNames, resolved } = analyzeCargoDeps(unit, unitsByRoot);
577
+ const byTarget = new Map();
578
+ for (const hit of resolved) {
579
+ if (hit.ambiguous) {
580
+ noteAmbiguity(
581
+ ctx,
582
+ "dep:" + unit.root + ":" + hit.dep.key,
583
+ "dependency '" + hit.dep.key + "' in " + rel + " resolves to more than one crate; no edge was created",
584
+ [rel],
585
+ [compRef]
586
+ );
587
+ continue;
588
+ }
589
+ const target = hit.target;
590
+ if (!target || !target.component || target.component.ref === compRef) continue;
591
+ const prev = byTarget.get(target.component.ref);
592
+ if (!prev) byTarget.set(target.component.ref, { target, kind: hit.kind, deps: [hit.dep] });
593
+ else {
594
+ prev.deps.push(hit.dep);
595
+ if (hit.kind === "build") prev.kind = "build";
596
+ }
597
+ }
598
+ for (const rec of [...byTarget.values()].sort((a, b) => (a.target.root < b.target.root ? -1 : 1))) {
599
+ const pkg = rec.target.crateName || rec.deps[0].packageName;
600
+ ctx.addEdge(
601
+ rec.kind === "test" ? "testDependsOn" : "buildDependsOn",
602
+ compRef,
603
+ rec.target.component.ref,
604
+ { via: "cargo-manifest", package: pkg },
605
+ [pr],
606
+ "DERIVED"
607
+ );
608
+ }
609
+
610
+ const siblingNames = new Set();
611
+ for (const rec of byTarget.values()) for (const d of rec.deps) siblingNames.add(d.packageName);
612
+ const ambiguousNames = new Set(resolved.filter((h) => h.ambiguous).map((h) => h.dep.packageName));
613
+ for (const name of prodNames) {
614
+ if (siblingNames.has(name) || ambiguousNames.has(name)) continue;
615
+ for (const x of ctx.externalMatchers()) {
616
+ if (!x.re.test(name)) continue;
617
+ ctx.addExternal(compRef, x.name, x.mechanism, x.role, pr, "DERIVED");
618
+ }
619
+ }
620
+
621
+ const identFiles = (ctx.importIndex && ctx.importIndex.rustIdentsByUnit && ctx.importIndex.rustIdentsByUnit.get(unit)) || new Map();
622
+ const byIdent = new Map();
623
+ for (const hit of resolved) {
624
+ const ident = rustIdent(hit.dep.key);
625
+ if (!byIdent.has(ident)) byIdent.set(ident, []);
626
+ byIdent.get(ident).push(hit);
627
+ }
628
+ for (const ident of [...identFiles.keys()].sort()) {
629
+ if (SKIP_IDENTS.has(ident)) continue;
630
+ const files = [...(identFiles.get(ident) || [])].sort();
631
+ const hits = byIdent.get(ident) || [];
632
+ const targets = [];
633
+ const seen = new Set();
634
+ for (const hit of hits) {
635
+ if (hit.ambiguous || !hit.target || !hit.target.component) continue;
636
+ if (hit.target.component.ref === compRef || seen.has(hit.target.component.ref)) continue;
637
+ seen.add(hit.target.component.ref);
638
+ targets.push(hit);
639
+ }
640
+ if (hits.some((h) => h.ambiguous) || targets.length > 1) {
641
+ noteAmbiguity(
642
+ ctx,
643
+ "ident:" + ident,
644
+ "crate import '" + ident + "' matches more than one dependency; no edge was created",
645
+ [rel, ...files],
646
+ [compRef]
647
+ );
648
+ continue;
649
+ }
650
+ if (targets.length === 1) {
651
+ const hit = targets[0];
652
+ const kind = hits.some((h) => h.target === hit.target && h.kind === "build") ? "build" : hit.kind;
653
+ const provs = files.map((f) => prov.add("observed", f, "cargo-import-extractor", ["rel:pending"]));
654
+ ctx.addEdge(
655
+ kind === "test" ? "testDependsOn" : "buildDependsOn",
656
+ compRef,
657
+ hit.target.component.ref,
658
+ { via: "source-import", package: hit.target.crateName || hit.dep.packageName },
659
+ provs.length ? provs : [pr],
660
+ "DERIVED"
661
+ );
662
+ continue;
663
+ }
664
+ // No manifest edge. An import of one sibling is not a dependency: a
665
+ // local module (`mod engine; engine::run()`) and an undeclared crate
666
+ // look the same, and Rust will not link a crate the manifest omitted.
667
+ // Two sibling crates sharing the ident is still a refusal — electing
668
+ // either one would be an assertion — and it creates no edge.
669
+ if (hits.some((h) => h.ambiguous)) continue;
670
+ const matches = unitsMatchingIdent(ctx.discovery.eligible || [], ident).filter((u) => u.component && u.component.ref !== compRef);
671
+ if (matches.length > 1) {
672
+ noteAmbiguity(
673
+ ctx,
674
+ "ident:" + ident,
675
+ "crate import '" + ident + "' matches " + matches.length + " crates (" + matches.map((u) => u.crateName).join(", ") + "); no dependency edge was created",
676
+ [...files, ...matches.map((u) => manifestPath(u.root))],
677
+ [compRef, ...matches.map((u) => u.component.ref)]
678
+ );
679
+ }
680
+ }
681
+
682
+ const name = shellCrate(unit.crateName);
683
+ if (name) {
684
+ ctx.addVerification({
685
+ id: compRef + ":cargo-test",
686
+ componentRefs: [compRef],
687
+ tier: "unit",
688
+ command: "cargo test -p " + name,
689
+ configPaths: [rel],
690
+ provRecs: [pr],
691
+ });
692
+ ctx.addVerification({
693
+ id: compRef + ":cargo-clippy",
694
+ componentRefs: [compRef],
695
+ tier: "static",
696
+ command: "cargo clippy -p " + name,
697
+ configPaths: [rel],
698
+ provRecs: [pr],
699
+ });
700
+ }
701
+
702
+ if (hasRustTests(repo, unit.root)) derived.hasTests = true;
703
+ return derived;
704
+ }
705
+
706
+ contracts(ctx) {
707
+ const names = ctx.ambiguousCrateNames || new Map();
708
+ for (const [name, units] of [...names.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
709
+ const list = units.slice().sort((a, b) => (a.root < b.root ? -1 : 1));
710
+ ctx.diagnostics.push({
711
+ severity: "warning",
712
+ code: "AGENTDOC_BINDING_AMBIGUOUS",
713
+ message: "crate name '" + name + "' is declared by " + list.map((u) => u.root).join(" and ") + "; neither unit was elected as that name",
714
+ paths: list.map((u) => manifestPath(u.root)).sort(),
715
+ refs: list.map((u) => u.component && u.component.ref).filter(Boolean).sort(),
716
+ });
717
+ }
718
+ const amb = ctx.rustAmbiguity || new Map();
719
+ for (const [, rec] of [...amb.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
720
+ ctx.diagnostics.push({
721
+ severity: "warning",
722
+ code: "AGENTDOC_BINDING_AMBIGUOUS",
723
+ message: rec.message,
724
+ paths: [...rec.paths].sort(),
725
+ refs: [...rec.refs].sort(),
726
+ });
727
+ }
728
+ const reported = new Set();
729
+ for (const ws of workspaceManifests(ctx.repo, ctx.cfg)) {
730
+ if (!ws.error || reported.has(ws.path)) continue;
731
+ const unit = (ctx.discovery.eligible || []).find((u) => manifestPath(u.root) === ws.path && u.cargoPackage);
732
+ if (unit) continue;
733
+ reported.add(ws.path);
734
+ ctx.diagnostics.push({
735
+ severity: "warning",
736
+ code: ws.error.code || "AGENTDOC_YAML_PARSE",
737
+ message: "workspace Cargo.toml could not be parsed; workspace members were not derived from it: " + ws.error.message,
738
+ paths: [ws.path],
739
+ refs: [],
740
+ });
741
+ }
742
+ }
743
+ }
744
+
745
+ function hasRustTests(repo, root) {
746
+ const prefix = unitDir(root);
747
+ const tests = prefix ? prefix + "/tests" : "tests";
748
+ if (repo.ownsUnitPath(root, tests) && repo.isDir(tests)) return true;
749
+ const attr = /#\s*\[\s*(?:test|cfg\s*\(\s*test\s*\))\s*\]/;
750
+ for (const f of repo.walkUnit(root, prefix)) {
751
+ if (!f.endsWith(".rs")) continue;
752
+ if (attr.test(repo.readText(f))) return true;
753
+ }
754
+ return false;
755
+ }
756
+
757
+ export function rustIdentsInSource(src) {
758
+ return extractRustIdents(maskRustNonCode(stripRustTestItems(String(src || ""))));
759
+ }
760
+
761
+ function stripRustTestItems(src) {
762
+ let out = "";
763
+ let i = 0;
764
+ while (i < src.length) {
765
+ const skipped = skipNoise(src, i);
766
+ if (skipped > i) {
767
+ out += src.slice(i, skipped);
768
+ i = skipped;
769
+ continue;
770
+ }
771
+ const attr = matchTestAttr(src, i);
772
+ if (attr > i) {
773
+ i = skipRustItem(src, attr);
774
+ continue;
775
+ }
776
+ out += src[i];
777
+ i++;
778
+ }
779
+ return out;
780
+ }
781
+
782
+ function matchTestAttr(src, i) {
783
+ if (src[i] !== "#") return i;
784
+ const m = /^#\s*\[\s*(?:test|cfg\s*\(\s*test\s*\))\s*\]/.exec(src.slice(i));
785
+ return m ? i + m[0].length : i;
786
+ }
787
+
788
+ function skipRustItem(src, i) {
789
+ while (i < src.length && /\s/.test(src[i])) i++;
790
+ let depth = 0;
791
+ let opened = false;
792
+ while (i < src.length) {
793
+ const skipped = skipNoise(src, i);
794
+ if (skipped > i) {
795
+ i = skipped;
796
+ continue;
797
+ }
798
+ const c = src[i];
799
+ if (c === "{") {
800
+ depth++;
801
+ opened = true;
802
+ i++;
803
+ continue;
804
+ }
805
+ if (c === "}") {
806
+ depth--;
807
+ i++;
808
+ if (opened && depth <= 0) return i;
809
+ continue;
810
+ }
811
+ if (c === ";" && depth === 0) return i + 1;
812
+ i++;
813
+ }
814
+ return i;
815
+ }
816
+
817
+ function maskRustNonCode(src) {
818
+ let out = "";
819
+ let i = 0;
820
+ while (i < src.length) {
821
+ const skipped = skipNoise(src, i);
822
+ if (skipped > i) {
823
+ out += " ".repeat(skipped - i);
824
+ i = skipped;
825
+ continue;
826
+ }
827
+ out += src[i];
828
+ i++;
829
+ }
830
+ return out;
831
+ }
832
+
833
+ function extractRustIdents(src) {
834
+ const out = new Set();
835
+ const add = (ident) => {
836
+ if (ident && !SKIP_IDENTS.has(ident)) out.add(ident);
837
+ };
838
+ for (const m of src.matchAll(/\buse\s+([A-Za-z_][A-Za-z0-9_]*)\b/g)) add(m[1]);
839
+ for (const m of src.matchAll(/\bextern\s+crate\s+([A-Za-z_][A-Za-z0-9_]*)/g)) add(m[1]);
840
+ for (const m of src.matchAll(/(^|[^A-Za-z0-9_:])([A-Za-z_][A-Za-z0-9_]*)::/g)) add(m[2]);
841
+ return out;
842
+ }
843
+
844
+ function isIdentChar(c) {
845
+ return typeof c === "string" && /[A-Za-z0-9_]/.test(c);
846
+ }
847
+
848
+ function skipNoise(src, i) {
849
+ if (src[i] === "/" && src[i + 1] === "/") {
850
+ const j = src.indexOf("\n", i);
851
+ return j < 0 ? src.length : j;
852
+ }
853
+ if (src[i] === "/" && src[i + 1] === "*") return skipBlockComment(src, i);
854
+ // A string prefix is a token of its own. `crab"…"` is an ident plus a
855
+ // string, not a byte string.
856
+ if (!isIdentChar(src[i - 1])) {
857
+ const prefixed = prefixedLiteralEnd(src, i);
858
+ if (prefixed > i) return prefixed;
859
+ }
860
+ if (src[i] === '"') return skipQuoted(src, i);
861
+ if (src[i] === "'") return skipCharOrLifetime(src, i);
862
+ return i;
863
+ }
864
+
865
+ function prefixedLiteralEnd(src, i) {
866
+ let j = i;
867
+ if (src[j] === "b" || src[j] === "c") j++;
868
+ if (src[j] === "r") {
869
+ const end = rawStringEnd(src, j);
870
+ return end > j ? end : i;
871
+ }
872
+ if (j !== i && src[j] === '"') return skipQuoted(src, j);
873
+ if (j !== i && src[i] === "b" && src[j] === "'") {
874
+ const end = charLiteralEnd(src, j);
875
+ return end > j ? end : i;
876
+ }
877
+ return i;
878
+ }
879
+
880
+ function skipBlockComment(src, i) {
881
+ let depth = 0;
882
+ let j = i;
883
+ while (j < src.length) {
884
+ if (src[j] === "/" && src[j + 1] === "*") {
885
+ depth++;
886
+ j += 2;
887
+ continue;
888
+ }
889
+ if (src[j] === "*" && src[j + 1] === "/") {
890
+ depth--;
891
+ j += 2;
892
+ if (depth === 0) return j;
893
+ continue;
894
+ }
895
+ j++;
896
+ }
897
+ return src.length;
898
+ }
899
+
900
+ function rawStringEnd(src, i) {
901
+ if (src[i] !== "r") return i;
902
+ let hashes = 0;
903
+ let j = i + 1;
904
+ while (src[j] === "#") {
905
+ hashes++;
906
+ j++;
907
+ }
908
+ if (src[j] !== '"') return i;
909
+ j++;
910
+ const close = '"' + "#".repeat(hashes);
911
+ const at = src.indexOf(close, j);
912
+ return at < 0 ? src.length : at + close.length;
913
+ }
914
+
915
+ // A `"` string may contain newlines. Stopping at the newline would treat the
916
+ // rest of the line as code, so `fake_crate::thing` inside the string became
917
+ // an import. A `'` is not this kind of string: it is a char literal or a
918
+ // lifetime, and a lifetime must not mask the rest of the file.
919
+ function skipQuoted(src, i) {
920
+ const q = src[i];
921
+ if (q !== '"') return i;
922
+ let j = i + 1;
923
+ while (j < src.length) {
924
+ if (src[j] === "\\") {
925
+ j += j + 1 < src.length ? 2 : 1;
926
+ continue;
927
+ }
928
+ if (src[j] === q) return j + 1;
929
+ j++;
930
+ }
931
+ return src.length;
932
+ }
933
+
934
+ function skipCharOrLifetime(src, i) {
935
+ const end = charLiteralEnd(src, i);
936
+ if (end > i) return end;
937
+ return skipLifetime(src, i);
938
+ }
939
+
940
+ // 'x' / '\n' / '\u{1F600}' / '\''. Anything else that starts with ' is not a
941
+ // char, and must not be scanned for a closing quote.
942
+ function charLiteralEnd(src, i) {
943
+ if (src[i] !== "'") return i;
944
+ const n = src[i + 1];
945
+ if (n === undefined || n === "'" || n === "\n" || n === "\r") return i;
946
+ let j;
947
+ if (n === "\\") {
948
+ j = escapeEnd(src, i + 1);
949
+ if (j <= i + 1) return i;
950
+ } else {
951
+ const cp = src.codePointAt(i + 1);
952
+ j = i + 1 + (cp > 0xffff ? 2 : 1);
953
+ }
954
+ return src[j] === "'" ? j + 1 : i;
955
+ }
956
+
957
+ function escapeEnd(src, i) {
958
+ if (src[i] !== "\\") return i;
959
+ const n = src[i + 1];
960
+ if (n === undefined) return i;
961
+ if (n === "u") {
962
+ if (src[i + 2] !== "{") return i;
963
+ let j = i + 3;
964
+ let hex = 0;
965
+ while (j < src.length && hex < 6 && /[0-9a-fA-F]/.test(src[j])) {
966
+ j++;
967
+ hex++;
968
+ }
969
+ if (hex === 0 || src[j] !== "}") return i;
970
+ return j + 1;
971
+ }
972
+ if (n === "x") {
973
+ if (!/[0-9a-fA-F]/.test(src[i + 2] || "") || !/[0-9a-fA-F]/.test(src[i + 3] || "")) return i;
974
+ return i + 4;
975
+ }
976
+ if ("nrt\\0'\"".includes(n) || n === "\n") return i + 2;
977
+ return i;
978
+ }
979
+
980
+ // 'a, 'static, '_. The quote is consumed so it cannot open a false string.
981
+ function skipLifetime(src, i) {
982
+ if (src[i] !== "'") return i;
983
+ const n = src[i + 1];
984
+ if (n === "_" || (typeof n === "string" && /[A-Za-z]/.test(n))) {
985
+ let j = i + 2;
986
+ while (j < src.length && isIdentChar(src[j])) j++;
987
+ return j;
988
+ }
989
+ return i + 1;
990
+ }