agent-doc-system 1.0.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 (68) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/README.md +97 -112
  3. package/adapters/ansible.mjs +306 -0
  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 +5 -2
  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 +60 -44
  17. package/bin/commands.mjs +169 -0
  18. package/bin/usage.txt +5 -3
  19. package/core/audit.mjs +55 -4
  20. package/core/authority/engine.mjs +27 -4
  21. package/core/codes.mjs +11 -0
  22. package/core/compile.mjs +263 -13
  23. package/core/dependents.mjs +42 -0
  24. package/core/descriptors.mjs +5 -2
  25. package/core/discover.mjs +60 -9
  26. package/core/fsx.mjs +27 -4
  27. package/core/graph.mjs +1 -1
  28. package/core/impact.mjs +55 -44
  29. package/core/normative.mjs +171 -0
  30. package/core/query.mjs +18 -8
  31. package/core/scaffold.mjs +87 -22
  32. package/core/sourcescan.mjs +11 -2
  33. package/core/toml.mjs +463 -140
  34. package/core/yaml.mjs +5 -3
  35. package/docs/ADAPTERS.md +232 -7
  36. package/docs/AUTHORITY.md +10 -1
  37. package/docs/BENCHMARK.md +68 -0
  38. package/docs/CI.md +3 -1
  39. package/docs/EVALS.md +33 -2
  40. package/docs/MIGRATION.md +76 -6
  41. package/docs/QUERY.md +53 -4
  42. package/docs/SPEC.md +22 -0
  43. package/docs/assets/readme/agentdoc-hero.webp +0 -0
  44. package/docs/assets/readme/agentdoc-in-action.webp +0 -0
  45. package/docs/assets/readme/how-agentdoc-works.webp +0 -0
  46. package/docs/assets/readme/why-agentdoc.webp +0 -0
  47. package/docs/ci/github-actions.yml +1 -1
  48. package/evals/harness.mjs +32 -6
  49. package/evals/scenarios/fixture-b-03-change-shared-lib.json +8 -1
  50. package/evals/scenarios/fixture-c-03-shared-vocabulary.json +7 -1
  51. package/evals/scenarios/fixture-e-01-change-shared-lib.json +14 -0
  52. package/evals/scenarios/fixture-e-02-change-manifest.json +9 -0
  53. package/evals/scenarios/fixture-e-03-local-module.json +9 -0
  54. package/evals/scenarios/fixture-e-04-ambiguous-crate.json +14 -0
  55. package/evals/scenarios/fixture-f-01-change-shared-lib.json +9 -0
  56. package/evals/scenarios/fixture-f-02-change-manifest.json +9 -0
  57. package/evals/scenarios/fixture-f-03-shadowed-import.json +9 -0
  58. package/evals/scenarios/fixture-f-04-nested-workspace.json +9 -0
  59. package/evals/scenarios/fixture-f-05-alembic-database.json +9 -0
  60. package/evals/scenarios/fixture-f-06-unittest-job.json +9 -0
  61. package/package.json +2 -1
  62. package/schemas/common.schema.json +40 -0
  63. package/schemas/component.schema.json +5 -1
  64. package/schemas/config.schema.json +117 -1
  65. package/skills/agent-doc-system/SKILL.md +5 -1
  66. package/skills/agent-doc-system/references/cli.md +12 -4
  67. package/skills/agent-doc-system/references/create-migrate.md +28 -6
  68. package/templates/ci.yml +1 -1
package/core/compile.mjs CHANGED
@@ -17,8 +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";
22
+ import {
23
+ loadNormativeDocuments, verifyCitation, normativeCoverage, isNormative, isRequirementKey, NORMATIVE_SEMANTICS_PREFIX,
24
+ } from "./normative.mjs";
21
25
  import { loadAdapters } from "../adapters/index.mjs";
26
+ import { loadCargoUnit, analyzeCargoDeps, rustIdentsInSource } from "../adapters/rust.mjs";
22
27
  import {
23
28
  COMPILER_NAME, COMPILER_VERSION, GRAPH_SCHEMA_VERSION,
24
29
  assembleGraph, serializeGraph, finalizeSerialization, subjectResolver,
@@ -115,16 +120,18 @@ export function compile(root, opts = {}) {
115
120
  evidence: o.evidence.map((ev) => ({ path: ev.path, sha256: ev.sha256 })),
116
121
  });
117
122
 
123
+ // ── normative requirements ───────────────────────────────────────────────
124
+ // Checked before discovery so a stale citation is reported alongside every
125
+ // other authored-input error rather than hiding behind a coverage failure.
126
+ const normative = loadNormative(repo, cfg, sources, overrides.overrides);
127
+ res.errors.push(...normative.errors);
128
+ res.normative = { sections: normative.sections, uncovered: normative.uncovered, requirements: normative.requirements };
129
+
118
130
  res.errors.push(...checkContractCoverage({ repo, cfg, sources }));
119
131
 
120
132
  for (const api of sources.entities.filter((e) => e.kind === "API")) {
121
133
  collect(() => validateContractFile(repo, bundle, api), res.errors);
122
134
  }
123
- res.errors.push(...validateOidcProviders({ repo, sources, discovery: null }));
124
-
125
- if (res.errors.length) {
126
- return earlyOut(res, { entities: sources.entities.length });
127
- }
128
135
 
129
136
  let adapters;
130
137
  try {
@@ -137,16 +144,22 @@ export function compile(root, opts = {}) {
137
144
  throw e;
138
145
  }
139
146
  const discovery = discoverUnits(repo, cfg, adapters);
147
+ repo.nestedUnitRoots = discovery.nestedRoots;
140
148
  // Eligibility is the compiler's own answer to "which directories are units" —
141
149
  // exposed on the result so scaffold can write descriptors for exactly these
142
150
  // roots rather than re-deriving them from filesystem markers.
143
151
  res.discovery = discovery;
144
- const match = matchComponents(discovery.eligible, sources.entities.filter((e) => e.kind === "Component"));
145
- res.errors.push(...match.errors);
146
152
 
147
- // OIDC discovery must be exposed by the provider's own source. Re-check now
148
- // 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.
149
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);
150
163
 
151
164
  if (res.errors.length) {
152
165
  return earlyOut(res, { entities: sources.entities.length, units: discovery.eligible.length });
@@ -165,6 +178,9 @@ export function compile(root, opts = {}) {
165
178
  repo, cfg, refs, bundle, prov, diagnostics, facts,
166
179
  sources: { ...sources, journeys: journeys.journeys }, discovery, journeys: journeys.journeys, journeysPath: journeys.path,
167
180
  observations, overrides: overrides.overrides,
181
+ // Declared normative requirements, so an adapter named in `satisfiedBy`
182
+ // can derive the implementation side on the same key.
183
+ requirements: normative.requirements,
168
184
  edges, events, externals, capabilities, verification,
169
185
  errors: [],
170
186
  unitByComponent: new Map(),
@@ -272,6 +288,17 @@ export function compile(root, opts = {}) {
272
288
  // ── manifest pre-pass: identify each unit's package / module namespace ────
273
289
  // Done before extraction so cross-unit resolution never depends on the order
274
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");
275
302
  for (const unit of discovery.eligible) {
276
303
  const pkgPath = unit.root + "/package.json";
277
304
  if (repo.exists(pkgPath)) {
@@ -288,15 +315,68 @@ export function compile(root, opts = {}) {
288
315
  const m = /^module\s+(\S+)/m.exec(repo.readText(gomodPath));
289
316
  unit.goModule = m ? m[1] : null;
290
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
+ }
291
335
  }
292
336
  const unitByPkgName = new Map();
293
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();
294
342
  for (const unit of discovery.eligible) {
295
343
  if (unit.pkgName && !unitByPkgName.has(unit.pkgName)) unitByPkgName.set(unit.pkgName, unit);
296
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;
297
363
  }
298
364
  ctx.unitByPkgName = unitByPkgName;
299
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;
300
380
 
301
381
  // Manifest-declared unit dependencies, computed before any adapter runs so
302
382
  // that cross-unit reasoning never depends on adapter execution order.
@@ -304,6 +384,7 @@ export function compile(root, opts = {}) {
304
384
  // matches a dependency name rather than a blob of source.
305
385
  const productionDependencies = new Map();
306
386
  const dependencyMap = new Map();
387
+ const unitsByRoot = new Map(discovery.eligible.map((u) => [u.root, u]));
307
388
  for (const unit of discovery.eligible) {
308
389
  if (!unit.component) continue;
309
390
  const deps = new Set();
@@ -325,6 +406,32 @@ export function compile(root, opts = {}) {
325
406
  if (t && t.component && t.component.ref !== unit.component.ref) deps.add(t.component.ref);
326
407
  }
327
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
+ }
328
435
  dependencyMap.set(unit.component.ref, deps);
329
436
  }
330
437
  ctx.dependencyMap = dependencyMap;
@@ -436,6 +543,21 @@ export function compile(root, opts = {}) {
436
543
  }
437
544
  }
438
545
 
546
+ // A requirement is an AUTHORED claim that the implementation must make true.
547
+ // It is a fact like any other so the authority engine compares it with the
548
+ // implementation evidence on the same key — and it carries its citation, so
549
+ // every report can say which section of which document it came from.
550
+ for (const r of normative.requirements) {
551
+ facts.add({
552
+ subject: r.subject, key: r.key, value: r.value, evidenceClass: "AUTHORED", confidence: "declared",
553
+ provRecs: [
554
+ prov.add("declared", r.file, "requirement-extractor", ["ass:pending"]),
555
+ prov.add("declared", r.source.path, "requirement-extractor", ["ass:pending"]),
556
+ ],
557
+ semantics: NORMATIVE_SEMANTICS_PREFIX + r.source.path + "#" + r.source.section,
558
+ });
559
+ }
560
+
439
561
  // ── reviewed overrides → relations and facts ─────────────────────────────
440
562
  const overrideEdges = [];
441
563
  for (const o of overrides.overrides) {
@@ -503,6 +625,13 @@ export function compile(root, opts = {}) {
503
625
  evidenceClass: "REVIEWED_OVERRIDE", confidence: "reviewed", provRecs: provs, semantics: o.reason,
504
626
  review: reviewed(o),
505
627
  });
628
+ } else if (o.fact === "requirement-satisfaction") {
629
+ facts.add({
630
+ subject: o.subject, key: o.key, value: o.value,
631
+ evidenceClass: "REVIEWED_OVERRIDE", confidence: "reviewed", provRecs: provs,
632
+ semantics: o.reason,
633
+ review: reviewed(o),
634
+ });
506
635
  } else if (o.fact === "component-placement") {
507
636
  const key = o.system ? "placement.system" : "placement.domain";
508
637
  facts.add({
@@ -557,6 +686,7 @@ export function compile(root, opts = {}) {
557
686
 
558
687
  const { conflicts: rawConflicts } = resolveAssertions(facts.facts, authority);
559
688
  pushConflictDiagnostics(rawConflicts, diagnostics, facts);
689
+ pushNormativeDiagnostics(normative, facts, adapters, diagnostics);
560
690
 
561
691
  // ── relations ────────────────────────────────────────────────────────────
562
692
  const rel = buildRelations(ctx);
@@ -753,7 +883,13 @@ function pushConflictDiagnostics(rawConflicts, diagnostics, facts) {
753
883
  .filter(Boolean)
754
884
  .map((f) => f.evidenceClass + "=" + JSON.stringify(f.value))
755
885
  .sort();
756
- const detail = c.subject + " " + c.key + ": " + values.join(" vs ");
886
+ let detail = c.subject + " " + c.key + ": " + values.join(" vs ");
887
+ // For a requirement, say where each side comes from: the cited section and
888
+ // what the extractor actually found. Two bare values send a reader hunting.
889
+ if (isRequirementKey(c.key)) {
890
+ const why = c.assertionIds.map((id) => byId.get(id)).filter((f) => f && f.semantics).map((f) => f.semantics).sort();
891
+ if (why.length) detail += " (" + why.join("; ") + ")";
892
+ }
757
893
  if (c.kind === "ambiguity" && c.status === "unresolved") {
758
894
  diagnostics.push({
759
895
  severity: "warning",
@@ -775,7 +911,7 @@ function pushConflictDiagnostics(rawConflicts, diagnostics, facts) {
775
911
  message:
776
912
  "unresolved " + c.kind + " — " + detail + ". " +
777
913
  (c.election.rationale || "no authority rule governs this fact") +
778
- ". Add an authority rule and, if deterministic discovery cannot settle it, a reviewed override.",
914
+ (isRequirementKey(c.key) ? "." : ". Add an authority rule and, if deterministic discovery cannot settle it, a reviewed override."),
779
915
  });
780
916
  } else {
781
917
  diagnostics.push({
@@ -794,6 +930,101 @@ function pushConflictDiagnostics(rawConflicts, diagnostics, facts) {
794
930
  }
795
931
  }
796
932
 
933
+ // Normative documents, the requirements components declare against them, and
934
+ // which sections nobody has accounted for. Every citation is re-verified
935
+ // against the section's current digest: a requirement or an attestation
936
+ // written against an older wording of the specification is a review that has
937
+ // not happened yet.
938
+ function loadNormative(repo, cfg, sources, overrides) {
939
+ const errors = [];
940
+ const out = { errors, sections: [], uncovered: [], requirements: [] };
941
+ let docs;
942
+ try {
943
+ docs = loadNormativeDocuments(repo, cfg);
944
+ } catch (e) {
945
+ if (!(e instanceof AgentDocError)) throw e;
946
+ errors.push(e);
947
+ return out;
948
+ }
949
+ for (const e of sources.entities) {
950
+ if (e.kind !== "Component") continue;
951
+ const seen = new Set();
952
+ for (const r of e.doc.spec.requirements || []) {
953
+ collect(() => {
954
+ if (seen.has(r.id) || seen.has(r.key)) {
955
+ throw new AgentDocError(CODES.CONFIG, "duplicate requirement id or key on " + e.ref + ": " + r.id + " / " + r.key, { path: e.file, ref: e.ref });
956
+ }
957
+ seen.add(r.id);
958
+ seen.add(r.key);
959
+ verifyCitation(docs, r.source, e.file, e.ref);
960
+ out.requirements.push({ ...r, subject: e.ref, file: e.file, satisfiedBy: r.satisfiedBy || [] });
961
+ }, errors);
962
+ }
963
+ }
964
+ // A satisfaction override must answer a requirement someone declared. One
965
+ // that answers nothing would be an implementation claim with no obligation
966
+ // behind it, which no report could ever surface.
967
+ const declared = new Set(out.requirements.map((r) => r.subject + "\u0000" + r.key));
968
+ for (const o of overrides) {
969
+ if (o.fact !== "requirement-satisfaction") continue;
970
+ if (!declared.has(o.subject + "\u0000" + o.key)) {
971
+ errors.push(new AgentDocError(
972
+ CODES.CONFIG,
973
+ "requirement-satisfaction override answers no declared requirement: " + o.subject + " " + o.key,
974
+ { path: CONFIG_PATH, ref: o.subject }
975
+ ));
976
+ }
977
+ }
978
+ const cov = normativeCoverage(docs, out.requirements, cfg.normativeCoverage || [], errors);
979
+ out.sections = cov.sections;
980
+ out.uncovered = cov.uncovered;
981
+ return out;
982
+ }
983
+
984
+ function pushNormativeDiagnostics(normative, facts, adapters, diagnostics) {
985
+ for (const s of normative.uncovered) {
986
+ const id = s.path + "#" + s.section;
987
+ diagnostics.push({
988
+ severity: "warning",
989
+ code: CODES.NORMATIVE_UNCOVERED,
990
+ subject: id,
991
+ paths: [s.path],
992
+ refs: [],
993
+ message:
994
+ "normative section " + id + " (sha256 " + s.sha256 + ") is routed but not reviewed: " +
995
+ "declare the requirement it places on a component (spec.requirements), or attest in normativeCoverage " +
996
+ "that it carries no implementation obligation.",
997
+ });
998
+ }
999
+ const enabled = new Set(adapters.map((a) => a.name));
1000
+ for (const r of normative.requirements) {
1001
+ const group = facts.facts.filter((f) => f.subject === r.subject && f.key === r.key && f.status !== "superseded");
1002
+ if (group.some((f) => !isNormative(f) && f.confidence !== "candidate")) continue;
1003
+ const candidate = group.find((f) => !isNormative(f));
1004
+ const missing = r.satisfiedBy.filter((a) => !enabled.has(a));
1005
+ const why = candidate
1006
+ ? "only a heuristic candidate exists, which is never elected — " + (candidate.semantics || JSON.stringify(candidate.value))
1007
+ : !r.satisfiedBy.length
1008
+ ? "no adapter is named as able to derive it"
1009
+ : missing.length
1010
+ ? "the adapter able to derive it is not enabled or not available: " + missing.join(", ")
1011
+ : !r.expects
1012
+ ? "the requirement has no `expects`, so " + r.satisfiedBy.join(", ") + " has nothing to look for"
1013
+ : "the enabled adapter (" + r.satisfiedBy.join(", ") + ") derived no evidence for it";
1014
+ diagnostics.push({
1015
+ severity: "warning",
1016
+ code: CODES.REQUIREMENT_UNVERIFIED,
1017
+ subject: r.subject + "#" + r.key,
1018
+ paths: [r.source.path],
1019
+ refs: [r.subject],
1020
+ message:
1021
+ "requirement " + r.key + " = " + r.value + " on " + r.subject + " (from " + r.source.path + "#" + r.source.section +
1022
+ ") has no implementation evidence; " + why + ". Add a requirement-satisfaction reviewed override citing the " +
1023
+ "implementation, or accept the gap with review.",
1024
+ });
1025
+ }
1026
+ }
1027
+
797
1028
  // A Go import is either host.tld/owner/repo/path or host.tld/owner/repo. The
798
1029
  // second form is what a Go *workspace* member uses, and it has only two dots —
799
1030
  // a pattern that requires three silently loses every workspace-only dependency.
@@ -805,9 +1036,13 @@ const GO_IMPORT_RE = /["`]([a-z0-9]+\.[a-z0-9.-]+\/[a-z0-9./_-]+|[a-z0-9]+\.[a-z
805
1036
  function buildImportIndex(repo, discovery) {
806
1037
  const byUnit = new Map();
807
1038
  const goPathsByUnit = new Map();
1039
+ const rustIdentsByUnit = new Map();
1040
+ const pythonImportsByUnit = new Map();
808
1041
  for (const unit of discovery.eligible) {
809
1042
  const map = new Map();
810
1043
  const goPaths = new Set();
1044
+ const rustIdents = new Map();
1045
+ const pyImports = new Map();
811
1046
  for (const f of sourceFiles(repo, unit.root, { test: false })) {
812
1047
  const specifiers = importSpecifiers(repo.readText(f));
813
1048
  for (const s of specifiers) {
@@ -821,11 +1056,26 @@ function buildImportIndex(repo, discovery) {
821
1056
  // importing it, with no require line at all.
822
1057
  for (const m of text.matchAll(GO_IMPORT_RE) ) goPaths.add(m[1]);
823
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
+ }
824
1072
  }
825
1073
  byUnit.set(unit, map);
826
1074
  goPathsByUnit.set(unit, goPaths);
1075
+ rustIdentsByUnit.set(unit, rustIdents);
1076
+ pythonImportsByUnit.set(unit, pyImports);
827
1077
  }
828
- return { byUnit, goPathsByUnit };
1078
+ return { byUnit, goPathsByUnit, rustIdentsByUnit, pythonImportsByUnit };
829
1079
  }
830
1080
 
831
1081
  function mergeDerived(target, extra) {
@@ -858,7 +1108,7 @@ function validateOidcProviders({ repo, sources, discovery }) {
858
1108
  const roots = unit ? [unit.root] : [dirOf(provider.file)];
859
1109
  let found = false;
860
1110
  for (const root of roots) {
861
- for (const f of repo.walk(root)) {
1111
+ for (const f of repo.walkUnit(root)) {
862
1112
  if (!/\.(ts|tsx|js|mjs|jsx|py|go|rb|java|kt|rs)$/.test(f)) continue;
863
1113
  if (/(^|\/)(test|tests|__tests__)\//.test(f) || /\.(test|spec)\./.test(f)) continue;
864
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
+ }
@@ -15,7 +15,7 @@ import fs from "node:fs";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { createHash } from "node:crypto";
17
17
  import { AgentDocError, CODES, collect } from "./codes.mjs";
18
- import { assertRepoPath, expandGlob, sha256Hex } from "./fsx.mjs";
18
+ import { assertGlob, assertRepoPath, expandGlob, sha256Hex } from "./fsx.mjs";
19
19
  import { parseYamlDocuments } from "./yaml.mjs";
20
20
  import { scanForSecrets } from "./secrets.mjs";
21
21
  import { formatErrors } from "./jsonschema.mjs";
@@ -160,10 +160,13 @@ export function loadConfig(repo, bundle) {
160
160
  cfg.output.graph, cfg.output.observations,
161
161
  ...(cfg.discovery.supplementalRoots || []),
162
162
  ...(cfg.discovery.contractRoots || []),
163
- ...(cfg.docs ? Object.values(cfg.docs) : []),
163
+ ...(cfg.docs ? Object.entries(cfg.docs).filter(([k]) => k !== "normative").map(([, v]) => v) : []),
164
164
  ].filter(Boolean)) {
165
165
  assertRepoPath(p, CONFIG_PATH);
166
166
  }
167
+ // `docs.normative` holds globs, not paths: the only role that names a set of
168
+ // documents rather than one.
169
+ for (const g of (cfg.docs && cfg.docs.normative) || []) assertGlob(g, CONFIG_PATH);
167
170
  return cfg;
168
171
  }
169
172
 
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.
@@ -175,16 +192,17 @@ export class Repo {
175
192
  return names;
176
193
  }
177
194
 
178
- git(args) {
195
+ git(args, { trim = true } = {}) {
179
196
  // stderr must be piped, not inherited: every caller treats a non-zero exit
180
197
  // as a signal to degrade (unborn HEAD, detached worktree, missing remote),
181
198
  // and execFileSync would otherwise print git's own "fatal:" line to the
182
199
  // user's terminal while the command reports success.
183
- return execFileSync("git", args, {
200
+ const out = execFileSync("git", args, {
184
201
  cwd: this.root,
185
202
  encoding: "utf8",
186
203
  stdio: ["ignore", "pipe", "pipe"],
187
- }).trim();
204
+ });
205
+ return trim ? out.trim() : out;
188
206
  }
189
207
  hasGit() {
190
208
  if (this.noGit) return false;
@@ -239,7 +257,12 @@ export class Repo {
239
257
  if (this.noGit || !this.hasGit()) return [];
240
258
  let raw;
241
259
  try {
242
- raw = this.git(["status", "--porcelain", "-z", "--untracked-files=all"]);
260
+ // Untrimmed: a porcelain record is `XY <path>` with fixed columns, and an
261
+ // unstaged change has X = " ". Trimming would eat that space from the
262
+ // first record, so `.slice(3)` would drop the path's first character —
263
+ // ` M .agentdoc/graph.json` became `agentdoc/graph.json`, slipped past
264
+ // the output exclusion and counted as a dirty input (issue #11).
265
+ raw = this.git(["status", "--porcelain", "-z", "--untracked-files=all"], { trim: false });
243
266
  } catch {
244
267
  return [];
245
268
  }