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
@@ -44,7 +44,7 @@ export class TypeScriptAdapter extends Adapter {
44
44
  const routes = [];
45
45
  for (const base of ["src/app", "app", "src/pages", "pages"]) {
46
46
  if (!repo.isDir(unit.root + "/" + base)) continue;
47
- for (const f of repo.walk(unit.root + "/" + base)) {
47
+ for (const f of repo.walkUnit(unit.root, unit.root + "/" + base)) {
48
48
  const routePath = routeFor(unit.root, base, f);
49
49
  if (!routePath) continue;
50
50
  const rpr = prov.add("observed", f, "route-extractor", ["der:" + compRef + ":/routes"]);
package/bin/agentdoc.mjs CHANGED
@@ -275,9 +275,9 @@ try {
275
275
  }
276
276
  }
277
277
  const ctx = queryContext(graph, needle, { budget });
278
- if (opt("md")) out(renderContext(ctx));
278
+ if (opt("md")) out(renderContext(ctx, { budget }));
279
279
  else if (opt("json")) out(ctx, true);
280
- else out(renderContext(ctx));
280
+ else out(renderContext(ctx, { budget }));
281
281
  } else if (cmd === "impact") {
282
282
  // Impact answers a question about the working tree, so it compiles in
283
283
  // memory rather than reading the committed graph. Refusing a stale graph
package/core/audit.mjs CHANGED
@@ -160,7 +160,9 @@ export function audit(repo, cfg, res) {
160
160
  if (!DOC_EXT.test(f)) continue;
161
161
  const owners = graph.entities.filter((e) => {
162
162
  const d = e.derived || {};
163
- return (d.docs || []).includes(f) || (d.constraints || []).includes(f) || (d.runbooks || []).includes(f);
163
+ const context = e.entity.spec?.context || {};
164
+ return [d, context].some((c) =>
165
+ ["docs", "constraints", "runbooks"].some((key) => (c[key] || []).includes(f)));
164
166
  }).map((e) => e.ref);
165
167
  const role = (configured.find((h) => h.path === f) || {}).role || null;
166
168
  docInventory.push({ path: f, linkedTo: owners.sort(), role });
@@ -193,9 +195,9 @@ export function audit(repo, cfg, res) {
193
195
  subject: d.path,
194
196
  refs: [d.path],
195
197
  message:
196
- "document is claimed by no component" +
198
+ "document is claimed by no entity" +
197
199
  (d.role ? " and its configured role (" + d.role + ") is not a repository-wide role" : "") +
198
- ". Link it from a component descriptor, or remove it.",
200
+ ". Link it from an entity descriptor, or remove it.",
199
201
  });
200
202
  }
201
203
  for (const h of hierarchyDocs) {
package/core/codes.mjs CHANGED
@@ -77,6 +77,9 @@ export const CODES = {
77
77
  WARNING_ACCEPTANCE: "AGENTDOC_WARNING_ACCEPTANCE",
78
78
  OVERRIDE_STALE: "AGENTDOC_OVERRIDE_STALE",
79
79
  JOURNEY: "AGENTDOC_JOURNEY",
80
+
81
+ // language manifests
82
+ PYTHON_SPECIFIER: "AGENTDOC_PYTHON_SPECIFIER",
80
83
  };
81
84
 
82
85
  // Warning codes that a reviewer may explicitly accept. Anything outside this
@@ -94,6 +97,7 @@ export const ACCEPTABLE_WARNING_CODES = Object.freeze([
94
97
  "AGENTDOC_VERIFICATION_UNTIERED",
95
98
  "AGENTDOC_NORMATIVE_UNCOVERED",
96
99
  "AGENTDOC_REQUIREMENT_UNVERIFIED",
100
+ "AGENTDOC_PYTHON_SPECIFIER",
97
101
  ]);
98
102
 
99
103
  export class AgentDocError extends Error {
package/core/compile.mjs CHANGED
@@ -17,11 +17,13 @@ import { redactCommand } from "./secrets.mjs";
17
17
  import { formatErrors } from "./jsonschema.mjs";
18
18
  import { validateContractFile } from "./contracts.mjs";
19
19
  import { sourceFiles, importSpecifiers } from "./sourcescan.mjs";
20
+ import { readPythonUnit, pythonImportNames } from "../adapters/python.mjs";
20
21
  import { applyWarningAcceptances } from "./acceptances.mjs";
21
22
  import {
22
23
  loadNormativeDocuments, verifyCitation, normativeCoverage, isNormative, isRequirementKey, NORMATIVE_SEMANTICS_PREFIX,
23
24
  } from "./normative.mjs";
24
25
  import { loadAdapters } from "../adapters/index.mjs";
26
+ import { loadCargoUnit, analyzeCargoDeps, rustIdentsInSource } from "../adapters/rust.mjs";
25
27
  import {
26
28
  COMPILER_NAME, COMPILER_VERSION, GRAPH_SCHEMA_VERSION,
27
29
  assembleGraph, serializeGraph, finalizeSerialization, subjectResolver,
@@ -130,11 +132,6 @@ export function compile(root, opts = {}) {
130
132
  for (const api of sources.entities.filter((e) => e.kind === "API")) {
131
133
  collect(() => validateContractFile(repo, bundle, api), res.errors);
132
134
  }
133
- res.errors.push(...validateOidcProviders({ repo, sources, discovery: null }));
134
-
135
- if (res.errors.length) {
136
- return earlyOut(res, { entities: sources.entities.length });
137
- }
138
135
 
139
136
  let adapters;
140
137
  try {
@@ -147,16 +144,22 @@ export function compile(root, opts = {}) {
147
144
  throw e;
148
145
  }
149
146
  const discovery = discoverUnits(repo, cfg, adapters);
147
+ repo.nestedUnitRoots = discovery.nestedRoots;
150
148
  // Eligibility is the compiler's own answer to "which directories are units" —
151
149
  // exposed on the result so scaffold can write descriptors for exactly these
152
150
  // roots rather than re-deriving them from filesystem markers.
153
151
  res.discovery = discovery;
154
- const match = matchComponents(discovery.eligible, sources.entities.filter((e) => e.kind === "Component"));
155
- res.errors.push(...match.errors);
156
152
 
157
- // OIDC discovery must be exposed by the provider's own source. Re-check now
158
- // that component -> unit bindings are known.
153
+ // A provider descriptor sits at its unit root. Check its source only after
154
+ // ownership is known, while still reporting this error alongside other
155
+ // descriptor/contract errors rather than hiding it behind an early return.
159
156
  res.errors.push(...validateOidcProviders({ repo, sources, discovery }));
157
+ if (res.errors.length) {
158
+ return earlyOut(res, { entities: sources.entities.length });
159
+ }
160
+
161
+ const match = matchComponents(discovery.eligible, sources.entities.filter((e) => e.kind === "Component"), cfg);
162
+ res.errors.push(...match.errors);
160
163
 
161
164
  if (res.errors.length) {
162
165
  return earlyOut(res, { entities: sources.entities.length, units: discovery.eligible.length });
@@ -285,6 +288,17 @@ export function compile(root, opts = {}) {
285
288
  // ── manifest pre-pass: identify each unit's package / module namespace ────
286
289
  // Done before extraction so cross-unit resolution never depends on the order
287
290
  // in which units happen to be processed.
291
+ // Cargo.toml is read only when the rust adapter is on. An existence probe is
292
+ // part of the input hash, so probing it for every unit would change every
293
+ // graph that does not enable rust.
294
+ const rustOn = adapters.some((a) => a.name === "rust");
295
+ ctx.unitByCrateName = new Map();
296
+ ctx.ambiguousCrateNames = new Map();
297
+ ctx.rustAmbiguity = new Map();
298
+ // A Python manifest is read only when the python adapter is on. An existence
299
+ // probe is part of the input hash, so probing one for every unit would change
300
+ // every graph that does not enable python.
301
+ const pythonOn = adapters.some((a) => a.name === "python");
288
302
  for (const unit of discovery.eligible) {
289
303
  const pkgPath = unit.root + "/package.json";
290
304
  if (repo.exists(pkgPath)) {
@@ -301,15 +315,68 @@ export function compile(root, opts = {}) {
301
315
  const m = /^module\s+(\S+)/m.exec(repo.readText(gomodPath));
302
316
  unit.goModule = m ? m[1] : null;
303
317
  }
318
+ if (rustOn) loadCargoUnit(repo, unit);
319
+ if (pythonOn) {
320
+ const py = readPythonUnit(repo, unit.root);
321
+ if (py.manifest) {
322
+ unit.pythonMarker = py.manifest;
323
+ unit.pythonManifest = py.manifest;
324
+ unit.pythonDist = py.dist;
325
+ unit.pythonImports = py.ambiguousImports ? null : py.importNames;
326
+ unit.pythonProdDeps = [...new Set([
327
+ ...py.prodDeps,
328
+ ...py.sourceDeps.filter((n) => !py.devDeps.includes(n) && !py.prodDeps.includes(n)),
329
+ ])].sort();
330
+ unit.pythonDevDeps = py.devDeps;
331
+ unit.pythonPathDeps = py.pathDeps;
332
+ if (py.error) res.errors.push(py.error);
333
+ }
334
+ }
304
335
  }
305
336
  const unitByPkgName = new Map();
306
337
  const unitByGoModule = new Map();
338
+ const unitByPythonDist = new Map();
339
+ const unitByPythonImport = new Map();
340
+ const distOwners = new Map();
341
+ const importOwners = new Map();
307
342
  for (const unit of discovery.eligible) {
308
343
  if (unit.pkgName && !unitByPkgName.has(unit.pkgName)) unitByPkgName.set(unit.pkgName, unit);
309
344
  if (unit.goModule && !unitByGoModule.has(unit.goModule)) unitByGoModule.set(unit.goModule, unit);
345
+ if (unit.pythonDist) {
346
+ if (!distOwners.has(unit.pythonDist)) distOwners.set(unit.pythonDist, []);
347
+ distOwners.get(unit.pythonDist).push(unit);
348
+ }
349
+ if (Array.isArray(unit.pythonImports)) {
350
+ for (const name of unit.pythonImports) {
351
+ if (!importOwners.has(name)) importOwners.set(name, []);
352
+ importOwners.get(name).push(unit);
353
+ }
354
+ }
355
+ }
356
+ for (const [name, owners] of distOwners) {
357
+ if (owners.length === 1) unitByPythonDist.set(name, owners[0]);
358
+ else for (const u of owners) u.pythonDistConflict = true;
359
+ }
360
+ for (const [name, owners] of importOwners) {
361
+ if (owners.length === 1) unitByPythonImport.set(name, owners[0]);
362
+ else for (const u of owners) u.pythonImportConflict = true;
310
363
  }
311
364
  ctx.unitByPkgName = unitByPkgName;
312
365
  ctx.unitByGoModule = unitByGoModule;
366
+ if (rustOn) {
367
+ const owners = new Map();
368
+ for (const unit of discovery.eligible) {
369
+ if (!unit.crateName) continue;
370
+ if (!owners.has(unit.crateName)) owners.set(unit.crateName, []);
371
+ owners.get(unit.crateName).push(unit);
372
+ }
373
+ for (const [name, list] of [...owners.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
374
+ if (list.length === 1) ctx.unitByCrateName.set(name, list[0]);
375
+ else ctx.ambiguousCrateNames.set(name, list.slice().sort((a, b) => (a.root < b.root ? -1 : 1)));
376
+ }
377
+ }
378
+ ctx.unitByPythonDist = unitByPythonDist;
379
+ ctx.unitByPythonImport = unitByPythonImport;
313
380
 
314
381
  // Manifest-declared unit dependencies, computed before any adapter runs so
315
382
  // that cross-unit reasoning never depends on adapter execution order.
@@ -317,6 +384,7 @@ export function compile(root, opts = {}) {
317
384
  // matches a dependency name rather than a blob of source.
318
385
  const productionDependencies = new Map();
319
386
  const dependencyMap = new Map();
387
+ const unitsByRoot = new Map(discovery.eligible.map((u) => [u.root, u]));
320
388
  for (const unit of discovery.eligible) {
321
389
  if (!unit.component) continue;
322
390
  const deps = new Set();
@@ -338,6 +406,32 @@ export function compile(root, opts = {}) {
338
406
  if (t && t.component && t.component.ref !== unit.component.ref) deps.add(t.component.ref);
339
407
  }
340
408
  }
409
+ if (rustOn && unit.cargoPackage) {
410
+ const cargo = analyzeCargoDeps(unit, unitsByRoot);
411
+ productionDependencies.set(unit.component.ref, [
412
+ ...(productionDependencies.get(unit.component.ref) || []),
413
+ ...cargo.prodNames,
414
+ ]);
415
+ for (const hit of cargo.resolved) {
416
+ if (hit.ambiguous || !hit.target || !hit.target.component) continue;
417
+ if (hit.target.component.ref !== unit.component.ref) deps.add(hit.target.component.ref);
418
+ }
419
+ }
420
+ if (unit.pythonMarker) {
421
+ const pyNames = [...new Set([...(unit.pythonProdDeps || []), ...(unit.pythonDevDeps || [])])];
422
+ productionDependencies.set(unit.component.ref, [
423
+ ...(productionDependencies.get(unit.component.ref) || []),
424
+ ...(unit.pythonProdDeps || []),
425
+ ]);
426
+ for (const name of pyNames) {
427
+ const t = unitByPythonDist.get(name);
428
+ if (t && t.component && t.component.ref !== unit.component.ref) deps.add(t.component.ref);
429
+ }
430
+ for (const p of unit.pythonPathDeps || []) {
431
+ const t = discovery.eligible.find((u) => u.root === p.dir);
432
+ if (t && t.component && t.component.ref !== unit.component.ref) deps.add(t.component.ref);
433
+ }
434
+ }
341
435
  dependencyMap.set(unit.component.ref, deps);
342
436
  }
343
437
  ctx.dependencyMap = dependencyMap;
@@ -942,9 +1036,13 @@ const GO_IMPORT_RE = /["`]([a-z0-9]+\.[a-z0-9.-]+\/[a-z0-9./_-]+|[a-z0-9]+\.[a-z
942
1036
  function buildImportIndex(repo, discovery) {
943
1037
  const byUnit = new Map();
944
1038
  const goPathsByUnit = new Map();
1039
+ const rustIdentsByUnit = new Map();
1040
+ const pythonImportsByUnit = new Map();
945
1041
  for (const unit of discovery.eligible) {
946
1042
  const map = new Map();
947
1043
  const goPaths = new Set();
1044
+ const rustIdents = new Map();
1045
+ const pyImports = new Map();
948
1046
  for (const f of sourceFiles(repo, unit.root, { test: false })) {
949
1047
  const specifiers = importSpecifiers(repo.readText(f));
950
1048
  for (const s of specifiers) {
@@ -958,11 +1056,26 @@ function buildImportIndex(repo, discovery) {
958
1056
  // importing it, with no require line at all.
959
1057
  for (const m of text.matchAll(GO_IMPORT_RE) ) goPaths.add(m[1]);
960
1058
  }
1059
+ if (unit.cargoPackage && f.endsWith(".rs")) {
1060
+ const text = repo.readText(f);
1061
+ for (const ident of rustIdentsInSource(text)) {
1062
+ if (!rustIdents.has(ident)) rustIdents.set(ident, new Set());
1063
+ rustIdents.get(ident).add(f);
1064
+ }
1065
+ }
1066
+ if (unit.pythonMarker && f.endsWith(".py")) {
1067
+ for (const name of pythonImportNames(repo.readText(f))) {
1068
+ if (!pyImports.has(name)) pyImports.set(name, new Set());
1069
+ pyImports.get(name).add(f);
1070
+ }
1071
+ }
961
1072
  }
962
1073
  byUnit.set(unit, map);
963
1074
  goPathsByUnit.set(unit, goPaths);
1075
+ rustIdentsByUnit.set(unit, rustIdents);
1076
+ pythonImportsByUnit.set(unit, pyImports);
964
1077
  }
965
- return { byUnit, goPathsByUnit };
1078
+ return { byUnit, goPathsByUnit, rustIdentsByUnit, pythonImportsByUnit };
966
1079
  }
967
1080
 
968
1081
  function mergeDerived(target, extra) {
@@ -995,7 +1108,7 @@ function validateOidcProviders({ repo, sources, discovery }) {
995
1108
  const roots = unit ? [unit.root] : [dirOf(provider.file)];
996
1109
  let found = false;
997
1110
  for (const root of roots) {
998
- for (const f of repo.walk(root)) {
1111
+ for (const f of repo.walkUnit(root)) {
999
1112
  if (!/\.(ts|tsx|js|mjs|jsx|py|go|rb|java|kt|rs)$/.test(f)) continue;
1000
1113
  if (/(^|\/)(test|tests|__tests__)\//.test(f) || /\.(test|spec)\./.test(f)) continue;
1001
1114
  if (repo.readText(f).includes(dp)) { found = true; break; }
@@ -0,0 +1,42 @@
1
+ // Change routing follows dependency edges in reverse. Normalize inverse edges
2
+ // here so query and impact agree even when the graph includes both spellings.
3
+ export function dependentScope(graph, seeds) {
4
+ const reverse = new Map();
5
+ for (const r of graph.relations) {
6
+ let dependency, dependent;
7
+ if (r.type === "buildDependsOn" || r.type === "testDependsOn") {
8
+ dependency = r.targetRef;
9
+ dependent = r.sourceRef;
10
+ } else if (r.type === "buildDependencyOf" || r.type === "testDependencyOf") {
11
+ dependency = r.sourceRef;
12
+ dependent = r.targetRef;
13
+ } else continue;
14
+ if (!reverse.has(dependency)) reverse.set(dependency, []);
15
+ reverse.get(dependency).push({ ref: dependent, unitOnly: r.type.startsWith("test") });
16
+ }
17
+
18
+ // false means all tiers; true means only unit checks. An all-build route
19
+ // upgrades a prior test-only route, including its downstream dependents.
20
+ const scope = new Map(seeds.map((ref) => [ref, false]));
21
+ const queue = [...scope.keys()];
22
+ for (let i = 0; i < queue.length; i++) {
23
+ const current = queue[i];
24
+ for (const edge of reverse.get(current) || []) {
25
+ const unitOnly = scope.get(current) || edge.unitOnly;
26
+ if (!scope.has(edge.ref) || (scope.get(edge.ref) && !unitOnly)) {
27
+ scope.set(edge.ref, unitOnly);
28
+ queue.push(edge.ref);
29
+ }
30
+ }
31
+ }
32
+ return scope;
33
+ }
34
+
35
+ export function verificationForChange(graph, seeds, priorityRefs = seeds) {
36
+ const own = new Set(priorityRefs);
37
+ const scope = dependentScope(graph, seeds);
38
+ const rank = (v) => v.componentRefs.some((ref) => own.has(ref)) ? 0 : 1;
39
+ return graph.verification
40
+ .filter((v) => v.componentRefs.some((ref) => scope.has(ref) && (!scope.get(ref) || v.tier === "unit")))
41
+ .sort((a, b) => rank(a) - rank(b) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
42
+ }
package/core/discover.mjs CHANGED
@@ -8,12 +8,22 @@ import path from "node:path";
8
8
  import { AgentDocError, CODES } from "./codes.mjs";
9
9
  import { expandGlob, globToRegExp, assertGlob } from "../core/fsx.mjs";
10
10
 
11
- // Directories that are never units. Overridable, because "infra" or "tools"
12
- // may be a legitimate unit in another project.
11
+ // Roots excluded from inference and scaffolding. Authored descriptors strictly
12
+ // below them can still claim units; descriptors at these exact roots cannot.
13
13
  export const DEFAULT_NOT_UNIT_ROOTS = ["agentdoc", "docs", "scripts", "node_modules", ".github"];
14
14
 
15
+ function notUnitRoots(cfg) {
16
+ return cfg?.discovery.notUnitRoots || DEFAULT_NOT_UNIT_ROOTS;
17
+ }
18
+
19
+ // Inference excludes the whole subtree, not similarly named siblings. CREATE
20
+ // mode shares this policy even for units with an existing authored descriptor.
21
+ export function isNotUnitRoot(root, cfg) {
22
+ return notUnitRoots(cfg)
23
+ .some((excluded) => root === excluded || root.startsWith(excluded + "/"));
24
+ }
25
+
15
26
  export function discoverUnits(repo, cfg, adapters) {
16
- const notUnit = new Set(cfg.discovery.notUnitRoots || DEFAULT_NOT_UNIT_ROOTS);
17
27
  const extraDeployable = new Set();
18
28
  const extraImportable = new Set();
19
29
  const units = new Map();
@@ -35,13 +45,17 @@ export function discoverUnits(repo, cfg, adapters) {
35
45
  assertGlob(g, "agentdoc config");
36
46
  for (const f of expandGlob(repo, g)) {
37
47
  const dir = path.posix.dirname(f);
38
- if (!notUnit.has(dir) && repo.isDir(dir)) {
48
+ if (repo.isDir(dir)) {
39
49
  candidates.add(dir);
40
50
  descriptorRoots.add(dir);
41
51
  }
42
52
  }
43
53
  }
44
- for (const r of cfg.discovery.supplementalRoots || []) candidates.add(r);
54
+ for (const r of cfg.discovery.supplementalRoots || []) {
55
+ candidates.add(r);
56
+ // The descriptor loader also reads these fixed paths outside the globs.
57
+ if (repo.exists(r + "/" + cfg.discovery.componentDescriptorName)) descriptorRoots.add(r);
58
+ }
45
59
 
46
60
  // Adapters may contribute roots the descriptor globs cannot know about: a
47
61
  // Go workspace member, a workspace file member with no descriptor yet. The
@@ -59,7 +73,10 @@ export function discoverUnits(repo, cfg, adapters) {
59
73
  }
60
74
 
61
75
  for (const root of [...candidates].sort()) {
62
- if (notUnit.has(root)) continue;
76
+ // Preserve explicit human claims below an exclusion, while keeping inferred
77
+ // roots out of the subtree and refusing descriptors at the exact root.
78
+ if (notUnitRoots(cfg).includes(root)) continue;
79
+ if (isNotUnitRoot(root, cfg) && !descriptorRoots.has(root)) continue;
63
80
  if (!repo.isDir(root)) continue;
64
81
  const u = get(root);
65
82
  u.explicit = (cfg.discovery.supplementalRoots || []).includes(root) || descriptorRoots.has(root);
@@ -72,13 +89,38 @@ export function discoverUnits(repo, cfg, adapters) {
72
89
  const shape = markerShape(marker);
73
90
  if (shape === "deployable" || shape === "both") u.deployable = true;
74
91
  if (shape === "importable" || shape === "both") u.importable = true;
92
+ // A manifest with no artifact shape is still a unit. deployable and
93
+ // importable stay false, so scaffold does not invent a service or a
94
+ // library. A descriptor is not required before the unit is recognised.
95
+ if (shape === "present") u.present = true;
75
96
  }
76
97
  }
77
98
  }
78
99
 
79
100
  const list = [...units.values()].sort((a, b) => (a.root < b.root ? -1 : 1));
80
- const eligible = list.filter((u) => u.deployable || u.importable || u.explicit);
81
- return { units: list, eligible };
101
+ const eligible = list.filter((u) => u.deployable || u.importable || u.explicit || u.present);
102
+ // Only eligible units create ownership boundaries. Record immediate children
103
+ // once; pruning one of them also prunes every deeper descendant. Normalise
104
+ // the repository root so descriptor roots (".") and workspace roots agree.
105
+ const nestedRoots = new Map(eligible.map((u) => [unitRoot(u.root), new Set()]));
106
+ for (const root of nestedRoots.keys()) {
107
+ if (!root) continue;
108
+ let parent = unitRoot(path.posix.dirname(root));
109
+ while (true) {
110
+ if (nestedRoots.has(parent)) {
111
+ nestedRoots.get(parent).add(root);
112
+ break;
113
+ }
114
+ if (!parent) break;
115
+ parent = unitRoot(path.posix.dirname(parent));
116
+ }
117
+ }
118
+ return { units: list, eligible, nestedRoots };
119
+ }
120
+
121
+ function unitRoot(root) {
122
+ const normalized = path.posix.normalize(root || ".");
123
+ return normalized === "." ? "" : normalized;
82
124
  }
83
125
 
84
126
  function markerShape(marker) {
@@ -93,7 +135,7 @@ export function descriptorDir(file) {
93
135
  }
94
136
 
95
137
  // Exactly one descriptor per eligible unit, and no descriptor outside one.
96
- export function matchComponents(eligible, componentEntities) {
138
+ export function matchComponents(eligible, componentEntities, cfg) {
97
139
  const errors = [];
98
140
  const byDir = new Map();
99
141
  for (const e of componentEntities) {
@@ -124,6 +166,15 @@ export function matchComponents(eligible, componentEntities) {
124
166
  for (const e of componentEntities) {
125
167
  const dir = descriptorDir(e.file);
126
168
  if (unitRoots.has(dir)) continue;
169
+ if (notUnitRoots(cfg).includes(dir)) {
170
+ errors.push(new AgentDocError(
171
+ CODES.COMPONENT_UNCORROBORATED,
172
+ "component descriptor at '" + dir + "' is in discovery.notUnitRoots (or a default not-unit root); " +
173
+ "remove it from notUnitRoots or move the descriptor",
174
+ { path: e.file, ref: e.ref }
175
+ ));
176
+ continue;
177
+ }
127
178
  // A descriptor is a human claim that a component exists. When no adapter
128
179
  // can corroborate a deployable or importable artifact in that source root,
129
180
  // the claim is unsupported. This is reported rather than ignored: it is
package/core/fsx.mjs CHANGED
@@ -59,6 +59,7 @@ export class Repo {
59
59
  this.probes = new Map();
60
60
  this.walks = new Map();
61
61
  this.listings = new Map();
62
+ this.nestedUnitRoots = new Map();
62
63
  this.generatedDirs = new Set(opts.generatedDirs || DEFAULT_GENERATED_DIRS);
63
64
  this.noGit = Boolean(opts.noGit);
64
65
  }
@@ -140,6 +141,22 @@ export class Repo {
140
141
  this.walks.set(relDir || ".", out);
141
142
  return out;
142
143
  }
144
+ // Per-unit scans use the discovery result, never filesystem heuristics.
145
+ // Keep walk() repository-wide for discovery, contracts and authored inputs.
146
+ ownsUnitPath(unitRoot, rel) {
147
+ const normalize = (p) => path.posix.normalize(p || ".").replace(/^\.$/, "");
148
+ const root = normalize(unitRoot);
149
+ const target = normalize(rel);
150
+ if (root && target !== root && !target.startsWith(root + "/")) return false;
151
+ for (const child of this.nestedUnitRoots.get(root) || []) {
152
+ if (target === child || target.startsWith(child + "/")) return false;
153
+ }
154
+ return true;
155
+ }
156
+ walkUnit(unitRoot, relDir = unitRoot, { exclude = () => false } = {}) {
157
+ if (!this.ownsUnitPath(unitRoot, relDir)) return [];
158
+ return this.walk(relDir, { exclude: (rel, ent) => !this.ownsUnitPath(unitRoot, rel) || exclude(rel, ent) });
159
+ }
143
160
  // Files under a path or a path *prefix*. A contract is often named by a module
144
161
  // prefix rather than a single file (`events.go`, `events/*.go`), and
145
162
  // requiring an exact directory would reject a perfectly good contract.
package/core/graph.mjs CHANGED
@@ -9,7 +9,7 @@ import { assertDeterministic, structuralProjection } from "./determinism.mjs";
9
9
  import { sha256Hex } from "./fsx.mjs";
10
10
 
11
11
  export const COMPILER_NAME = "agentdoc-compiler";
12
- export const COMPILER_VERSION = "1.1.0";
12
+ export const COMPILER_VERSION = "1.2.0";
13
13
  export const GRAPH_SCHEMA_VERSION = "agentdoc.dev/graph/v1";
14
14
 
15
15
  // Resolve symbolic provenance subjects to RFC 6901 pointers into the graph.