agent-doc-system 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +402 -0
  2. package/README.md +38 -0
  3. package/adapters/ansible.mjs +1 -1
  4. package/adapters/cache.mjs +9 -2
  5. package/adapters/contract-consumers.mjs +1 -1
  6. package/adapters/database.mjs +19 -4
  7. package/adapters/deploy-config.mjs +3 -2
  8. package/adapters/dockerfile.mjs +3 -2
  9. package/adapters/events.mjs +1 -1
  10. package/adapters/generic.mjs +2 -1
  11. package/adapters/github-actions.mjs +245 -85
  12. package/adapters/go.mjs +5 -4
  13. package/adapters/index.mjs +3 -1
  14. package/adapters/node.mjs +3 -2
  15. package/adapters/object-store.mjs +2 -2
  16. package/adapters/python.mjs +1142 -0
  17. package/adapters/resource-client.mjs +1 -1
  18. package/adapters/rust.mjs +990 -0
  19. package/adapters/turborepo.mjs +3 -2
  20. package/adapters/typescript.mjs +7 -5
  21. package/adapters/wrangler.mjs +3 -2
  22. package/bin/agentdoc.mjs +11 -4
  23. package/bin/usage.txt +2 -0
  24. package/core/audit.mjs +12 -8
  25. package/core/codes.mjs +4 -0
  26. package/core/compile.mjs +131 -16
  27. package/core/dependents.mjs +42 -0
  28. package/core/derive.mjs +4 -3
  29. package/core/descriptors.mjs +3 -2
  30. package/core/discover.mjs +61 -9
  31. package/core/fsx.mjs +185 -36
  32. package/core/graph.mjs +1 -1
  33. package/core/impact.mjs +66 -48
  34. package/core/paths.mjs +71 -0
  35. package/core/query.mjs +76 -31
  36. package/core/scaffold.mjs +216 -44
  37. package/core/sourcescan.mjs +14 -2
  38. package/core/toml.mjs +463 -140
  39. package/docs/ADAPTERS.md +196 -6
  40. package/docs/BENCHMARK.md +130 -0
  41. package/docs/CI.md +7 -1
  42. package/docs/EVALS.md +239 -16
  43. package/docs/MIGRATION.md +26 -6
  44. package/docs/PROVENANCE.md +6 -0
  45. package/docs/QUERY.md +78 -7
  46. package/docs/SPEC.md +69 -2
  47. package/docs/ci/github-actions.yml +1 -1
  48. package/evals/baseline-search.mjs +272 -0
  49. package/evals/facts.mjs +28 -0
  50. package/evals/harness.mjs +149 -119
  51. package/evals/scenarios/fixture-b-03-change-shared-lib.json +8 -1
  52. package/evals/scenarios/fixture-c-03-shared-vocabulary.json +7 -1
  53. package/evals/scenarios/fixture-e-01-change-shared-lib.json +14 -0
  54. package/evals/scenarios/fixture-e-02-change-manifest.json +9 -0
  55. package/evals/scenarios/fixture-e-03-local-module.json +9 -0
  56. package/evals/scenarios/fixture-e-04-ambiguous-crate.json +14 -0
  57. package/evals/scenarios/fixture-f-01-change-shared-lib.json +9 -0
  58. package/evals/scenarios/fixture-f-02-change-manifest.json +9 -0
  59. package/evals/scenarios/fixture-f-03-shadowed-import.json +9 -0
  60. package/evals/scenarios/fixture-f-04-nested-workspace.json +9 -0
  61. package/evals/scenarios/fixture-f-05-alembic-database.json +9 -0
  62. package/evals/scenarios/fixture-f-06-unittest-job.json +9 -0
  63. package/package.json +5 -2
  64. package/skills/agent-doc-system/SKILL.md +2 -1
  65. package/skills/agent-doc-system/references/cli.md +6 -1
  66. package/skills/agent-doc-system/references/create-migrate.md +21 -5
  67. package/templates/ci.yml +1 -1
@@ -4,6 +4,7 @@
4
4
  // verification answer available for a monorepo unit.
5
5
  import { Adapter } from "./registry.mjs";
6
6
  import { parseYamlDocuments } from "../core/yaml.mjs";
7
+ import { isRepoRoot, joinPath } from "../core/paths.mjs";
7
8
 
8
9
  // Whether a declared task is a check is decided by `taskTier` alone, not by a
9
10
  // second, narrower pattern. Two lists meant two places to disagree: a task that
@@ -19,7 +20,7 @@ export class TurborepoAdapter extends Adapter {
19
20
  super({ name: "turborepo", version: "1.0.0", kind: "ci" });
20
21
  }
21
22
  detect(ctx, root) {
22
- const p = root + "/turbo.json";
23
+ const p = joinPath(root, "turbo.json");
23
24
  if (!ctx.repo.exists(p)) return null;
24
25
  return { path: p, shape: "importable" };
25
26
  }
@@ -122,7 +123,7 @@ function taskTier(t) {
122
123
 
123
124
  // A task graph normally lives at the workspace root, not in each package.
124
125
  function nearestTurboConfig(repo, root) {
125
- let dir = root;
126
+ let dir = isRepoRoot(root) ? "" : root;
126
127
  for (let i = 0; i < 12; i++) {
127
128
  const p = dir ? dir + "/turbo.json" : "turbo.json";
128
129
  if (repo.exists(p)) return p;
@@ -6,6 +6,7 @@
6
6
  import path from "node:path";
7
7
  import { Adapter } from "./registry.mjs";
8
8
  import { parseYamlDocuments } from "../core/yaml.mjs";
9
+ import { joinPath } from "../core/paths.mjs";
9
10
 
10
11
  export class TypeScriptAdapter extends Adapter {
11
12
  static adapterName = "typescript";
@@ -13,13 +14,13 @@ export class TypeScriptAdapter extends Adapter {
13
14
  super({ name: "typescript", version: "1.0.0", kind: "unit-marker" });
14
15
  }
15
16
  detect(ctx, root) {
16
- const p = root + "/tsconfig.json";
17
+ const p = joinPath(root, "tsconfig.json");
17
18
  if (!ctx.repo.exists(p)) return null;
18
19
  return { path: p, shape: "importable" };
19
20
  }
20
21
  extract(ctx, unit) {
21
22
  const { repo, prov, compRef } = ctx;
22
- const p = unit.root + "/tsconfig.json";
23
+ const p = joinPath(unit.root, "tsconfig.json");
23
24
  if (!repo.exists(p)) return {};
24
25
  const derived = { languages: new Set(["typescript"]), runtimes: new Set(["node"]) };
25
26
  let ts;
@@ -43,8 +44,9 @@ export class TypeScriptAdapter extends Adapter {
43
44
  // real HTTP surface and must be visible to the query surface.
44
45
  const routes = [];
45
46
  for (const base of ["src/app", "app", "src/pages", "pages"]) {
46
- if (!repo.isDir(unit.root + "/" + base)) continue;
47
- for (const f of repo.walk(unit.root + "/" + base)) {
47
+ const dir = joinPath(unit.root, base);
48
+ if (!repo.isDir(dir)) continue;
49
+ for (const f of repo.walkUnit(unit.root, dir)) {
48
50
  const routePath = routeFor(unit.root, base, f);
49
51
  if (!routePath) continue;
50
52
  const rpr = prov.add("observed", f, "route-extractor", ["der:" + compRef + ":/routes"]);
@@ -63,7 +65,7 @@ export class TypeScriptAdapter extends Adapter {
63
65
  }
64
66
 
65
67
  function routeFor(root, base, file) {
66
- const rel = file.slice((root + "/" + base).length + 1);
68
+ const rel = file.slice(joinPath(root, base).length + 1);
67
69
  if (base.endsWith("app")) {
68
70
  if (!rel.endsWith("/route.ts") && !rel.endsWith("/route.tsx") && !rel.endsWith("/route.js")) return null;
69
71
  const segs = rel.split("/").slice(0, -1);
@@ -11,6 +11,7 @@
11
11
  import { Adapter } from "./registry.mjs";
12
12
  import { parse as parseToml } from "../core/toml.mjs";
13
13
  import { sourceFiles } from "../core/sourcescan.mjs";
14
+ import { joinPath } from "../core/paths.mjs";
14
15
 
15
16
  const MANIFESTS = ["wrangler.toml", "wrangler.json", "wrangler.jsonc"];
16
17
 
@@ -21,13 +22,13 @@ export class WranglerAdapter extends Adapter {
21
22
  }
22
23
  detect(ctx, root) {
23
24
  for (const n of MANIFESTS) {
24
- if (ctx.repo.exists(root + "/" + n)) return { path: root + "/" + n, shape: "deployable" };
25
+ if (ctx.repo.exists(joinPath(root, n))) return { path: joinPath(root, n), shape: "deployable" };
25
26
  }
26
27
  return null;
27
28
  }
28
29
  extract(ctx, unit) {
29
30
  const { repo, prov, compRef } = ctx;
30
- const file = MANIFESTS.map((n) => unit.root + "/" + n).find((p) => repo.exists(p));
31
+ const file = MANIFESTS.map((n) => joinPath(unit.root, n)).find((p) => repo.exists(p));
31
32
  if (!file) return {};
32
33
  let toml;
33
34
  try {
package/bin/agentdoc.mjs CHANGED
@@ -19,7 +19,7 @@ import { observationFreshness, stalenessDiagnostics } from "../core/observations
19
19
  import { assertNoSecretsInText } from "../core/secrets.mjs";
20
20
  import { installTemplates } from "../core/scaffold.mjs";
21
21
  import { COMMANDS, flagsOf, parseArgs, commandHelp, UsageError } from "./commands.mjs";
22
- import { loadSchemaBundle, loadConfig } from "../core/descriptors.mjs";
22
+ import { loadSchemaBundle, loadConfig, CONFIG_PATH } from "../core/descriptors.mjs";
23
23
  import { execFileSync } from "node:child_process";
24
24
 
25
25
  const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -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
@@ -353,7 +353,14 @@ try {
353
353
  : {}),
354
354
  }, true);
355
355
  } else if (cmd === "init") {
356
- const result = installTemplates(null, null, { write: true, root: ROOT, force: opt("force") });
356
+ // An existing configuration is the authority on where documentation lives
357
+ // (issue #49). Load it through the compiler's own loader, so init, scaffold,
358
+ // audit and compile cannot disagree, and so an invalid or escaping docs path
359
+ // is rejected before anything is written.
360
+ const docsConfig = fs.existsSync(path.join(ROOT, CONFIG_PATH))
361
+ ? loadConfig(new Repo(ROOT, { noGit: true }), loadSchemaBundle().bundle)
362
+ : null;
363
+ const result = installTemplates(null, null, { write: true, root: ROOT, force: opt("force"), docsConfig });
357
364
  out(result, true);
358
365
  } else if (cmd === "eval") {
359
366
  const which = positional[0] || "routing";
package/bin/usage.txt CHANGED
@@ -5,6 +5,8 @@ agentdoc — portable documentation & software-catalog system
5
5
  agentdoc <command> --help every flag one command accepts (also: agentdoc help <command>)
6
6
 
7
7
  agentdoc init [--force] write a configuration and documentation skeleton
8
+ (docs skeleton goes to the configured docs.* paths;
9
+ agentdoc/docs/ when there is no configuration yet)
8
10
  agentdoc audit inventory the repository, classify every fact
9
11
  agentdoc scaffold [--write] generate a CREATE-mode skeleton from the audit
10
12
  agentdoc validate sources + schemas + semantic checks
package/core/audit.mjs CHANGED
@@ -19,6 +19,7 @@ import { classifyContractFile } from "./contracts.mjs";
19
19
  import { ContextIndex } from "./query.mjs";
20
20
  import { expandGlob, isRepoPath } from "./fsx.mjs";
21
21
  import { isNormative, NORMATIVE_SEMANTICS_PREFIX } from "./normative.mjs";
22
+ import { isRepoRoot, longestOwner } from "./paths.mjs";
22
23
 
23
24
  const DOC_EXT = /\.(md|mdx|adoc|rst|txt)$/;
24
25
 
@@ -143,11 +144,12 @@ export function audit(repo, cfg, res) {
143
144
  configured.push({ role, path: dir, configured: false });
144
145
  }
145
146
  }
146
- const unitRoots = new Set(res.discovery.units.map((u) => u.root));
147
- const insideComponent = (p) => {
148
- for (const r of unitRoots) if (p === r || p.startsWith(r + "/")) return r;
149
- return null;
150
- };
147
+ // The most specific unit root containing the path. A root unit is left out
148
+ // on purpose: it contains the whole repository, so counting it would make
149
+ // every repository-wide hierarchy document "component-scoped" and the
150
+ // distinction this check draws would disappear.
151
+ const unitRoots = [...new Set(res.discovery.units.map((u) => u.root))].filter((r) => !isRepoRoot(r));
152
+ const insideComponent = (p) => longestOwner(unitRoots, p);
151
153
 
152
154
  // The audit reports; it does not gate. These are therefore collected as audit
153
155
  // findings on the report rather than pushed into the compile-time diagnostics,
@@ -160,7 +162,9 @@ export function audit(repo, cfg, res) {
160
162
  if (!DOC_EXT.test(f)) continue;
161
163
  const owners = graph.entities.filter((e) => {
162
164
  const d = e.derived || {};
163
- return (d.docs || []).includes(f) || (d.constraints || []).includes(f) || (d.runbooks || []).includes(f);
165
+ const context = e.entity.spec?.context || {};
166
+ return [d, context].some((c) =>
167
+ ["docs", "constraints", "runbooks"].some((key) => (c[key] || []).includes(f)));
164
168
  }).map((e) => e.ref);
165
169
  const role = (configured.find((h) => h.path === f) || {}).role || null;
166
170
  docInventory.push({ path: f, linkedTo: owners.sort(), role });
@@ -193,9 +197,9 @@ export function audit(repo, cfg, res) {
193
197
  subject: d.path,
194
198
  refs: [d.path],
195
199
  message:
196
- "document is claimed by no component" +
200
+ "document is claimed by no entity" +
197
201
  (d.role ? " and its configured role (" + d.role + ") is not a repository-wide role" : "") +
198
- ". Link it from a component descriptor, or remove it.",
202
+ ". Link it from an entity descriptor, or remove it.",
199
203
  });
200
204
  }
201
205
  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,14 @@ 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";
27
+ import { joinPath, ownsPath } from "./paths.mjs";
25
28
  import {
26
29
  COMPILER_NAME, COMPILER_VERSION, GRAPH_SCHEMA_VERSION,
27
30
  assembleGraph, serializeGraph, finalizeSerialization, subjectResolver,
@@ -130,11 +133,6 @@ export function compile(root, opts = {}) {
130
133
  for (const api of sources.entities.filter((e) => e.kind === "API")) {
131
134
  collect(() => validateContractFile(repo, bundle, api), res.errors);
132
135
  }
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
136
 
139
137
  let adapters;
140
138
  try {
@@ -147,16 +145,22 @@ export function compile(root, opts = {}) {
147
145
  throw e;
148
146
  }
149
147
  const discovery = discoverUnits(repo, cfg, adapters);
148
+ repo.nestedUnitRoots = discovery.nestedRoots;
150
149
  // Eligibility is the compiler's own answer to "which directories are units" —
151
150
  // exposed on the result so scaffold can write descriptors for exactly these
152
151
  // roots rather than re-deriving them from filesystem markers.
153
152
  res.discovery = discovery;
154
- const match = matchComponents(discovery.eligible, sources.entities.filter((e) => e.kind === "Component"));
155
- res.errors.push(...match.errors);
156
153
 
157
- // OIDC discovery must be exposed by the provider's own source. Re-check now
158
- // that component -> unit bindings are known.
154
+ // A provider descriptor sits at its unit root. Check its source only after
155
+ // ownership is known, while still reporting this error alongside other
156
+ // descriptor/contract errors rather than hiding it behind an early return.
159
157
  res.errors.push(...validateOidcProviders({ repo, sources, discovery }));
158
+ if (res.errors.length) {
159
+ return earlyOut(res, { entities: sources.entities.length });
160
+ }
161
+
162
+ const match = matchComponents(discovery.eligible, sources.entities.filter((e) => e.kind === "Component"), cfg);
163
+ res.errors.push(...match.errors);
160
164
 
161
165
  if (res.errors.length) {
162
166
  return earlyOut(res, { entities: sources.entities.length, units: discovery.eligible.length });
@@ -285,8 +289,19 @@ export function compile(root, opts = {}) {
285
289
  // ── manifest pre-pass: identify each unit's package / module namespace ────
286
290
  // Done before extraction so cross-unit resolution never depends on the order
287
291
  // in which units happen to be processed.
292
+ // Cargo.toml is read only when the rust adapter is on. An existence probe is
293
+ // part of the input hash, so probing it for every unit would change every
294
+ // graph that does not enable rust.
295
+ const rustOn = adapters.some((a) => a.name === "rust");
296
+ ctx.unitByCrateName = new Map();
297
+ ctx.ambiguousCrateNames = new Map();
298
+ ctx.rustAmbiguity = new Map();
299
+ // A Python manifest is read only when the python adapter is on. An existence
300
+ // probe is part of the input hash, so probing one for every unit would change
301
+ // every graph that does not enable python.
302
+ const pythonOn = adapters.some((a) => a.name === "python");
288
303
  for (const unit of discovery.eligible) {
289
- const pkgPath = unit.root + "/package.json";
304
+ const pkgPath = joinPath(unit.root, "package.json");
290
305
  if (repo.exists(pkgPath)) {
291
306
  try {
292
307
  const pkg = repo.readJson(pkgPath);
@@ -296,20 +311,73 @@ export function compile(root, opts = {}) {
296
311
  res.errors.push(new AgentDocError(CODES.SCHEMA, "package.json is not valid JSON: " + pkgPath, { path: pkgPath }));
297
312
  }
298
313
  }
299
- const gomodPath = unit.root + "/go.mod";
314
+ const gomodPath = joinPath(unit.root, "go.mod");
300
315
  if (repo.exists(gomodPath)) {
301
316
  const m = /^module\s+(\S+)/m.exec(repo.readText(gomodPath));
302
317
  unit.goModule = m ? m[1] : null;
303
318
  }
319
+ if (rustOn) loadCargoUnit(repo, unit);
320
+ if (pythonOn) {
321
+ const py = readPythonUnit(repo, unit.root);
322
+ if (py.manifest) {
323
+ unit.pythonMarker = py.manifest;
324
+ unit.pythonManifest = py.manifest;
325
+ unit.pythonDist = py.dist;
326
+ unit.pythonImports = py.ambiguousImports ? null : py.importNames;
327
+ unit.pythonProdDeps = [...new Set([
328
+ ...py.prodDeps,
329
+ ...py.sourceDeps.filter((n) => !py.devDeps.includes(n) && !py.prodDeps.includes(n)),
330
+ ])].sort();
331
+ unit.pythonDevDeps = py.devDeps;
332
+ unit.pythonPathDeps = py.pathDeps;
333
+ if (py.error) res.errors.push(py.error);
334
+ }
335
+ }
304
336
  }
305
337
  const unitByPkgName = new Map();
306
338
  const unitByGoModule = new Map();
339
+ const unitByPythonDist = new Map();
340
+ const unitByPythonImport = new Map();
341
+ const distOwners = new Map();
342
+ const importOwners = new Map();
307
343
  for (const unit of discovery.eligible) {
308
344
  if (unit.pkgName && !unitByPkgName.has(unit.pkgName)) unitByPkgName.set(unit.pkgName, unit);
309
345
  if (unit.goModule && !unitByGoModule.has(unit.goModule)) unitByGoModule.set(unit.goModule, unit);
346
+ if (unit.pythonDist) {
347
+ if (!distOwners.has(unit.pythonDist)) distOwners.set(unit.pythonDist, []);
348
+ distOwners.get(unit.pythonDist).push(unit);
349
+ }
350
+ if (Array.isArray(unit.pythonImports)) {
351
+ for (const name of unit.pythonImports) {
352
+ if (!importOwners.has(name)) importOwners.set(name, []);
353
+ importOwners.get(name).push(unit);
354
+ }
355
+ }
356
+ }
357
+ for (const [name, owners] of distOwners) {
358
+ if (owners.length === 1) unitByPythonDist.set(name, owners[0]);
359
+ else for (const u of owners) u.pythonDistConflict = true;
360
+ }
361
+ for (const [name, owners] of importOwners) {
362
+ if (owners.length === 1) unitByPythonImport.set(name, owners[0]);
363
+ else for (const u of owners) u.pythonImportConflict = true;
310
364
  }
311
365
  ctx.unitByPkgName = unitByPkgName;
312
366
  ctx.unitByGoModule = unitByGoModule;
367
+ if (rustOn) {
368
+ const owners = new Map();
369
+ for (const unit of discovery.eligible) {
370
+ if (!unit.crateName) continue;
371
+ if (!owners.has(unit.crateName)) owners.set(unit.crateName, []);
372
+ owners.get(unit.crateName).push(unit);
373
+ }
374
+ for (const [name, list] of [...owners.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
375
+ if (list.length === 1) ctx.unitByCrateName.set(name, list[0]);
376
+ else ctx.ambiguousCrateNames.set(name, list.slice().sort((a, b) => (a.root < b.root ? -1 : 1)));
377
+ }
378
+ }
379
+ ctx.unitByPythonDist = unitByPythonDist;
380
+ ctx.unitByPythonImport = unitByPythonImport;
313
381
 
314
382
  // Manifest-declared unit dependencies, computed before any adapter runs so
315
383
  // that cross-unit reasoning never depends on adapter execution order.
@@ -317,6 +385,7 @@ export function compile(root, opts = {}) {
317
385
  // matches a dependency name rather than a blob of source.
318
386
  const productionDependencies = new Map();
319
387
  const dependencyMap = new Map();
388
+ const unitsByRoot = new Map(discovery.eligible.map((u) => [u.root, u]));
320
389
  for (const unit of discovery.eligible) {
321
390
  if (!unit.component) continue;
322
391
  const deps = new Set();
@@ -330,7 +399,7 @@ export function compile(root, opts = {}) {
330
399
  if (t && t.component && t.component.ref !== unit.component.ref) deps.add(t.component.ref);
331
400
  }
332
401
  if (unit.goModule) {
333
- const gomod = repo.exists(unit.root + "/go.mod") ? repo.readText(unit.root + "/go.mod") : "";
402
+ const gomod = repo.exists(joinPath(unit.root, "go.mod")) ? repo.readText(joinPath(unit.root, "go.mod")) : "";
334
403
  const goReqs = [...gomod.matchAll(/^\s*(?:require\s+)?([a-z0-9.]+\.[a-z0-9./-]+)\s+v[0-9]/gm)].map((m) => m[1]);
335
404
  productionDependencies.set(unit.component.ref, [...(productionDependencies.get(unit.component.ref) || []), ...goReqs]);
336
405
  for (const m of gomod.matchAll(/^\s*(?:require\s+)?([a-z0-9.]+\.[a-z0-9./-]+)\s+v[0-9]/gm)) {
@@ -338,6 +407,32 @@ export function compile(root, opts = {}) {
338
407
  if (t && t.component && t.component.ref !== unit.component.ref) deps.add(t.component.ref);
339
408
  }
340
409
  }
410
+ if (rustOn && unit.cargoPackage) {
411
+ const cargo = analyzeCargoDeps(unit, unitsByRoot);
412
+ productionDependencies.set(unit.component.ref, [
413
+ ...(productionDependencies.get(unit.component.ref) || []),
414
+ ...cargo.prodNames,
415
+ ]);
416
+ for (const hit of cargo.resolved) {
417
+ if (hit.ambiguous || !hit.target || !hit.target.component) continue;
418
+ if (hit.target.component.ref !== unit.component.ref) deps.add(hit.target.component.ref);
419
+ }
420
+ }
421
+ if (unit.pythonMarker) {
422
+ const pyNames = [...new Set([...(unit.pythonProdDeps || []), ...(unit.pythonDevDeps || [])])];
423
+ productionDependencies.set(unit.component.ref, [
424
+ ...(productionDependencies.get(unit.component.ref) || []),
425
+ ...(unit.pythonProdDeps || []),
426
+ ]);
427
+ for (const name of pyNames) {
428
+ const t = unitByPythonDist.get(name);
429
+ if (t && t.component && t.component.ref !== unit.component.ref) deps.add(t.component.ref);
430
+ }
431
+ for (const p of unit.pythonPathDeps || []) {
432
+ const t = discovery.eligible.find((u) => u.root === p.dir);
433
+ if (t && t.component && t.component.ref !== unit.component.ref) deps.add(t.component.ref);
434
+ }
435
+ }
341
436
  dependencyMap.set(unit.component.ref, deps);
342
437
  }
343
438
  ctx.dependencyMap = dependencyMap;
@@ -702,14 +797,15 @@ export function compile(root, opts = {}) {
702
797
  ...schemaInputs,
703
798
  ...adapters.map((a) => ADAPTER_VERSION_INPUT_PREFIX + a.name + "@" + a.version),
704
799
  ]);
705
- const dirtySet = new Set(repo.dirtyPaths().filter((p) => p !== graphPath));
800
+ // The same git status snapshot the ignore oracle answered from (fsx.mjs).
801
+ const dirtySet = new Set(repo.dirtyPaths({ snapshot: true }).filter((p) => p !== graphPath));
706
802
  const walkedDirs = [...repo.walks.keys()];
707
803
  const dirty = [...dirtySet].some(
708
804
  (p) =>
709
805
  repo.readPaths.has(p) ||
710
806
  repo.probes.has(p) ||
711
807
  repo.listings.has(p) ||
712
- walkedDirs.some((d) => (d === "." ? true : p === d || p.startsWith(d + "/")))
808
+ walkedDirs.some((d) => ownsPath(d, p))
713
809
  );
714
810
  graph.source = { commit, dirty, inputHash };
715
811
 
@@ -942,9 +1038,13 @@ const GO_IMPORT_RE = /["`]([a-z0-9]+\.[a-z0-9.-]+\/[a-z0-9./_-]+|[a-z0-9]+\.[a-z
942
1038
  function buildImportIndex(repo, discovery) {
943
1039
  const byUnit = new Map();
944
1040
  const goPathsByUnit = new Map();
1041
+ const rustIdentsByUnit = new Map();
1042
+ const pythonImportsByUnit = new Map();
945
1043
  for (const unit of discovery.eligible) {
946
1044
  const map = new Map();
947
1045
  const goPaths = new Set();
1046
+ const rustIdents = new Map();
1047
+ const pyImports = new Map();
948
1048
  for (const f of sourceFiles(repo, unit.root, { test: false })) {
949
1049
  const specifiers = importSpecifiers(repo.readText(f));
950
1050
  for (const s of specifiers) {
@@ -958,11 +1058,26 @@ function buildImportIndex(repo, discovery) {
958
1058
  // importing it, with no require line at all.
959
1059
  for (const m of text.matchAll(GO_IMPORT_RE) ) goPaths.add(m[1]);
960
1060
  }
1061
+ if (unit.cargoPackage && f.endsWith(".rs")) {
1062
+ const text = repo.readText(f);
1063
+ for (const ident of rustIdentsInSource(text)) {
1064
+ if (!rustIdents.has(ident)) rustIdents.set(ident, new Set());
1065
+ rustIdents.get(ident).add(f);
1066
+ }
1067
+ }
1068
+ if (unit.pythonMarker && f.endsWith(".py")) {
1069
+ for (const name of pythonImportNames(repo.readText(f))) {
1070
+ if (!pyImports.has(name)) pyImports.set(name, new Set());
1071
+ pyImports.get(name).add(f);
1072
+ }
1073
+ }
961
1074
  }
962
1075
  byUnit.set(unit, map);
963
1076
  goPathsByUnit.set(unit, goPaths);
1077
+ rustIdentsByUnit.set(unit, rustIdents);
1078
+ pythonImportsByUnit.set(unit, pyImports);
964
1079
  }
965
- return { byUnit, goPathsByUnit };
1080
+ return { byUnit, goPathsByUnit, rustIdentsByUnit, pythonImportsByUnit };
966
1081
  }
967
1082
 
968
1083
  function mergeDerived(target, extra) {
@@ -995,7 +1110,7 @@ function validateOidcProviders({ repo, sources, discovery }) {
995
1110
  const roots = unit ? [unit.root] : [dirOf(provider.file)];
996
1111
  let found = false;
997
1112
  for (const root of roots) {
998
- for (const f of repo.walk(root)) {
1113
+ for (const f of repo.walkUnit(root)) {
999
1114
  if (!/\.(ts|tsx|js|mjs|jsx|py|go|rb|java|kt|rs)$/.test(f)) continue;
1000
1115
  if (/(^|\/)(test|tests|__tests__)\//.test(f) || /\.(test|spec)\./.test(f)) continue;
1001
1116
  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/derive.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  import path from "node:path";
8
8
  import { assertRepoPath } from "./fsx.mjs";
9
9
  import { CODES } from "./codes.mjs";
10
+ import { joinPath } from "./paths.mjs";
10
11
 
11
12
  const DEFAULT_DOC_CANDIDATES = ["README.md", "ARCHITECTURE.md", "AGENTS.md", "CONTRIBUTING.md", "DESIGN.md", "OVERVIEW.md"];
12
13
  const DEFAULT_CONSTRAINT_CANDIDATES = ["CONSTRAINTS.md", "INVARIANTS.md"];
@@ -33,15 +34,15 @@ export function genericFacts(ctx, unit) {
33
34
  const constraints = [];
34
35
  const runbooks = [];
35
36
  for (const n of docNames) {
36
- const p = root + "/" + n;
37
+ const p = joinPath(root, n);
37
38
  if (repo.exists(p)) { docs.push(p); prov.add("observed", p, "docs-extractor", ["der:" + compRef + ":/docs"]); }
38
39
  }
39
40
  for (const n of constraintNames) {
40
- const p = root + "/" + n;
41
+ const p = joinPath(root, n);
41
42
  if (repo.exists(p)) { constraints.push(p); prov.add("observed", p, "docs-extractor", ["der:" + compRef + ":/constraints"]); }
42
43
  }
43
44
  for (const n of runbookNames) {
44
- const p = root + "/" + n;
45
+ const p = joinPath(root, n);
45
46
  if (repo.exists(p)) { runbooks.push(p); prov.add("observed", p, "docs-extractor", ["der:" + compRef + ":/runbooks"]); }
46
47
  }
47
48
  const spec = ctx.componentEntity().doc.spec || {};
@@ -20,6 +20,7 @@ import { parseYamlDocuments } from "./yaml.mjs";
20
20
  import { scanForSecrets } from "./secrets.mjs";
21
21
  import { formatErrors } from "./jsonschema.mjs";
22
22
  import { SchemaBundle } from "./jsonschema.mjs";
23
+ import { joinPath, ownsPath } from "./paths.mjs";
23
24
 
24
25
  export const KINDS = Object.freeze(["Domain", "System", "Component", "API", "Resource"]);
25
26
  const NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -177,7 +178,7 @@ function descriptorGlobs(repo, cfg) {
177
178
  // glob — its descriptor lives at a fixed path and must be loaded even when
178
179
  // no componentDescriptors glob happens to cover that directory.
179
180
  for (const r of cfg.discovery.supplementalRoots || []) {
180
- const f = String(r).replace(/\/+$/, "") + "/" + cfg.discovery.componentDescriptorName;
181
+ const f = joinPath(String(r), cfg.discovery.componentDescriptorName);
181
182
  if (repo.exists(f)) componentFiles.add(f);
182
183
  }
183
184
  const centralFiles = new Set();
@@ -473,7 +474,7 @@ export function loadReviewedOverrides(repo, cfg, refs, byRef) {
473
474
  : null;
474
475
  for (const ev of o.evidence) {
475
476
  const p = ev.path;
476
- const scoped = p === o.contractRef || p.startsWith(subjectRoot + "/") || (targetRoot && p.startsWith(targetRoot + "/"));
477
+ const scoped = p === o.contractRef || ownsPath(subjectRoot, p) || (targetRoot !== null && ownsPath(targetRoot, p));
477
478
  if (!scoped) {
478
479
  throw new AgentDocError(
479
480
  CODES.RELATION_KIND,