agent-doc-system 1.2.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 (45) hide show
  1. package/CHANGELOG.md +134 -1
  2. package/README.md +1 -1
  3. package/adapters/cache.mjs +5 -4
  4. package/adapters/database.mjs +3 -2
  5. package/adapters/deploy-config.mjs +3 -2
  6. package/adapters/dockerfile.mjs +3 -2
  7. package/adapters/generic.mjs +2 -1
  8. package/adapters/github-actions.mjs +2 -1
  9. package/adapters/go.mjs +4 -3
  10. package/adapters/node.mjs +3 -2
  11. package/adapters/rust.mjs +3 -0
  12. package/adapters/turborepo.mjs +3 -2
  13. package/adapters/typescript.mjs +7 -5
  14. package/adapters/wrangler.mjs +3 -2
  15. package/bin/agentdoc.mjs +9 -2
  16. package/bin/usage.txt +2 -0
  17. package/core/audit.mjs +7 -5
  18. package/core/compile.mjs +7 -5
  19. package/core/derive.mjs +4 -3
  20. package/core/descriptors.mjs +3 -2
  21. package/core/discover.mjs +3 -2
  22. package/core/fsx.mjs +174 -42
  23. package/core/graph.mjs +1 -1
  24. package/core/impact.mjs +11 -4
  25. package/core/paths.mjs +71 -0
  26. package/core/query.mjs +58 -23
  27. package/core/scaffold.mjs +130 -23
  28. package/core/sourcescan.mjs +4 -1
  29. package/docs/ADAPTERS.md +1 -1
  30. package/docs/BENCHMARK.md +83 -21
  31. package/docs/CI.md +7 -1
  32. package/docs/EVALS.md +209 -17
  33. package/docs/MIGRATION.md +22 -4
  34. package/docs/PROVENANCE.md +6 -0
  35. package/docs/QUERY.md +25 -3
  36. package/docs/SPEC.md +47 -2
  37. package/docs/ci/github-actions.yml +1 -1
  38. package/evals/baseline-search.mjs +272 -0
  39. package/evals/facts.mjs +28 -0
  40. package/evals/harness.mjs +120 -116
  41. package/package.json +4 -2
  42. package/skills/agent-doc-system/SKILL.md +2 -1
  43. package/skills/agent-doc-system/references/cli.md +6 -1
  44. package/skills/agent-doc-system/references/create-migrate.md +8 -2
  45. package/templates/ci.yml +1 -1
package/CHANGELOG.md CHANGED
@@ -9,6 +9,131 @@ refused by the next with `AGENTDOC_COMPILER_MISMATCH` until it is recompiled.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.3.0] - 2026-11-07
13
+
14
+ A graph compiled on your machine now passes `check` in CI and in a clean
15
+ checkout. Files git ignores no longer count as compiler inputs, so a local
16
+ build that fills an ignored directory no longer changes `inputHash`. A
17
+ component whose descriptor sits at the repository root now owns the root files
18
+ no other component owns, so `query` and `impact` route them. `init` and
19
+ `scaffold --write` no longer write into your product's `docs/`: they follow the
20
+ configured `docs.*` paths, and a new install without a configuration gets its
21
+ skeleton under `agentdoc/docs/`. Releases are now published from CI with npm
22
+ provenance, so `npm audit signatures` can verify that a release was built from
23
+ this repository. The benchmark claim made in the 1.2.0 release notes is
24
+ withdrawn, because its gap came from the evaluation and not from routing.
25
+
26
+ Before you upgrade:
27
+
28
+ - **Recompile committed graphs.** As with every release, a graph compiled by
29
+ 1.2.0 is refused with `AGENTDOC_COMPILER_MISMATCH` until you run
30
+ `agentdoc compile` again.
31
+ - **A descriptor or configuration path that points at a git-ignored file now
32
+ fails** with `AGENTDOC_PATH_UNRESOLVED`, as it would in a clean checkout.
33
+ Commit the file or stop ignoring it ([#45]).
34
+ - **New installs write the documentation skeleton to `agentdoc/docs/`**
35
+ instead of `docs/`. A configuration that declares `docs.*` keeps its paths,
36
+ and no existing file is moved ([#59]).
37
+
38
+ ### Added
39
+
40
+ - A deterministic `baseline-search` routing evaluation using working-tree file
41
+ reads and text search, without compiled relations ([#46]). CLI reports compare
42
+ catalog, corrected targeted and search recall/noise/check selectivity per
43
+ scenario, corpus and aggregate; `docs/EVALS.md` documents every heuristic.
44
+
45
+ ### Changed
46
+
47
+ - Releases are now published only through a manual GitHub Actions workflow,
48
+ using npm trusted publishing (no token) with provenance, so `npm audit
49
+ signatures` can verify that a release was built from this repository
50
+ ([#51]). `RELEASING.md` in the repository describes the process.
51
+
52
+ - **Behaviour change for new installs only:** without a configuration,
53
+ `agentdoc init` (and `scaffold --write`) now writes the documentation
54
+ skeleton to `agentdoc/docs/PRODUCT.md`, `agentdoc/docs/ARCHITECTURE.md`,
55
+ `agentdoc/docs/CONSTRAINTS.md` and `agentdoc/docs/adr/`, and records those
56
+ paths in the `docs:` block of the generated `agentdoc/agentdoc.config.yaml`
57
+ ([#59]). It used to write to `docs/`, which in many repositories is the
58
+ product's own user documentation and the source of a published docs site
59
+ (MkDocs, Docusaurus, GitHub Pages). Authored AgentDoc material now lives in
60
+ `agentdoc/`; generated output stays in `.agentdoc/`. Existing configurations
61
+ are unaffected: a configuration that declares `docs.*` keeps its paths,
62
+ `docs/...` included, and no existing file is moved. To keep `docs/` on a new
63
+ install, declare `docs.product`, `docs.architecture`, `docs.constraints` and
64
+ `docs.adrDir` in `agentdoc/agentdoc.config.yaml` before running `init`.
65
+
66
+ ### Fixed
67
+
68
+ - A component whose descriptor sits at the repository root now owns the files
69
+ no nested component owns ([#50]). Before, `query vite.config.ts` failed with
70
+ `AGENTDOC_REF_UNRESOLVED` and `impact` listed the file in `unmatchedFiles`,
71
+ for both `vite.config.ts` and `./vite.config.ts`. Paths are now compared in
72
+ one canonical repository-relative form (no `./`), by whole path segment
73
+ (`src` never owns `src2/x`), and the longest owning root wins, so nested
74
+ components keep precedence and a contract file still routes to its API. A
75
+ root unit's derived paths (`README.md`, `package.json`, markers, provenance,
76
+ verification config paths) are no longer recorded as `./README.md`, so
77
+ `impact` now recognises them as documentation and compiler inputs. Without a
78
+ root descriptor, root files stay unmatched. Graphs of repositories without a
79
+ root component are unchanged.
80
+
81
+ - `agentdoc scaffold --write` and `agentdoc init` now write the documentation
82
+ skeleton (`PRODUCT.md`, `ARCHITECTURE.md`, `CONSTRAINTS.md`, the ADR README)
83
+ to the configured `docs.product`, `docs.architecture`, `docs.constraints` and
84
+ `docs.adrDir` paths ([#49]). Both used to write to `docs/` even when the
85
+ configuration said otherwise, and `docs/` is often the product's own user
86
+ documentation. The paths are read through the same loader as `compile` and
87
+ `audit`. A role the configuration omits is listed under `notConfigured`
88
+ instead of falling back to `docs/`. An existing file is still skipped unless
89
+ `init --force` is given. A path that leaves the repository, including through
90
+ a symlink, fails with `AGENTDOC_PATH_ESCAPE` before anything is written.
91
+ Without a configuration, `init` uses the `agentdoc/docs/` defaults (see
92
+ [#59] under Changed).
93
+
94
+ - Files git ignores no longer enter the compiler's inputs or `inputHash`
95
+ ([#45]). A local build writing into an ignored directory (`astro build`
96
+ filling `.astro/`, a `build/` tree, `*.log`) used to change the hash, so a
97
+ graph compiled after it failed `check` with `AGENTDOC_GRAPH_FRESHNESS` in CI
98
+ and in every clean checkout, while passing on the author's machine. Reads,
99
+ existence probes, listings and discovery now exclude git-ignored paths, with
100
+ git itself as the oracle: tracked files always count, and nested
101
+ `.gitignore`, `.git/info/exclude`, `core.excludesFile` and negations behave
102
+ exactly as in git. A non-ignored untracked file is still an input. Without
103
+ git the previous behaviour (filesystem minus `discovery.generatedDirs`)
104
+ holds. Committed graphs keep their hash. A descriptor or configuration
105
+ that points at a git-ignored file (for example a spec generated into an
106
+ ignored directory before `compile`) now fails with
107
+ `AGENTDOC_PATH_UNRESOLVED`, as it would in a clean checkout: commit the file
108
+ or stop ignoring it. The `git clean -fdX` before
109
+ `compile` documented as a workaround in lbframe/klozeo's `AGENTS.md` is no
110
+ longer needed.
111
+
112
+ - `npm publish` no longer auto-corrects `package.json`: the `bin` path is
113
+ committed in npm's normalized form, `bin/agentdoc.mjs` ([#55]).
114
+
115
+ - All routing evaluation systems and the noise closure now share fact labels
116
+ ([#46]). Corrected targeted micro-recall is 100/101 (0.9901), rather than
117
+ the previously reported gap. Search matches catalog recall in 21/30 scenarios;
118
+ measurements and limitations are in `docs/BENCHMARK.md`. Normalized full fact
119
+ counts fall below the existing size floor, so aggregate size reduction is
120
+ reported as unmeasured. Thresholds, scenarios and core routing are unchanged.
121
+
122
+ - **Withdrawn benchmark claim.** The README, `docs/BENCHMARK.md` and the 1.2.0
123
+ release notes reported that routing retrieves 100% of the facts a change
124
+ needs against 58% for a baseline that reads only the touched component, with
125
+ noise under 9% against 40%, and an answer about 70% smaller than the whole
126
+ catalog. The gap came from the evaluation, not from
127
+ routing: the baseline already held almost every expected fact under a
128
+ different label, and it read relations from the compiled graph that an agent
129
+ without AgentDoc does not have. The size figure counted the same component
130
+ once per relation label; deduplicated, every corpus falls below the size
131
+ floor. Both claims are
132
+ removed. A baseline that finds
133
+ relations with search alone is now measured in the benchmark. The
134
+ [correction](docs/BENCHMARK.md#correction-the-fact-recall-comparison-is-withdrawn)
135
+ gives the details.
136
+
12
137
  ## [1.2.0] - 2026-10-27
13
138
 
14
139
  Rust and Python are now supported at the same level as Node and Go. Before
@@ -242,7 +367,8 @@ First public release.
242
367
  for project health.
243
368
  - An Agent Skill under `skills/agent-doc-system/`.
244
369
 
245
- [Unreleased]: https://github.com/lbframe/agent-doc-system/compare/v1.2.0...HEAD
370
+ [Unreleased]: https://github.com/lbframe/agent-doc-system/compare/v1.3.0...HEAD
371
+ [1.3.0]: https://github.com/lbframe/agent-doc-system/compare/v1.2.0...v1.3.0
246
372
  [1.2.0]: https://github.com/lbframe/agent-doc-system/compare/v1.1.0...v1.2.0
247
373
  [1.1.0]: https://github.com/lbframe/agent-doc-system/compare/v1.0.0...v1.1.0
248
374
  [1.0.0]: https://github.com/lbframe/agent-doc-system/releases/tag/v1.0.0
@@ -267,3 +393,10 @@ First public release.
267
393
  [#40]: https://github.com/lbframe/agent-doc-system/issues/40
268
394
  [#41]: https://github.com/lbframe/agent-doc-system/issues/41
269
395
  [#43]: https://github.com/lbframe/agent-doc-system/issues/43
396
+ [#45]: https://github.com/lbframe/agent-doc-system/issues/45
397
+ [#46]: https://github.com/lbframe/agent-doc-system/issues/46
398
+ [#49]: https://github.com/lbframe/agent-doc-system/issues/49
399
+ [#50]: https://github.com/lbframe/agent-doc-system/issues/50
400
+ [#51]: https://github.com/lbframe/agent-doc-system/issues/51
401
+ [#55]: https://github.com/lbframe/agent-doc-system/issues/55
402
+ [#59]: https://github.com/lbframe/agent-doc-system/issues/59
package/README.md CHANGED
@@ -98,7 +98,7 @@ What every adapter derives, and what it deliberately cannot see, is in [docs/ADA
98
98
 
99
99
  Measured, reproducible, and with no model in the loop. Method and caveats are in [docs/BENCHMARK.md](docs/BENCHMARK.md).
100
100
 
101
- - **The right context: 100% vs. 58%.** Across 30 routing scenarios (Node, Go, Rust, Python), `query` + `impact` retrieves every fact the change needs. Reading only the touched component retrieves 58%. Noise stays under 9%, against 40%. Every check the change requires is in the answer, and that answer holds about half of the repository's checks (29% on the largest corpus), not all of them. The thresholds were fixed before the first run and have not moved since.
101
+ - **The checks a change needs, not the whole suite.** Across 30 routing scenarios (Node, Go, Rust, Python), every check the change requires is in the answer, and that answer holds about half of the repository's checks (29% on the largest corpus), not all of them. The thresholds were fixed before the first run and have not moved since.
102
102
  - **Seconds to map a real repository.** A fresh `init` → `scaffold` → `compile` takes 1.0–4.6 s on four public repositories, from 141 to 5,142 files (axum, pydantic-ai, full-stack-fastapi-template, create-t3-turbo). No edit was needed beyond choosing each component's placement.
103
103
  - **A briefing, not a dump.** A `query` answer is about 100–260 tokens: what the change reaches, what to read, and which checks to run.
104
104
  - **The same graph every time.** Compiles are byte-identical, and CI can verify it.
@@ -1,5 +1,6 @@
1
1
  // Cache adapter: Redis / in-memory cache client evidence as a derived binding.
2
2
  import { Adapter } from "./registry.mjs";
3
+ import { joinPath } from "../core/paths.mjs";
3
4
 
4
5
  const CACHE_HINT = /(@upstash\/redis|ioredis|redis\.Redis|redis|go-redis|redis\.go|valkey|memcache|@memcached)/i;
5
6
 
@@ -11,12 +12,12 @@ export class CacheAdapter extends Adapter {
11
12
  extract(ctx, unit) {
12
13
  const { repo, prov, compRef } = ctx;
13
14
  const evFile = unit.goModule
14
- ? unit.root + "/go.mod"
15
+ ? joinPath(unit.root, "go.mod")
15
16
  : unit.pythonManifest
16
17
  ? unit.pythonManifest
17
- : repo.exists(unit.root + "/package.json")
18
- ? unit.root + "/package.json"
19
- : unit.root + "/Cargo.toml";
18
+ : repo.exists(joinPath(unit.root, "package.json"))
19
+ ? joinPath(unit.root, "package.json")
20
+ : joinPath(unit.root, "Cargo.toml");
20
21
  if (!repo.exists(evFile)) return {};
21
22
  const text = repo.readText(evFile);
22
23
  if (!CACHE_HINT.test(text)) return {};
@@ -9,6 +9,7 @@
9
9
  // library shipped migrations but explicitly did not own a database.
10
10
  import { Adapter } from "./registry.mjs";
11
11
  import { firstExistingFile, findMigrationDirs } from "../core/sourcescan.mjs";
12
+ import { joinPath } from "../core/paths.mjs";
12
13
 
13
14
  const ORM_CONFIG = [
14
15
  "drizzle.config.ts", "drizzle.config.js", "drizzle.config.mjs",
@@ -37,8 +38,8 @@ export class DatabaseAdapter extends Adapter {
37
38
  }
38
39
  const alembicDir = migrationDirs.find((d) => d === "alembic/versions" || d.endsWith("/alembic/versions"));
39
40
  if (alembicDir) {
40
- const ini = root + "/alembic.ini";
41
- const pyproject = root + "/pyproject.toml";
41
+ const ini = joinPath(root, "alembic.ini");
42
+ const pyproject = joinPath(root, "pyproject.toml");
42
43
  if (repo.exists(ini)) {
43
44
  const text = repo.readText(ini);
44
45
  if (/^\s*\[alembic\]/m.test(text) || /^\s*script_location\s*=/m.test(text)) evidencePath = ini;
@@ -2,6 +2,7 @@
2
2
  // Only the file's existence is treated as evidence; no value from these files
3
3
  // is ever reported as deployed state.
4
4
  import { Adapter } from "./registry.mjs";
5
+ import { joinPath } from "../core/paths.mjs";
5
6
 
6
7
  const DEPLOY_FILES = [
7
8
  "fly.toml", "app.yaml", "render.yaml", "railway.json", "vercel.json",
@@ -16,7 +17,7 @@ export class DeployConfigAdapter extends Adapter {
16
17
  }
17
18
  detect(ctx, root) {
18
19
  for (const n of DEPLOY_FILES) {
19
- if (ctx.repo.exists(root + "/" + n)) return { path: root + "/" + n, shape: "deployable" };
20
+ if (ctx.repo.exists(joinPath(root, n))) return { path: joinPath(root, n), shape: "deployable" };
20
21
  }
21
22
  return null;
22
23
  }
@@ -24,7 +25,7 @@ export class DeployConfigAdapter extends Adapter {
24
25
  const { repo, prov, compRef } = ctx;
25
26
  const out = { deployConfigs: [] };
26
27
  for (const n of DEPLOY_FILES) {
27
- const p = unit.root + "/" + n;
28
+ const p = joinPath(unit.root, n);
28
29
  if (!repo.exists(p)) continue;
29
30
  prov.add("observed", p, "deploy-extractor", ["der:" + compRef + ":/artifact"]);
30
31
  out.deployConfigs.push(p);
@@ -1,5 +1,6 @@
1
1
  // Dockerfile adapter: a Dockerfile is deployable-artifact evidence.
2
2
  import { Adapter } from "./registry.mjs";
3
+ import { joinPath } from "../core/paths.mjs";
3
4
 
4
5
  export class DockerfileAdapter extends Adapter {
5
6
  static adapterName = "dockerfile";
@@ -8,7 +9,7 @@ export class DockerfileAdapter extends Adapter {
8
9
  }
9
10
  detect(ctx, root) {
10
11
  for (const n of ["Dockerfile", "Dockerfile.prod", "Containerfile"]) {
11
- if (ctx.repo.exists(root + "/" + n)) return { path: root + "/" + n, shape: "deployable" };
12
+ if (ctx.repo.exists(joinPath(root, n))) return { path: joinPath(root, n), shape: "deployable" };
12
13
  }
13
14
  return null;
14
15
  }
@@ -16,7 +17,7 @@ export class DockerfileAdapter extends Adapter {
16
17
  const { repo, prov, compRef } = ctx;
17
18
  const out = { dockerfiles: [] };
18
19
  for (const n of ["Dockerfile", "Dockerfile.prod", "Containerfile"]) {
19
- const p = unit.root + "/" + n;
20
+ const p = joinPath(unit.root, n);
20
21
  if (!repo.exists(p)) continue;
21
22
  prov.add("observed", p, "deploy-extractor", ["der:" + compRef + ":/artifact"]);
22
23
  out.dockerfiles.push(p);
@@ -7,6 +7,7 @@
7
7
  import { Adapter } from "./registry.mjs";
8
8
  import { resolveClientResources } from "./resource-client.mjs";
9
9
  import { sourceFiles } from "../core/sourcescan.mjs";
10
+ import { joinPath } from "../core/paths.mjs";
10
11
 
11
12
  // Conservative: a health route is an absolute string literal that looks like a
12
13
  // liveness or readiness endpoint. Anything more specific belongs to a
@@ -50,7 +51,7 @@ export class GenericAdapter extends Adapter {
50
51
  // Provenance must name a file that exists. The dependency evidence is the
51
52
  // manifest when there is one, and otherwise the unit's descriptor.
52
53
  const manifest = ["package.json", "go.mod", "Cargo.toml", "pyproject.toml", "pom.xml"]
53
- .map((n) => unit.root + "/" + n)
54
+ .map((n) => joinPath(unit.root, n))
54
55
  .find((p) => repo.exists(p)) || ctx.componentEntity().file;
55
56
  const clientProv = prov.add("observed", manifest, "client-resolver", ["rel:pending", "der:" + compRef + ":/bindings"]);
56
57
  resolveClientResources(ctx, unit, compRef, clientProv);
@@ -4,6 +4,7 @@
4
4
  import path from "node:path";
5
5
  import { Adapter } from "./registry.mjs";
6
6
  import { parseYamlDocuments } from "../core/yaml.mjs";
7
+ import { ownsPath } from "../core/paths.mjs";
7
8
 
8
9
  export class GitHubActionsAdapter extends Adapter {
9
10
  static adapterName = "github-actions";
@@ -224,7 +225,7 @@ function literalPath(value) {
224
225
  const p = path.posix.normalize(value);
225
226
  return p === ".." || p.startsWith("../") ? null : p.replace(/\/$/, "");
226
227
  }
227
- function within(p, root) { return root === "." || p === root || p.startsWith(root + "/"); }
228
+ function within(p, root) { return ownsPath(root, p); }
228
229
  function shellQuote(p) { return /^[A-Za-z0-9_./-]+$/.test(p) ? p : "'" + p.replace(/'/g, "'\\''") + "'"; }
229
230
 
230
231
  function scopedTo(unit, args, cwd, filters, units, repo) {
package/adapters/go.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // Go adapter: go.mod / go.work as unit markers, module graph as build
2
2
  // relations, and the go toolchain's own verification commands.
3
3
  import { Adapter } from "./registry.mjs";
4
+ import { joinPath } from "../core/paths.mjs";
4
5
 
5
6
  export class GoAdapter extends Adapter {
6
7
  static adapterName = "go";
@@ -26,13 +27,13 @@ export class GoAdapter extends Adapter {
26
27
  return out;
27
28
  }
28
29
  detect(ctx, root) {
29
- const p = root + "/go.mod";
30
+ const p = joinPath(root, "go.mod");
30
31
  if (!ctx.repo.exists(p)) return null;
31
- return { path: p, shape: ctx.repo.isDir(root + "/cmd") ? "deployable" : "importable" };
32
+ return { path: p, shape: ctx.repo.isDir(joinPath(root, "cmd")) ? "deployable" : "importable" };
32
33
  }
33
34
  extract(ctx, unit) {
34
35
  const { repo, prov, compRef } = ctx;
35
- const p = unit.root + "/go.mod";
36
+ const p = joinPath(unit.root, "go.mod");
36
37
  if (!repo.exists(p)) return {};
37
38
  const derived = { languages: new Set(["go"]), runtimes: new Set(["go"]) };
38
39
  const text = repo.readText(p);
package/adapters/node.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // Node adapter: package.json as the unit marker and manifest authority.
2
2
  import { Adapter } from "./registry.mjs";
3
+ import { joinPath } from "../core/paths.mjs";
3
4
 
4
5
  // A script name maps to a verification tier. Deliberately conservative and
5
6
  // project-neutral: a script that does not look like a check is not a check.
@@ -20,7 +21,7 @@ export class NodeAdapter extends Adapter {
20
21
  super({ name: "node", version: "1.0.0", kind: "unit-marker" });
21
22
  }
22
23
  detect(ctx, root) {
23
- const p = root + "/package.json";
24
+ const p = joinPath(root, "package.json");
24
25
  if (!ctx.repo.exists(p)) return null;
25
26
  let pkg;
26
27
  try {
@@ -34,7 +35,7 @@ export class NodeAdapter extends Adapter {
34
35
  extract(ctx, unit) {
35
36
  const { repo, prov, compRef } = ctx;
36
37
  const root = unit.root;
37
- const manifestPath = root + "/package.json";
38
+ const manifestPath = joinPath(root, "package.json");
38
39
  if (!repo.exists(manifestPath)) return {};
39
40
  const derived = { languages: new Set(["javascript"]), runtimes: new Set(["node"]) };
40
41
  const pkg = unit.pkg || repo.readJson(manifestPath);
package/adapters/rust.mjs CHANGED
@@ -225,6 +225,9 @@ function childDirectoryNames(repo, dir) {
225
225
  if (ent.isSymbolicLink() || !ent.isDirectory()) continue;
226
226
  if (!ent.name || ent.name.startsWith(".")) continue;
227
227
  if (generated.has(ent.name) || WORKSPACE_SKIP_DIRS.has(ent.name)) continue;
228
+ // Each child is probed for Cargo.toml, and a probe is an input: a
229
+ // git-ignored directory must not even be visited (issue #45).
230
+ if (repo.isIgnored && repo.isIgnored(dir ? dir + "/" + ent.name : ent.name)) continue;
228
231
  names.push(ent.name);
229
232
  }
230
233
  names.sort();
@@ -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.walkUnit(unit.root, 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));
@@ -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,
package/core/compile.mjs CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  } from "./normative.mjs";
25
25
  import { loadAdapters } from "../adapters/index.mjs";
26
26
  import { loadCargoUnit, analyzeCargoDeps, rustIdentsInSource } from "../adapters/rust.mjs";
27
+ import { joinPath, ownsPath } from "./paths.mjs";
27
28
  import {
28
29
  COMPILER_NAME, COMPILER_VERSION, GRAPH_SCHEMA_VERSION,
29
30
  assembleGraph, serializeGraph, finalizeSerialization, subjectResolver,
@@ -300,7 +301,7 @@ export function compile(root, opts = {}) {
300
301
  // every graph that does not enable python.
301
302
  const pythonOn = adapters.some((a) => a.name === "python");
302
303
  for (const unit of discovery.eligible) {
303
- const pkgPath = unit.root + "/package.json";
304
+ const pkgPath = joinPath(unit.root, "package.json");
304
305
  if (repo.exists(pkgPath)) {
305
306
  try {
306
307
  const pkg = repo.readJson(pkgPath);
@@ -310,7 +311,7 @@ export function compile(root, opts = {}) {
310
311
  res.errors.push(new AgentDocError(CODES.SCHEMA, "package.json is not valid JSON: " + pkgPath, { path: pkgPath }));
311
312
  }
312
313
  }
313
- const gomodPath = unit.root + "/go.mod";
314
+ const gomodPath = joinPath(unit.root, "go.mod");
314
315
  if (repo.exists(gomodPath)) {
315
316
  const m = /^module\s+(\S+)/m.exec(repo.readText(gomodPath));
316
317
  unit.goModule = m ? m[1] : null;
@@ -398,7 +399,7 @@ export function compile(root, opts = {}) {
398
399
  if (t && t.component && t.component.ref !== unit.component.ref) deps.add(t.component.ref);
399
400
  }
400
401
  if (unit.goModule) {
401
- 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")) : "";
402
403
  const goReqs = [...gomod.matchAll(/^\s*(?:require\s+)?([a-z0-9.]+\.[a-z0-9./-]+)\s+v[0-9]/gm)].map((m) => m[1]);
403
404
  productionDependencies.set(unit.component.ref, [...(productionDependencies.get(unit.component.ref) || []), ...goReqs]);
404
405
  for (const m of gomod.matchAll(/^\s*(?:require\s+)?([a-z0-9.]+\.[a-z0-9./-]+)\s+v[0-9]/gm)) {
@@ -796,14 +797,15 @@ export function compile(root, opts = {}) {
796
797
  ...schemaInputs,
797
798
  ...adapters.map((a) => ADAPTER_VERSION_INPUT_PREFIX + a.name + "@" + a.version),
798
799
  ]);
799
- 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));
800
802
  const walkedDirs = [...repo.walks.keys()];
801
803
  const dirty = [...dirtySet].some(
802
804
  (p) =>
803
805
  repo.readPaths.has(p) ||
804
806
  repo.probes.has(p) ||
805
807
  repo.listings.has(p) ||
806
- walkedDirs.some((d) => (d === "." ? true : p === d || p.startsWith(d + "/")))
808
+ walkedDirs.some((d) => ownsPath(d, p))
807
809
  );
808
810
  graph.source = { commit, dirty, inputHash };
809
811
 
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,