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
@@ -0,0 +1,1142 @@
1
+ // Python adapter: pyproject.toml / setup.cfg / setup.py / requirements*.txt
2
+ // as unit markers, the distribution graph as build relations, and the
3
+ // verification commands the repository's own config actually proves.
4
+ //
5
+ // Shape of a marker (documented so a later change does not invent a second rule):
6
+ // deployable — an entrypoint the file states: [project.scripts] or
7
+ // [project.gui-scripts] or [tool.poetry.scripts], a console_scripts entry
8
+ // point, __main__.py, manage.py, or an ASGI/WSGI app object
9
+ // (`app`/`application` = FastAPI|Flask|Starlette|Litestar|Sanic|Quart(...),
10
+ // or get_asgi_application / get_wsgi_application).
11
+ // importable — a build backend ( [build-system] build-backend, or setup.py /
12
+ // setup.cfg) together with a package. The import name is the explicit
13
+ // package list when one is written; otherwise the distribution name with
14
+ // hyphens turned into underscores, when that directory exists; otherwise
15
+ // the single package directory present. Two or more, and no list, is
16
+ // ambiguous: the unit is still importable, and the import name is not
17
+ // recorded.
18
+ // present — a Python manifest with neither of the above. A declared
19
+ // workspace member in this shape is still a unit. It does not invent a
20
+ // service or a library.
21
+ //
22
+ // Nothing here executes Python, resolves a lockfile, or reads the network.
23
+ // A PEP 508 marker is recognised and then ignored: evaluating it would be a
24
+ // guess about the interpreter. A dynamic setup.py dependency is not derived.
25
+ import { Adapter } from "./registry.mjs";
26
+ import { parse as parseToml } from "../core/toml.mjs";
27
+ import { AgentDocError, CODES } from "../core/codes.mjs";
28
+ import { globToRegExp } from "../core/fsx.mjs";
29
+ import { sourceFiles } from "../core/sourcescan.mjs";
30
+ import { DEFAULT_NOT_UNIT_ROOTS } from "../core/discover.mjs";
31
+
32
+ // Nested workspace manifests are discovered only this deep. The same bound
33
+ // is what a Cargo workspace walk should use: deterministic, inside the repo,
34
+ // and short enough that a generated tree cannot become the search.
35
+ const MAX_WORKSPACE_DEPTH = 6;
36
+
37
+ const DEV_EXTRA = /^(dev|devel|development|test|tests|testing|lint|type|types|typecheck)$/i;
38
+ const APP_ASSIGN = /(?:^|\n)\s*(?:app|application)\s*(?::[^\n=]+)?=\s*(?:FastAPI|Flask|Starlette|Litestar|Sanic|Quart)\s*\(/;
39
+ const APP_GETTER = /\b(?:get_asgi_application|get_wsgi_application)\s*\(/;
40
+ const SKIP_PKG_DIR = new Set(["src", "tests", "test", "docs", "doc", "scripts", "script", "alembic", "migrations", "migration", ".github", "examples", "example"]);
41
+
42
+ export function pep503(name) {
43
+ return String(name).trim().toLowerCase().replace(/[-_.]+/g, "-");
44
+ }
45
+
46
+ // The distribution name, extras, a version specifier and a marker. Returns
47
+ // null when the tail is not a specifier — the caller must not keep the name.
48
+ export function parsePep508(spec) {
49
+ const s = String(spec).trim();
50
+ // The longer form has to come first: a one-character alternative would
51
+ // succeed on the first letter and leave the rest of the name unparsed.
52
+ const m = /^([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)/.exec(s);
53
+ if (!m) return null;
54
+ let rest = s.slice(m[1].length).trim();
55
+ if (rest.startsWith("[")) {
56
+ const end = rest.indexOf("]");
57
+ if (end < 0) return null;
58
+ const extras = rest.slice(1, end).trim();
59
+ if (extras && !/^[A-Za-z0-9._-]+(\s*,\s*[A-Za-z0-9._-]+)*$/.test(extras)) return null;
60
+ rest = rest.slice(end + 1).trim();
61
+ }
62
+ if (rest.startsWith("@")) {
63
+ const semi = indexOfMarker(rest);
64
+ const url = (semi < 0 ? rest.slice(1) : rest.slice(1, semi)).trim();
65
+ if (!url || /\s/.test(url)) return null;
66
+ if (semi >= 0 && !markerOk(rest.slice(semi + 1))) return null;
67
+ return { name: pep503(m[1]) };
68
+ }
69
+ const semi = indexOfMarker(rest);
70
+ const ver = (semi < 0 ? rest : rest.slice(0, semi)).trim();
71
+ const marker = semi < 0 ? "" : rest.slice(semi + 1);
72
+ if (ver && !versionOk(ver)) return null;
73
+ if (marker && !markerOk(marker)) return null;
74
+ return { name: pep503(m[1]) };
75
+ }
76
+
77
+ function indexOfMarker(s) {
78
+ let q = null;
79
+ for (let i = 0; i < s.length; i++) {
80
+ const c = s[i];
81
+ if (q) { if (c === "\\") { i++; continue; } if (c === q) q = null; continue; }
82
+ if (c === '"' || c === "'") { q = c; continue; }
83
+ if (c === ";") return i;
84
+ }
85
+ return -1;
86
+ }
87
+
88
+ function versionOk(ver) {
89
+ return ver.split(",").every((c) => /^\s*(?:~=|===|==|!=|<=|>=|<|>)\s*[A-Za-z0-9.!+*_-]+\s*$/.test(c));
90
+ }
91
+
92
+ function markerOk(marker) {
93
+ const t = marker.trim();
94
+ if (!t) return false;
95
+ // Shape only. The marker is not evaluated.
96
+ return /^[A-Za-z0-9_."'<>!=~\s()]+$/.test(t);
97
+ }
98
+
99
+ export function pythonImportNames(text) {
100
+ const out = new Set();
101
+ let triple = null;
102
+ let logical = "";
103
+ for (const raw of String(text).split("\n")) {
104
+ const masked = maskPythonLine(raw, triple);
105
+ triple = masked.triple;
106
+ if (triple && !masked.code.trim()) { logical = ""; continue; }
107
+ let code = masked.code;
108
+ if (logical) { code = logical + " " + code.trim(); logical = ""; }
109
+ if (code.trimEnd().endsWith("\\")) { logical = code.trimEnd().slice(0, -1); continue; }
110
+ for (const part of code.split(";")) collectImport(part, out);
111
+ }
112
+ return [...out].sort();
113
+ }
114
+
115
+ function maskPythonLine(line, triple) {
116
+ if (triple) {
117
+ const at = line.indexOf(triple);
118
+ if (at < 0) return { code: "", triple };
119
+ return maskPythonLine(line.slice(at + triple.length), null);
120
+ }
121
+ let code = "";
122
+ for (let i = 0; i < line.length; i++) {
123
+ const c = line[i];
124
+ if (line.startsWith('"""', i) || line.startsWith("'''", i)) {
125
+ const q = line.slice(i, i + 3);
126
+ const end = line.indexOf(q, i + 3);
127
+ if (end < 0) return { code, triple: q };
128
+ i = end + 2;
129
+ code += " ";
130
+ continue;
131
+ }
132
+ if (c === '"' || c === "'") {
133
+ const q = c;
134
+ i++;
135
+ while (i < line.length && line[i] !== q) { if (line[i] === "\\") i++; i++; }
136
+ code += " ";
137
+ continue;
138
+ }
139
+ if (c === "#") break;
140
+ code += c;
141
+ }
142
+ return { code, triple: null };
143
+ }
144
+
145
+ function collectImport(stmt, out) {
146
+ const t = stmt.trim();
147
+ let m;
148
+ if ((m = /^import\s+(.+)$/.exec(t))) {
149
+ for (const part of m[1].split(",")) addTop(out, part.trim().split(/\s+/)[0]);
150
+ return;
151
+ }
152
+ if ((m = /^from\s+(\.+)([A-Za-z_][\w.]*)?\s+import\b/.exec(t))) return;
153
+ if ((m = /^from\s+([A-Za-z_][\w.]*)\s+import\b/.exec(t))) addTop(out, m[1]);
154
+ }
155
+
156
+ function addTop(out, name) {
157
+ if (!name || name.startsWith(".")) return;
158
+ const top = name.split(".")[0];
159
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(top)) out.add(top);
160
+ }
161
+
162
+ function join(root, name) {
163
+ return !root || root === "." ? name : root + "/" + name;
164
+ }
165
+
166
+ // Resolve `rel` against a unit directory. Null when the result would leave
167
+ // the repository — an escaped path is not a sibling unit.
168
+ export function resolveInside(fromDir, rel) {
169
+ if (typeof rel !== "string" || !rel.trim()) return null;
170
+ let p = rel.trim().replace(/\\/g, "/");
171
+ p = p.replace(/^file:(?=\.{0,2}\/)/, "");
172
+ if (p.startsWith("/") || p.includes("://") || p.startsWith("git+") || p.startsWith("file:")) return null;
173
+ p = p.split(";")[0].trim().replace(/\[.*\]$/, "");
174
+ const base = !fromDir || fromDir === "." ? "" : fromDir;
175
+ const parts = (base ? base + "/" + p : p).split("/");
176
+ const out = [];
177
+ for (const seg of parts) {
178
+ if (!seg || seg === ".") continue;
179
+ if (seg === "..") {
180
+ if (!out.length) return null;
181
+ out.pop();
182
+ continue;
183
+ }
184
+ out.push(seg);
185
+ }
186
+ return out.join("/") || null;
187
+ }
188
+
189
+ function locate(repo, root) {
190
+ const py = join(root, "pyproject.toml");
191
+ const cfg = join(root, "setup.cfg");
192
+ const setup = join(root, "setup.py");
193
+ const pyExists = repo.exists(py);
194
+ const cfgExists = repo.exists(cfg);
195
+ const setupExists = repo.exists(setup);
196
+ const reqs = repo.listDir(root).filter((n) => /^requirements.*\.txt$/.test(n)).sort();
197
+ let manifest = null;
198
+ let kind = null;
199
+ if (pyExists) { manifest = py; kind = "pyproject"; }
200
+ else if (cfgExists) { manifest = cfg; kind = "setup.cfg"; }
201
+ else if (setupExists) { manifest = setup; kind = "setup.py"; }
202
+ else if (reqs.length) { manifest = join(root, reqs[0]); kind = "requirements"; }
203
+ return { py, cfg, setup, pyExists, cfgExists, setupExists, reqs, manifest, kind };
204
+ }
205
+
206
+ function emptyUnit(loc) {
207
+ return {
208
+ marker: loc.kind,
209
+ manifest: loc.manifest,
210
+ dist: null,
211
+ importNames: [],
212
+ ambiguousImports: false,
213
+ importable: false,
214
+ deployable: false,
215
+ requiresPython: null,
216
+ prodDeps: [],
217
+ devDeps: [],
218
+ sourceDeps: [],
219
+ pathDeps: [],
220
+ workspaceNames: [],
221
+ pytestInManifest: false,
222
+ ruffInManifest: false,
223
+ mypyInManifest: false,
224
+ warnings: [],
225
+ error: null,
226
+ };
227
+ }
228
+
229
+ export function readPythonUnit(repo, root) {
230
+ const loc = locate(repo, root);
231
+ if (!loc.manifest) return emptyUnit(loc);
232
+ try {
233
+ if (loc.kind === "pyproject") return readPyproject(repo, root, loc);
234
+ if (loc.kind === "setup.cfg") return readSetupCfg(repo, root, loc);
235
+ if (loc.kind === "setup.py") return readSetupPy(repo, root, loc);
236
+ return readRequirementsUnit(repo, root, loc);
237
+ } catch (e) {
238
+ if (!(e instanceof AgentDocError)) throw e;
239
+ const info = emptyUnit(loc);
240
+ info.error = e;
241
+ return info;
242
+ }
243
+ }
244
+
245
+ function finish(repo, root, info, explicitNames) {
246
+ if (info._prod.has("pytest") || info._dev.has("pytest")) info.pytestInManifest = true;
247
+ if (info._prod.has("ruff") || info._dev.has("ruff")) info.ruffInManifest = true;
248
+ if (info._prod.has("mypy") || info._dev.has("mypy")) info.mypyInManifest = true;
249
+ const dirs = packageDirs(repo, root);
250
+ let names = explicitNames;
251
+ if (!names) {
252
+ const conventional = info.dist ? info.dist.replace(/-/g, "_") : null;
253
+ if (conventional && dirs.includes(conventional)) names = [conventional];
254
+ else if (dirs.length === 1) names = dirs;
255
+ else if (dirs.length > 1) { names = []; info.ambiguousImports = true; }
256
+ else names = [];
257
+ }
258
+ info.importNames = [...new Set(names)].filter((n) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(n)).sort();
259
+ const backend = info.backend;
260
+ info.importable = Boolean(backend && (info.ambiguousImports || info.importNames.length > 0));
261
+ info.prodDeps = [...info._prod].sort();
262
+ info.devDeps = [...info._dev].filter((n) => !info._prod.has(n)).sort();
263
+ info.sourceDeps = [...info._source].sort();
264
+ info.workspaceNames = [...info._workspace].sort();
265
+ delete info._prod;
266
+ delete info._dev;
267
+ delete info._source;
268
+ delete info._workspace;
269
+ delete info.backend;
270
+ return info;
271
+ }
272
+
273
+ function blank(loc) {
274
+ const info = emptyUnit(loc);
275
+ info._prod = new Set();
276
+ info._dev = new Set();
277
+ info._source = new Set();
278
+ info._workspace = new Set();
279
+ info.backend = false;
280
+ return info;
281
+ }
282
+
283
+ function warn(info, message) {
284
+ info.warnings.push({ code: CODES.PYTHON_SPECIFIER, message });
285
+ }
286
+
287
+ function addDep(info, spec, bucket) {
288
+ const parsed = parsePep508(spec);
289
+ if (!parsed) { warn(info, "dependency specifier is not PEP 508 and was not recorded: " + spec); return null; }
290
+ if (parsed.name === "python") return null;
291
+ info[bucket].add(parsed.name);
292
+ return parsed.name;
293
+ }
294
+
295
+ function readPyproject(repo, root, loc) {
296
+ const doc = parseToml(repo.readText(loc.manifest), loc.manifest);
297
+ const info = blank(loc);
298
+ const project = doc.project && typeof doc.project === "object" ? doc.project : null;
299
+ const poetry = doc.tool && doc.tool.poetry && typeof doc.tool.poetry === "object" ? doc.tool.poetry : null;
300
+ if (project && typeof project.name === "string") info.dist = pep503(project.name);
301
+ else if (poetry && typeof poetry.name === "string") info.dist = pep503(poetry.name);
302
+ if (project && typeof project["requires-python"] === "string") info.requiresPython = project["requires-python"].trim();
303
+ const backend = doc["build-system"] && doc["build-system"]["build-backend"];
304
+ info.backend = typeof backend === "string" && backend.trim().length > 0;
305
+
306
+ for (const spec of stringList(project && project.dependencies)) {
307
+ const n = addDep(info, spec, "_prod");
308
+ if (n) info._source.add(n);
309
+ }
310
+ const groups = doc["dependency-groups"];
311
+ if (groups && typeof groups === "object" && !Array.isArray(groups)) {
312
+ for (const [gname, specs] of Object.entries(groups)) {
313
+ for (const spec of expandGroup(groups, gname, specs, new Set(), info)) {
314
+ addDep(info, spec, "_dev");
315
+ }
316
+ }
317
+ }
318
+ const extras = project && project["optional-dependencies"];
319
+ if (extras && typeof extras === "object") {
320
+ for (const [gname, specs] of Object.entries(extras)) {
321
+ if (!DEV_EXTRA.test(gname)) continue;
322
+ for (const spec of stringList(specs)) addDep(info, spec, "_dev");
323
+ }
324
+ }
325
+ if (poetry) {
326
+ takePoetryTable(info, poetry.dependencies, "_prod", root);
327
+ const g = poetry.group;
328
+ if (g && typeof g === "object") {
329
+ for (const group of Object.values(g)) {
330
+ if (group && typeof group === "object") takePoetryTable(info, group.dependencies, "_dev", root);
331
+ }
332
+ }
333
+ }
334
+ const sources = doc.tool && doc.tool.uv && doc.tool.uv.sources;
335
+ if (sources && typeof sources === "object") {
336
+ for (const [name, val] of Object.entries(sources)) {
337
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(name)) { warn(info, "uv source name is not a distribution name: " + name); continue; }
338
+ const norm = pep503(name);
339
+ info._source.add(norm);
340
+ if (!val || typeof val !== "object" || Array.isArray(val)) continue;
341
+ const hasPath = typeof val.path === "string";
342
+ const hasWorkspace = val.workspace === true;
343
+ if (!hasPath && !hasWorkspace) continue;
344
+ if (hasWorkspace) info._workspace.add(norm);
345
+ if (hasPath && hasWorkspace) {
346
+ const dir = resolveInside(root, val.path);
347
+ const member = resolveWorkspaceMember(repo, root, norm);
348
+ if (dir && member.dir === dir && !member.ambiguous) info.pathDeps.push({ name: norm, dir });
349
+ else info.warnings.push({ code: "AGENTDOC_BINDING_AMBIGUOUS", message: "uv source '" + name + "' sets both path and workspace = true and they do not name one directory" });
350
+ continue;
351
+ }
352
+ if (hasPath) {
353
+ const dir = resolveInside(root, val.path);
354
+ if (dir) info.pathDeps.push({ name: norm, dir });
355
+ else warn(info, "uv source path for '" + name + "' does not stay inside the repository");
356
+ continue;
357
+ }
358
+ const member = resolveWorkspaceMember(repo, root, norm);
359
+ if (member.ambiguous) {
360
+ info.warnings.push({ code: "AGENTDOC_BINDING_AMBIGUOUS", message: "uv source '" + name + "' matches more than one member of the enclosing workspace" });
361
+ continue;
362
+ }
363
+ if (member.dir) info.pathDeps.push({ name: norm, dir: member.dir });
364
+ else if (member.missingWorkspace) warn(info, "uv source '" + name + "' sets workspace = true but no enclosing workspace manifest was found");
365
+ else warn(info, "uv source '" + name + "' sets workspace = true but no member of the enclosing workspace has that name");
366
+ }
367
+ }
368
+ info.pytestInManifest = Boolean(doc.tool && doc.tool.pytest && doc.tool.pytest.ini_options);
369
+ info.ruffInManifest = Boolean(doc.tool && doc.tool.ruff);
370
+ info.mypyInManifest = Boolean(doc.tool && doc.tool.mypy);
371
+ const scripts = nonEmpty(project && project.scripts) || nonEmpty(project && project["gui-scripts"]) || nonEmpty(poetry && poetry.scripts);
372
+ info.deployable = scripts || hasEntrypointFile(repo, root);
373
+ const ws = doc.tool && doc.tool.uv && doc.tool.uv.workspace;
374
+ info.virtualWorkspace = Boolean(ws && typeof ws === "object" && !Array.isArray(ws) && !info.dist);
375
+ return finish(repo, root, info, explicitImportNames(doc));
376
+ }
377
+
378
+ function expandGroup(groups, gname, specs, seen, info) {
379
+ if (seen.has(gname)) { warn(info, "dependency-group '" + gname + "' includes itself"); return []; }
380
+ seen.add(gname);
381
+ const out = [];
382
+ for (const item of Array.isArray(specs) ? specs : []) {
383
+ if (typeof item === "string") { out.push(item); continue; }
384
+ if (item && typeof item === "object" && typeof item["include-group"] === "string") {
385
+ const other = item["include-group"];
386
+ if (!Object.prototype.hasOwnProperty.call(groups, other)) {
387
+ warn(info, "dependency-group '" + other + "' is included but not declared");
388
+ continue;
389
+ }
390
+ out.push(...expandGroup(groups, other, groups[other], seen, info));
391
+ continue;
392
+ }
393
+ warn(info, "dependency-group '" + gname + "' has an entry that is not a specifier");
394
+ }
395
+ return out;
396
+ }
397
+
398
+ function takePoetryTable(info, table, bucket, root) {
399
+ if (!table || typeof table !== "object" || Array.isArray(table)) return;
400
+ for (const [name, val] of Object.entries(table)) {
401
+ if (name === "python") {
402
+ const spec = typeof val === "string" ? val : val && typeof val.version === "string" ? val.version : null;
403
+ if (spec && !info.requiresPython) info.requiresPython = spec.trim();
404
+ continue;
405
+ }
406
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(name)) { warn(info, "poetry dependency name is not a distribution name: " + name); continue; }
407
+ const norm = pep503(name);
408
+ info[bucket].add(norm);
409
+ if (bucket === "_prod") info._source.add(norm);
410
+ if (val && typeof val === "object" && typeof val.path === "string") {
411
+ info._source.add(norm);
412
+ const dir = resolveInside(root, val.path);
413
+ if (dir) info.pathDeps.push({ name: norm, dir });
414
+ else warn(info, "poetry path for '" + name + "' does not stay inside the repository");
415
+ }
416
+ }
417
+ }
418
+
419
+ function explicitImportNames(doc) {
420
+ const names = [];
421
+ let explicit = false;
422
+ const st = doc.tool && doc.tool.setuptools && doc.tool.setuptools.packages;
423
+ if (Array.isArray(st)) {
424
+ explicit = true;
425
+ for (const s of st) if (typeof s === "string") names.push(s.split(".")[0]);
426
+ }
427
+ const poetry = doc.tool && doc.tool.poetry && doc.tool.poetry.packages;
428
+ if (Array.isArray(poetry)) {
429
+ explicit = true;
430
+ for (const p of poetry) {
431
+ if (p && typeof p.include === "string") names.push(p.include.split(/[./]/).filter(Boolean).pop());
432
+ }
433
+ }
434
+ const wheel = doc.tool && doc.tool.hatch && doc.tool.hatch.build && doc.tool.hatch.build.targets && doc.tool.hatch.build.targets.wheel;
435
+ const hatch = wheel && wheel.packages;
436
+ if (Array.isArray(hatch)) {
437
+ explicit = true;
438
+ for (const p of hatch) if (typeof p === "string") names.push(p.split("/").filter(Boolean).pop());
439
+ }
440
+ if (!explicit) return null;
441
+ return [...new Set(names)];
442
+ }
443
+
444
+ function readSetupCfg(repo, root, loc) {
445
+ const text = repo.readText(loc.manifest);
446
+ const ini = parseIni(text);
447
+ const info = blank(loc);
448
+ info.backend = true;
449
+ const meta = ini.get("metadata") || new Map();
450
+ const name = first(meta.get("name"));
451
+ if (name) info.dist = pep503(name);
452
+ const py = first(meta.get("python_requires"));
453
+ if (py) info.requiresPython = py;
454
+ const options = ini.get("options") || new Map();
455
+ for (const spec of options.get("install_requires") || []) addDep(info, spec, "_prod");
456
+ for (const spec of options.get("tests_require") || []) addDep(info, spec, "_dev");
457
+ const extras = ini.get("options.extras_require") || new Map();
458
+ for (const [gname, specs] of extras) {
459
+ if (!DEV_EXTRA.test(gname)) continue;
460
+ for (const spec of specs) addDep(info, spec, "_dev");
461
+ }
462
+ const entry = ini.get("options.entry_points") || new Map();
463
+ const scripts = (entry.get("console_scripts") || []).length > 0 || (entry.get("gui_scripts") || []).length > 0;
464
+ info.deployable = scripts || hasEntrypointFile(repo, root);
465
+ info.pytestInManifest = ini.has("tool:pytest");
466
+ info.mypyInManifest = ini.has("mypy") || ini.has("tool:mypy");
467
+ let explicit = null;
468
+ const packages = options.get("packages") || [];
469
+ if (packages.length && !(packages.length === 1 && packages[0] === "find:")) explicit = packages.map((p) => p.split(".")[0]);
470
+ return finish(repo, root, info, explicit);
471
+ }
472
+
473
+ // Keep strings and comments opaque, then split only the outer call's arguments.
474
+ // This is deliberately a literal reader, not a Python evaluator: nested calls,
475
+ // prefixed strings, and concatenation cannot supply metadata.
476
+ function setupPyKeywords(text) {
477
+ const tokens = [...text.matchAll(/#[^\n]*|\s+|("""|'''|"|')(?:\\[\s\S]|(?!\1)[^\\])*?\1|[A-Za-z_]\w*|[\s\S]/g)]
478
+ .map((m) => m[0]).filter((s) => !/^\s|^#/.test(s));
479
+ const keywords = new Map();
480
+ for (let i = 0; i < tokens.length; i++) {
481
+ const bare = tokens[i] === "setup" && tokens[i - 1] !== "." && tokens[i - 1] !== "def";
482
+ const qualified = tokens[i] === "setuptools" && tokens[i - 1] !== "."
483
+ && tokens[i + 1] === "." && tokens[i + 2] === "setup";
484
+ const open = i + (qualified ? 3 : 1);
485
+ if ((!bare && !qualified) || tokens[open] !== "(") continue;
486
+ let start = open + 1;
487
+ let depth = 0;
488
+ for (i = start; i < tokens.length; i++) {
489
+ const token = tokens[i];
490
+ if (depth === 0 && (token === "," || token === ")")) {
491
+ if (tokens[start + 1] === "=") keywords.set(tokens[start], tokens.slice(start + 2, i));
492
+ start = i + 1;
493
+ if (token === ")") break;
494
+ } else if ("([{".includes(token)) depth++;
495
+ else if (")]}".includes(token)) depth--;
496
+ }
497
+ }
498
+ return keywords;
499
+ }
500
+
501
+ function setupPyString(tokens) {
502
+ if (!tokens || tokens.length !== 1) return null;
503
+ const literal = /^("""|'''|"|')([\s\S]*)\1$/.exec(tokens[0]);
504
+ return literal ? literal[2] : null;
505
+ }
506
+
507
+ function setupPyList(tokens) {
508
+ if (!tokens || tokens[0] !== "[" || tokens.at(-1) !== "]") return null;
509
+ const out = [];
510
+ for (let i = 1; i < tokens.length - 1; i += 2) {
511
+ const value = setupPyString([tokens[i]]);
512
+ if (value === null || (i + 1 < tokens.length - 1 && tokens[i + 1] !== ",")) return null;
513
+ out.push(value);
514
+ }
515
+ return out;
516
+ }
517
+
518
+ function readSetupPy(repo, root, loc) {
519
+ const text = repo.readText(loc.manifest);
520
+ const info = blank(loc);
521
+ info.backend = true;
522
+ const keywords = setupPyKeywords(text);
523
+ for (const [key, field] of [["name", "dist"], ["python_requires", "requiresPython"]]) {
524
+ if (!keywords.has(key)) continue;
525
+ const value = setupPyString(keywords.get(key));
526
+ if (value === null) warn(info, "setup.py " + key + " is not a string literal; its value was not derived");
527
+ else if (value.trim()) info[field] = key === "name" ? pep503(value) : value.trim();
528
+ }
529
+ if (keywords.has("install_requires")) {
530
+ const listed = setupPyList(keywords.get("install_requires"));
531
+ if (listed === null) warn(info, "setup.py install_requires is not a literal list; its dependencies were not derived");
532
+ else for (const spec of listed) addDep(info, spec, "_prod");
533
+ }
534
+ info.deployable = /console_scripts/.test(text) || hasEntrypointFile(repo, root);
535
+ return finish(repo, root, info, null);
536
+ }
537
+
538
+ function readRequirementsUnit(repo, root, loc) {
539
+ const info = blank(loc);
540
+ info.backend = false;
541
+ for (const name of loc.reqs) {
542
+ const file = join(root, name);
543
+ const dev = /(dev|test)/i.test(name);
544
+ const parsed = parseRequirements(repo.readText(file), info);
545
+ for (const spec of parsed.deps) addDep(info, spec, dev ? "_dev" : "_prod");
546
+ for (const rel of parsed.paths) {
547
+ const dir = resolveInside(root, rel);
548
+ if (!dir) { warn(info, "editable requirement '" + rel + "' does not stay inside the repository"); continue; }
549
+ const base = pep503(dir.split("/").pop());
550
+ info._source.add(base);
551
+ if (dev) info._dev.add(base);
552
+ else info._prod.add(base);
553
+ info.pathDeps.push({ name: base, dir });
554
+ }
555
+ }
556
+ info.deployable = hasEntrypointFile(repo, root);
557
+ return finish(repo, root, info, null);
558
+ }
559
+
560
+ function parseRequirements(text, info) {
561
+ const deps = [];
562
+ const paths = [];
563
+ for (const raw of String(text).split("\n")) {
564
+ const line = raw.replace(/#.*/, "").trim();
565
+ if (!line) continue;
566
+ if (/^(-r|--requirement|-c|--constraint)\b/.test(line)) {
567
+ warn(info, "included requirement file is not expanded: " + line);
568
+ continue;
569
+ }
570
+ if (/^(-e|--editable)\b/.test(line)) {
571
+ const spec = line.replace(/^(-e|--editable)\s+/, "").trim();
572
+ const path = editablePath(spec);
573
+ if (path) paths.push(path);
574
+ else warn(info, "editable requirement is not a repository path: " + spec);
575
+ continue;
576
+ }
577
+ if (line.startsWith("-")) continue;
578
+ deps.push(line);
579
+ }
580
+ return { deps, paths };
581
+ }
582
+
583
+ function editablePath(spec) {
584
+ let s = spec.trim();
585
+ const at = s.indexOf(" @ ");
586
+ if (at >= 0) s = s.slice(at + 3).trim();
587
+ s = s.replace(/^file:(?=\.{0,2}\/)/, "");
588
+ if (!s || s.startsWith("file:") || s.includes("://") || s.startsWith("git+")) return null;
589
+ return s.split(";")[0].trim();
590
+ }
591
+
592
+ function parseIni(text) {
593
+ const sections = new Map();
594
+ let cur = null;
595
+ let key = null;
596
+ for (const raw of String(text).split("\n")) {
597
+ const line = raw.replace(/\s+$/, "");
598
+ const sec = /^\s*\[([^\]]+)\]\s*$/.exec(line);
599
+ if (sec) {
600
+ cur = sec[1].trim();
601
+ if (!sections.has(cur)) sections.set(cur, new Map());
602
+ key = null;
603
+ continue;
604
+ }
605
+ if (!cur || /^\s*[#;]/.test(line) || !line.trim()) continue;
606
+ const kv = /^\s*([A-Za-z0-9_.-]+)\s*=\s*(.*)$/.exec(line);
607
+ if (kv) {
608
+ key = kv[1];
609
+ const prev = sections.get(cur).get(key) || [];
610
+ if (kv[2].trim()) prev.push(kv[2].trim());
611
+ sections.get(cur).set(key, prev);
612
+ continue;
613
+ }
614
+ if (key && /^\s+\S/.test(line)) sections.get(cur).get(key).push(line.trim());
615
+ }
616
+ return sections;
617
+ }
618
+
619
+ function first(list) { return list && list.length ? list[0] : null; }
620
+ function stringList(v) { return Array.isArray(v) ? v.filter((x) => typeof x === "string") : []; }
621
+ function nonEmpty(t) { return Boolean(t && typeof t === "object" && !Array.isArray(t) && Object.keys(t).length); }
622
+
623
+ function packageDirs(repo, root) {
624
+ const found = [];
625
+ const src = join(root, "src");
626
+ if (repo.ownsUnitPath(root, src) && repo.isDir(src)) {
627
+ for (const name of repo.listDir(src)) {
628
+ if (!name.endsWith("/")) continue;
629
+ const base = name.slice(0, -1);
630
+ const init = join(src, base) + "/__init__.py";
631
+ if (repo.ownsUnitPath(root, init) && repo.exists(init)) found.push(base);
632
+ }
633
+ }
634
+ for (const name of repo.listDir(root)) {
635
+ if (!name.endsWith("/")) continue;
636
+ const base = name.slice(0, -1);
637
+ if (SKIP_PKG_DIR.has(base) || base.startsWith(".")) continue;
638
+ const init = join(root, base) + "/__init__.py";
639
+ if (repo.ownsUnitPath(root, init) && repo.exists(init)) found.push(base);
640
+ }
641
+ return [...new Set(found)].sort();
642
+ }
643
+
644
+ function hasEntrypointFile(repo, root) {
645
+ if (repo.exists(join(root, "manage.py"))) return true;
646
+ const prefix = !root || root === "." ? "" : root + "/";
647
+ for (const f of repo.walkUnit(root)) {
648
+ if (!f.endsWith("/__main__.py") && f !== prefix + "__main__.py") continue;
649
+ if (/(^|\/)tests?\//.test(prefix ? f.slice(prefix.length) : f)) continue;
650
+ return true;
651
+ }
652
+ for (const f of sourceFiles(repo, root, { test: false, extensions: /\.py$/ })) {
653
+ if (APP_ASSIGN.test(repo.readText(f)) || APP_GETTER.test(repo.readText(f))) return true;
654
+ }
655
+ return false;
656
+ }
657
+
658
+ function classify(info, name, target) {
659
+ const keys = [name, target && target.pythonDist].filter(Boolean);
660
+ if (keys.some((k) => info.prodDeps.includes(k))) return "buildDependsOn";
661
+ if (keys.some((k) => info.devDeps.includes(k))) return "testDependsOn";
662
+ if (keys.some((k) => info.sourceDeps.includes(k))) return "buildDependsOn";
663
+ return "buildDependsOn";
664
+ }
665
+
666
+ function lockLists(text, name) {
667
+ const n = pep503(name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
668
+ return new RegExp("^name\\s*=\\s*[\"']" + n + "[\"']\\s*(?:#.*)?$", "m").test(text);
669
+ }
670
+
671
+ function findUp(repo, root, filename) {
672
+ let dir = root || ".";
673
+ for (;;) {
674
+ const p = !dir || dir === "." ? filename : dir + "/" + filename;
675
+ if (repo.exists(p)) return p;
676
+ if (!dir || dir === ".") return null;
677
+ const i = dir.lastIndexOf("/");
678
+ dir = i < 0 ? "." : dir.slice(0, i);
679
+ }
680
+ }
681
+
682
+ function runnerFor(repo, root, tool, warnings) {
683
+ const uv = findUp(repo, root, "uv.lock");
684
+ const poetry = findUp(repo, root, "poetry.lock");
685
+ const uvProves = Boolean(uv && lockLists(repo.readText(uv), tool));
686
+ const poetryProves = Boolean(poetry && lockLists(repo.readText(poetry), tool));
687
+ if (uvProves && poetryProves) {
688
+ warnings.push({ code: "AGENTDOC_BINDING_AMBIGUOUS", message: "both uv.lock and poetry.lock provide '" + tool + "'; the command is not prefixed with a runner" });
689
+ return { prefix: null, files: [] };
690
+ }
691
+ if (uvProves) return { prefix: "uv run", files: [uv] };
692
+ if (poetryProves) return { prefix: "poetry run", files: [poetry] };
693
+ return { prefix: null, files: [] };
694
+ }
695
+
696
+ function testPyFiles(repo, root) {
697
+ const prefix = !root || root === "." ? "" : root + "/";
698
+ return repo.walkUnit(root).filter((f) => {
699
+ if (!f.endsWith(".py")) return false;
700
+ if (prefix && f !== root && !f.startsWith(prefix)) return false;
701
+ const base = prefix ? f.slice(prefix.length) : f;
702
+ if (/(^|\/)conftest\.py$/.test(base)) return true;
703
+ if (/(^|\/)test_.+\.py$/.test(base) || /_test\.py$/.test(base)) return true;
704
+ if (/(^|\/)tests?\//.test(base)) return true;
705
+ return false;
706
+ });
707
+ }
708
+
709
+ function usesUnittest(repo, files) {
710
+ return files.some((f) => {
711
+ if (/(^|\/)conftest\.py$/.test(f)) return false;
712
+ return /^\s*(?:import\s+unittest\b|from\s+unittest\b)/m.test(repo.readText(f));
713
+ });
714
+ }
715
+
716
+ function addCommand(ctx, compRef, prov, id, tier, command, configPaths) {
717
+ ctx.addVerification({
718
+ id: compRef + ":" + id,
719
+ componentRefs: [compRef],
720
+ tier,
721
+ command,
722
+ configPaths: [...new Set(configPaths)].sort(),
723
+ provRecs: [prov],
724
+ });
725
+ }
726
+
727
+ export class PythonAdapter extends Adapter {
728
+ static adapterName = "python";
729
+ constructor() {
730
+ super({ name: "python", version: "1.2.1", kind: "unit-marker" });
731
+ }
732
+
733
+ roots(ctx) {
734
+ const out = new Map();
735
+ const workspaces = uvWorkspaceManifests(ctx.repo, ctx.cfg);
736
+ const hop = new Set();
737
+ for (const ws of workspaces) {
738
+ hop.add(ws.dir || ".");
739
+ const members = expandMembers(ctx.repo, ws.dir, ws.table.members || []);
740
+ const excluded = new Set(expandMembers(ctx.repo, ws.dir, ws.table.exclude || []));
741
+ for (const dir of members) {
742
+ if (excluded.has(dir) || skippedRoot(ctx, dir)) continue;
743
+ if (!hasMarker(ctx.repo, dir)) continue;
744
+ const info = readPythonUnit(ctx.repo, dir);
745
+ // A member that only declares a workspace has no distribution and no
746
+ // shape. It is the workspace file, not a unit. Every other manifest
747
+ // is a unit; shape stays whatever the manifest and tree show.
748
+ if (!info.manifest || workspaceWithoutDistribution(info)) continue;
749
+ out.set(dir, {
750
+ root: dir,
751
+ deployable: Boolean(info.deployable),
752
+ importable: Boolean(info.importable),
753
+ });
754
+ }
755
+ }
756
+ for (const dir of out.keys()) hop.add(dir);
757
+ for (const dir of [...hop].sort()) {
758
+ const file = !dir || dir === "." ? "pyproject.toml" : dir + "/pyproject.toml";
759
+ if (!ctx.repo.exists(file)) continue;
760
+ let parsed;
761
+ try { parsed = parseToml(ctx.repo.readText(file), file); }
762
+ catch { continue; }
763
+ const from = dir === "." ? "" : dir;
764
+ for (const rel of pathDepsOf(parsed)) {
765
+ const resolved = resolveInside(from, rel);
766
+ if (!resolved || skippedRoot(ctx, resolved) || !ctx.repo.isDir(resolved) || !hasMarker(ctx.repo, resolved)) continue;
767
+ if (!out.has(resolved)) out.set(resolved, { root: resolved });
768
+ }
769
+ }
770
+ return [...out.values()].sort((a, b) => (a.root < b.root ? -1 : 1));
771
+ }
772
+
773
+ detect(ctx, root) {
774
+ const info = readPythonUnit(ctx.repo, root);
775
+ if (!info.manifest || workspaceWithoutDistribution(info)) return null;
776
+ const shape = info.deployable && info.importable ? "both" : info.deployable ? "deployable" : info.importable ? "importable" : "present";
777
+ return { path: info.manifest, shape };
778
+ }
779
+
780
+ extract(ctx, unit) {
781
+ const info = readPythonUnit(ctx.repo, unit.root);
782
+ if (!info.manifest || info.error) return {};
783
+ const { repo, prov, compRef } = ctx;
784
+ const derived = { languages: new Set(["python"]), runtimes: new Set(["python"]) };
785
+ if (info.dist) derived.pythonDistribution = info.dist;
786
+ if (info.importNames.length && !info.ambiguousImports) derived.pythonImportNames = info.importNames;
787
+ if (info.requiresPython) derived.requiresPython = info.requiresPython;
788
+ const subjects = ["der:" + compRef + ":/runtimes", "der:" + compRef + ":/languages"];
789
+ if (info.requiresPython) subjects.push("der:" + compRef + ":/requiresPython");
790
+ const pr = prov.add("observed", info.manifest, "python-manifest-extractor", subjects);
791
+
792
+ for (const w of info.warnings) {
793
+ ctx.diagnostics.push({
794
+ severity: "warning", code: w.code, subject: compRef, refs: [compRef], message: w.message, paths: [info.manifest],
795
+ });
796
+ }
797
+ if (info.ambiguousImports || unit.pythonDistConflict || unit.pythonImportConflict) {
798
+ ctx.diagnostics.push({
799
+ severity: "warning",
800
+ code: "AGENTDOC_BINDING_AMBIGUOUS",
801
+ subject: compRef,
802
+ refs: [compRef],
803
+ paths: [info.manifest],
804
+ message: info.ambiguousImports
805
+ ? "python unit '" + unit.root + "' has more than one import package and no explicit package list; the import name is left unresolved"
806
+ : "python distribution or import name of '" + unit.root + "' is claimed by another unit; the cross-unit link is left unresolved",
807
+ });
808
+ }
809
+
810
+ const externalNames = new Set(info.prodDeps);
811
+ for (const n of info.sourceDeps) if (!info.devDeps.includes(n)) externalNames.add(n);
812
+ for (const name of [...externalNames].sort()) {
813
+ for (const x of ctx.externalMatchers()) {
814
+ if (!x.re.test(name)) continue;
815
+ ctx.addExternal(compRef, x.name, x.mechanism, x.role, pr, "DERIVED");
816
+ }
817
+ }
818
+
819
+ const linked = new Set();
820
+ const link = (target, pkg, type, via, files) => {
821
+ if (!target || !target.component || target.component.ref === compRef) return;
822
+ const key = type + "|" + target.component.ref + "|" + pkg + "|" + via;
823
+ if (linked.has(key)) return;
824
+ linked.add(key);
825
+ const provs = files.length
826
+ ? files.map((f) => prov.add("observed", f, "python-import-extractor", ["rel:pending"]))
827
+ : [pr];
828
+ ctx.addEdge(type, compRef, target.component.ref, { via, package: pkg }, provs, "DERIVED");
829
+ };
830
+
831
+ const byRoot = new Map(ctx.discovery.eligible.map((u) => [u.root, u]));
832
+ const workspaceOnly = new Set(info.workspaceNames || []);
833
+ const consider = new Set([...info.prodDeps, ...info.devDeps, ...info.sourceDeps]);
834
+ for (const name of [...consider].sort()) {
835
+ // `workspace = true` is resolved against the nearest enclosing workspace.
836
+ // The global distribution map must not substitute a different sibling.
837
+ if (workspaceOnly.has(name)) continue;
838
+ const target = ctx.unitByPythonDist.get(name);
839
+ if (!target) continue;
840
+ link(target, target.pythonDist || name, classify(info, name, target), "python-manifest", []);
841
+ }
842
+ for (const p of info.pathDeps) {
843
+ const target = byRoot.get(p.dir);
844
+ if (!target) continue;
845
+ const pkg = target.pythonDist || p.name;
846
+ link(target, pkg, classify(info, p.name, target), "python-manifest", []);
847
+ }
848
+
849
+ const imports = (ctx.importIndex.pythonImportsByUnit && ctx.importIndex.pythonImportsByUnit.get(unit)) || new Map();
850
+ const byTarget = new Map();
851
+ const shadowed = [];
852
+ for (const name of [...imports.keys()].sort()) {
853
+ const files = imports.get(name);
854
+ const siblings = siblingImportUnits(ctx, unit, name);
855
+ // A module or package the unit itself provides is not a sibling import.
856
+ // Script-style layouts keep that module next to the importing file.
857
+ if (unitProvidesImport(repo, unit.root, name, files)) {
858
+ if (siblings.some((u) => u.component)) shadowed.push(name);
859
+ continue;
860
+ }
861
+ if (siblings.length !== 1) continue;
862
+ const target = siblings[0];
863
+ if (!target.component || target.component.ref === compRef) continue;
864
+ if (!byTarget.has(target)) byTarget.set(target, new Set());
865
+ for (const f of files) byTarget.get(target).add(f);
866
+ }
867
+ if (shadowed.length) {
868
+ const files = shadowed.flatMap((name) => [...imports.get(name)]);
869
+ ctx.diagnostics.push({
870
+ severity: "warning",
871
+ code: "AGENTDOC_BINDING_AMBIGUOUS",
872
+ subject: compRef,
873
+ refs: [compRef],
874
+ paths: [...new Set([info.manifest, ...files])].sort(),
875
+ message: "import " + shadowed.join(", ") + " is provided by unit '" + unit.root + "' and also by a sibling; no import edge was recorded",
876
+ });
877
+ }
878
+ for (const [target, files] of [...byTarget.entries()].sort((a, b) => (a[0].root < b[0].root ? -1 : 1))) {
879
+ const pkg = target.pythonDist || pep503([...imports.keys()].find((n) => ctx.unitByPythonImport.get(n) === target) || target.root.split("/").pop());
880
+ link(target, pkg, classify(info, pkg, target), "python-import", [...files].sort());
881
+ }
882
+
883
+ const tests = testPyFiles(repo, unit.root);
884
+ if (tests.length) derived.hasTests = true;
885
+ const pytestPaths = [];
886
+ if (info.pytestInManifest) pytestPaths.push(info.manifest);
887
+ for (const n of ["pytest.ini", "pytest.toml", ".pytest.ini"]) {
888
+ const p = join(unit.root, n);
889
+ if (repo.exists(p)) pytestPaths.push(p);
890
+ }
891
+ for (const f of tests) if (/(^|\/)conftest\.py$/.test(f)) pytestPaths.push(f);
892
+ if (repo.exists(locSetupCfg(unit)) && /\[tool:pytest\]/.test(repo.readText(locSetupCfg(unit)))) {
893
+ if (!pytestPaths.includes(locSetupCfg(unit))) pytestPaths.push(locSetupCfg(unit));
894
+ }
895
+ const runnerWarnings = [];
896
+ if (pytestPaths.length) {
897
+ const runner = runnerFor(repo, unit.root, "pytest", runnerWarnings);
898
+ addCommand(ctx, compRef, pr, "pytest", "unit", (runner.prefix ? runner.prefix + " " : "") + "pytest", [...pytestPaths, ...runner.files]);
899
+ } else if (usesUnittest(repo, tests)) {
900
+ addCommand(ctx, compRef, pr, "unittest", "unit", "python -m unittest discover", [info.manifest]);
901
+ }
902
+ const ruffPaths = info.ruffInManifest ? [info.manifest] : [];
903
+ for (const n of ["ruff.toml", ".ruff.toml"]) {
904
+ const p = join(unit.root, n);
905
+ if (repo.exists(p)) ruffPaths.push(p);
906
+ }
907
+ if (ruffPaths.length) {
908
+ const runner = runnerFor(repo, unit.root, "ruff", runnerWarnings);
909
+ addCommand(ctx, compRef, pr, "ruff", "static", (runner.prefix ? runner.prefix + " " : "") + "ruff check", [...ruffPaths, ...runner.files]);
910
+ }
911
+ const mypyPaths = info.mypyInManifest ? [info.manifest] : [];
912
+ for (const n of ["mypy.ini", ".mypy.ini"]) {
913
+ const p = join(unit.root, n);
914
+ if (repo.exists(p)) mypyPaths.push(p);
915
+ }
916
+ if (mypyPaths.length) {
917
+ const runner = runnerFor(repo, unit.root, "mypy", runnerWarnings);
918
+ addCommand(ctx, compRef, pr, "mypy", "static", (runner.prefix ? runner.prefix + " " : "") + "mypy", [...mypyPaths, ...runner.files]);
919
+ }
920
+ for (const w of runnerWarnings) {
921
+ ctx.diagnostics.push({
922
+ severity: "warning", code: w.code, subject: compRef, refs: [compRef], message: w.message, paths: [info.manifest],
923
+ });
924
+ }
925
+ return derived;
926
+ }
927
+ }
928
+
929
+ function locSetupCfg(unit) { return join(unit.root, "setup.cfg"); }
930
+
931
+ function workspaceWithoutDistribution(info) {
932
+ return Boolean(info && info.virtualWorkspace && !info.dist && !info.deployable && !info.importable && !info.error);
933
+ }
934
+
935
+ function hasMarker(repo, dir) {
936
+ return repo.exists(dir + "/pyproject.toml") || repo.exists(dir + "/setup.cfg") || repo.exists(dir + "/setup.py")
937
+ || repo.listDir(dir).some((n) => /^requirements.*\.txt$/.test(n));
938
+ }
939
+
940
+ function notUnitSet(cfg) {
941
+ const configured = cfg && cfg.discovery && cfg.discovery.notUnitRoots;
942
+ return new Set(configured || DEFAULT_NOT_UNIT_ROOTS);
943
+ }
944
+
945
+ function skippedRoot(ctx, dir) {
946
+ if (!dir) return false;
947
+ if (notUnitSet(ctx.cfg).has(dir)) return true;
948
+ const skip = ctx.repo.generatedDirs;
949
+ return dir.split("/").some((seg) => skip && skip.has(seg));
950
+ }
951
+
952
+ function underSkippedRoot(dir, notUnit, skip) {
953
+ if (!dir) return false;
954
+ const parts = dir.split("/");
955
+ if (parts.some((seg) => seg.startsWith(".") || (skip && skip.has(seg)))) return true;
956
+ for (let i = 1; i <= parts.length; i++) {
957
+ if (notUnit.has(parts.slice(0, i).join("/"))) return true;
958
+ }
959
+ return false;
960
+ }
961
+
962
+ // Workspace manifests at the repository root and below it. The file list comes
963
+ // from walk(), which already skips generated directories, so creating
964
+ // `.agentdoc/` between compiles does not change the input. Only manifests
965
+ // within MAX_WORKSPACE_DEPTH are workspaces.
966
+ function uvWorkspaceManifests(repo, cfg) {
967
+ const notUnit = notUnitSet(cfg);
968
+ const skip = repo.generatedDirs || new Set();
969
+ const found = [];
970
+ for (const f of repo.walk(".")) {
971
+ if (f !== "pyproject.toml" && !f.endsWith("/pyproject.toml")) continue;
972
+ const dir = f === "pyproject.toml" ? "" : f.slice(0, -"/pyproject.toml".length);
973
+ const depth = dir ? dir.split("/").length : 0;
974
+ if (depth > MAX_WORKSPACE_DEPTH) continue;
975
+ if (underSkippedRoot(dir, notUnit, skip)) continue;
976
+ try {
977
+ const doc = parseToml(repo.readText(f), f);
978
+ const table = doc.tool && doc.tool.uv && doc.tool.uv.workspace;
979
+ if (table && typeof table === "object" && !Array.isArray(table)) found.push({ dir, table });
980
+ } catch { /* a broken file is not a workspace; the unit reports the parse */ }
981
+ }
982
+ found.sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0));
983
+ return found;
984
+ }
985
+
986
+ function nearestUvWorkspace(repo, root) {
987
+ let dir = !root || root === "." ? "" : root;
988
+ const seen = new Set();
989
+ while (!seen.has(dir)) {
990
+ seen.add(dir);
991
+ const manifest = dir ? dir + "/pyproject.toml" : "pyproject.toml";
992
+ if (repo.exists(manifest)) {
993
+ try {
994
+ const doc = parseToml(repo.readText(manifest), manifest);
995
+ const table = doc.tool && doc.tool.uv && doc.tool.uv.workspace;
996
+ if (table && typeof table === "object" && !Array.isArray(table)) return { dir, table };
997
+ } catch { /* walk through a file that does not parse */ }
998
+ }
999
+ if (!dir) return null;
1000
+ const i = dir.lastIndexOf("/");
1001
+ dir = i < 0 ? "" : dir.slice(0, i);
1002
+ }
1003
+ return null;
1004
+ }
1005
+
1006
+ function resolveWorkspaceMember(repo, fromRoot, name) {
1007
+ const ws = nearestUvWorkspace(repo, fromRoot);
1008
+ if (!ws) return { dir: null, ambiguous: false, missingWorkspace: true };
1009
+ const members = expandMembers(repo, ws.dir, ws.table.members || []);
1010
+ const excluded = new Set(expandMembers(repo, ws.dir, ws.table.exclude || []));
1011
+ const hits = [];
1012
+ for (const dir of members) {
1013
+ if (excluded.has(dir)) continue;
1014
+ if (distributionNameAt(repo, dir) === name) hits.push(dir);
1015
+ }
1016
+ if (hits.length > 1) return { dir: null, ambiguous: true, missingWorkspace: false };
1017
+ if (hits.length === 1) return { dir: hits[0], ambiguous: false, missingWorkspace: false };
1018
+ return { dir: null, ambiguous: false, missingWorkspace: false };
1019
+ }
1020
+
1021
+ function distributionNameAt(repo, dir) {
1022
+ const loc = locate(repo, dir);
1023
+ try {
1024
+ if (loc.kind === "pyproject") {
1025
+ const doc = parseToml(repo.readText(loc.manifest), loc.manifest);
1026
+ const project = doc.project && doc.project.name;
1027
+ const poetry = doc.tool && doc.tool.poetry && doc.tool.poetry.name;
1028
+ if (typeof project === "string" && project.trim()) return pep503(project);
1029
+ if (typeof poetry === "string" && poetry.trim()) return pep503(poetry);
1030
+ return null;
1031
+ }
1032
+ if (loc.kind === "setup.cfg") {
1033
+ const meta = /\[metadata\]([^[]*)/.exec(repo.readText(loc.manifest));
1034
+ const n = meta && /^name\s*=\s*(\S+)/m.exec(meta[1]);
1035
+ return n ? pep503(n[1]) : null;
1036
+ }
1037
+ if (loc.kind === "setup.py") {
1038
+ const name = setupPyString(setupPyKeywords(repo.readText(loc.manifest)).get("name"));
1039
+ return name && name.trim() ? pep503(name) : null;
1040
+ }
1041
+ } catch {
1042
+ return null;
1043
+ }
1044
+ return null;
1045
+ }
1046
+
1047
+ function hasModule(repo, unitRoot, dir, name) {
1048
+ const base = !dir || dir === "." ? "" : dir;
1049
+ const py = (base ? base + "/" : "") + name + ".py";
1050
+ const init = (base ? base + "/" : "") + name + "/__init__.py";
1051
+ return (repo.ownsUnitPath(unitRoot, py) && repo.exists(py))
1052
+ || (repo.ownsUnitPath(unitRoot, init) && repo.exists(init));
1053
+ }
1054
+
1055
+ // Top-level module at the unit root, under src/, or beside the importing file.
1056
+ function unitProvidesImport(repo, unitRoot, name, files) {
1057
+ const root = !unitRoot || unitRoot === "." ? "" : unitRoot;
1058
+ if (hasModule(repo, unitRoot, root, name)) return true;
1059
+ if (hasModule(repo, unitRoot, root ? root + "/src" : "src", name)) return true;
1060
+ for (const f of files || []) {
1061
+ const slash = f.lastIndexOf("/");
1062
+ if (hasModule(repo, unitRoot, slash < 0 ? "" : f.slice(0, slash), name)) return true;
1063
+ }
1064
+ return false;
1065
+ }
1066
+
1067
+ function siblingImportUnits(ctx, unit, name) {
1068
+ const out = [];
1069
+ for (const u of ctx.discovery.eligible) {
1070
+ if (u === unit || !Array.isArray(u.pythonImports)) continue;
1071
+ if (u.pythonImports.includes(name)) out.push(u);
1072
+ }
1073
+ return out.sort((a, b) => (a.root < b.root ? -1 : a.root > b.root ? 1 : 0));
1074
+ }
1075
+
1076
+ function expandMembers(repo, base, patterns) {
1077
+ const root = !base || base === "." ? "" : String(base).replace(/\/$/, "");
1078
+ const dirs = [];
1079
+ const list = Array.isArray(patterns) ? patterns : [];
1080
+ for (const g of list) {
1081
+ if (typeof g !== "string") continue;
1082
+ const pat = g.replace(/^\.\//, "").replace(/\/$/, "");
1083
+ if (!pat || pat.startsWith("/") || pat.split("/").includes("..")) continue;
1084
+ const full = root ? root + "/" + pat : pat;
1085
+ if (full.split("/").includes("..")) continue;
1086
+ if (!/[*?]/.test(pat)) {
1087
+ if (repo.isDir(full)) dirs.push(full);
1088
+ continue;
1089
+ }
1090
+ let re;
1091
+ try { re = globToRegExp(full); }
1092
+ catch { continue; }
1093
+ const prefix = full.split(/[*?]/)[0].replace(/\/$/, "");
1094
+ if (prefix && !repo.isDir(prefix)) continue;
1095
+ for (const d of nestedDirs(repo, prefix || ".", 6)) if (re.test(d)) dirs.push(d);
1096
+ }
1097
+ return [...new Set(dirs)].sort();
1098
+ }
1099
+
1100
+ function nestedDirs(repo, base, maxDepth) {
1101
+ const out = [];
1102
+ const start = base === "." ? "" : base;
1103
+ const walk = (dir, depth) => {
1104
+ if (depth > maxDepth) return;
1105
+ for (const name of repo.listDir(dir)) {
1106
+ if (!name.endsWith("/")) continue;
1107
+ const baseName = name.slice(0, -1);
1108
+ if (baseName.startsWith(".") || baseName === "__pycache__" || (repo.generatedDirs && repo.generatedDirs.has(baseName))) continue;
1109
+ const child = dir ? dir + "/" + baseName : baseName;
1110
+ out.push(child);
1111
+ walk(child, depth + 1);
1112
+ }
1113
+ };
1114
+ walk(start, 1);
1115
+ return out.sort();
1116
+ }
1117
+
1118
+ function pathDepsOf(doc) {
1119
+ const out = [];
1120
+ const sources = doc.tool && doc.tool.uv && doc.tool.uv.sources;
1121
+ if (sources && typeof sources === "object") {
1122
+ for (const val of Object.values(sources)) {
1123
+ if (val && typeof val === "object" && typeof val.path === "string") out.push(val.path);
1124
+ }
1125
+ }
1126
+ const poetry = doc.tool && doc.tool.poetry;
1127
+ if (poetry && typeof poetry === "object") {
1128
+ collectPoetryPaths(poetry.dependencies, out);
1129
+ const groups = poetry.group;
1130
+ if (groups && typeof groups === "object") {
1131
+ for (const g of Object.values(groups)) if (g && typeof g === "object") collectPoetryPaths(g.dependencies, out);
1132
+ }
1133
+ }
1134
+ return out;
1135
+ }
1136
+
1137
+ function collectPoetryPaths(table, out) {
1138
+ if (!table || typeof table !== "object") return;
1139
+ for (const val of Object.values(table)) {
1140
+ if (val && typeof val === "object" && typeof val.path === "string") out.push(val.path);
1141
+ }
1142
+ }