agent-doc-system 1.2.0 → 1.4.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 (47) hide show
  1. package/CHANGELOG.md +188 -1
  2. package/README.md +1 -1
  3. package/adapters/cache.mjs +5 -4
  4. package/adapters/database.mjs +155 -6
  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/codes.mjs +2 -0
  19. package/core/compile.mjs +12 -6
  20. package/core/derive.mjs +4 -3
  21. package/core/descriptors.mjs +52 -2
  22. package/core/discover.mjs +3 -2
  23. package/core/fsx.mjs +174 -42
  24. package/core/graph.mjs +1 -1
  25. package/core/impact.mjs +84 -14
  26. package/core/paths.mjs +71 -0
  27. package/core/query.mjs +58 -23
  28. package/core/scaffold.mjs +130 -23
  29. package/core/sourcescan.mjs +4 -1
  30. package/docs/ADAPTERS.md +49 -5
  31. package/docs/BENCHMARK.md +83 -21
  32. package/docs/CI.md +9 -1
  33. package/docs/EVALS.md +209 -17
  34. package/docs/MIGRATION.md +22 -4
  35. package/docs/PROVENANCE.md +6 -0
  36. package/docs/QUERY.md +50 -7
  37. package/docs/SPEC.md +89 -2
  38. package/docs/ci/github-actions.yml +1 -1
  39. package/evals/baseline-search.mjs +272 -0
  40. package/evals/facts.mjs +28 -0
  41. package/evals/harness.mjs +120 -116
  42. package/package.json +4 -2
  43. package/schemas/resource.schema.json +9 -1
  44. package/skills/agent-doc-system/SKILL.md +2 -1
  45. package/skills/agent-doc-system/references/cli.md +6 -1
  46. package/skills/agent-doc-system/references/create-migrate.md +8 -2
  47. package/templates/ci.yml +1 -1
package/CHANGELOG.md CHANGED
@@ -9,6 +9,183 @@ refused by the next with `AGENTDOC_COMPILER_MISMATCH` until it is recompiled.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.4.0] - 2026-11-10
13
+
14
+ `impact` now treats a change under a Drizzle migration directory as a change
15
+ to the database it migrates. It reaches every component bound to that
16
+ database, not only the component that holds the file. Any other ORM layout can
17
+ be declared on the database Resource with the new `spec.migrationPaths`.
18
+ Existing `migrations/` and `alembic/versions/` paths give the same answer as
19
+ before.
20
+
21
+ Before you upgrade:
22
+
23
+ - **Recompile committed graphs.** As with every release, a graph compiled by
24
+ 1.3.0 is refused with `AGENTDOC_COMPILER_MISMATCH` until you run
25
+ `agentdoc compile` again. Graphs of repositories that enable the `database`
26
+ adapter also gain `derived.migrationDirs` and the provenance behind it.
27
+ - **A Drizzle config whose `out` is not a plain string** (an environment
28
+ variable, an expression, a spread) now raises the warning
29
+ `AGENTDOC_MIGRATION_DIR_UNRESOLVED`. Declare the directory with
30
+ `spec.migrationPaths`, or accept the warning ([#63]).
31
+
32
+ ### Added
33
+
34
+ - A `logical-database` Resource can declare where its schema migrations live,
35
+ with `spec.migrationPaths` ([#63]). A change under a declared directory is a
36
+ change to that Resource, whichever component owns the file, so `impact`
37
+ reaches every component bound to the database. Paths are whole-segment
38
+ prefixes, not globs: `frontend/apps/web/drizzle` covers `0036_x.sql` and
39
+ `meta/_journal.json`, and never `drizzle-extra/`. A glob, a declaration on
40
+ another Resource type, or one path declared by two Resources fails with the
41
+ new `AGENTDOC_MIGRATION_PATH`; a path outside the repository fails with
42
+ `AGENTDOC_PATH_ESCAPE`, a missing one with `AGENTDOC_PATH_UNRESOLVED`. The
43
+ declaration never creates a binding.
44
+
45
+ ### Fixed
46
+
47
+ - `impact` now classifies a change under a Drizzle migration directory as a
48
+ database change ([#63]). Only `migrations/`, `migration/` and
49
+ `alembic/versions/` used to count, so `impact
50
+ frontend/apps/web/drizzle/0036_email_locale.sql` reported the web component
51
+ alone and missed every other component bound to the same database. The
52
+ `database` adapter now reads Drizzle Kit's `out` from `drizzle.config.ts`
53
+ (`.js`, `.mjs`) as a string literal, without running it, and defaults to
54
+ `./drizzle`. An `out` it cannot read statically is reported with the
55
+ acceptable warning `AGENTDOC_MIGRATION_DIR_UNRESOLVED` instead of being
56
+ guessed. Detected directories, including `migrations` and `alembic/versions`,
57
+ are recorded in the graph as `derived.migrationDirs` with provenance, and
58
+ `impact` classifies from them. A detected directory still reaches a database
59
+ only through the component's existing bindings. The answer for an existing
60
+ `migrations/` or `alembic/versions/` path is unchanged. Committed graphs of
61
+ repositories that enable the `database` adapter gain `migrationDirs` and
62
+ must be recompiled.
63
+
64
+ ## [1.3.0] - 2026-11-07
65
+
66
+ A graph compiled on your machine now passes `check` in CI and in a clean
67
+ checkout. Files git ignores no longer count as compiler inputs, so a local
68
+ build that fills an ignored directory no longer changes `inputHash`. A
69
+ component whose descriptor sits at the repository root now owns the root files
70
+ no other component owns, so `query` and `impact` route them. `init` and
71
+ `scaffold --write` no longer write into your product's `docs/`: they follow the
72
+ configured `docs.*` paths, and a new install without a configuration gets its
73
+ skeleton under `agentdoc/docs/`. Releases are now published from CI with npm
74
+ provenance, so `npm audit signatures` can verify that a release was built from
75
+ this repository. The benchmark claim made in the 1.2.0 release notes is
76
+ withdrawn, because its gap came from the evaluation and not from routing.
77
+
78
+ Before you upgrade:
79
+
80
+ - **Recompile committed graphs.** As with every release, a graph compiled by
81
+ 1.2.0 is refused with `AGENTDOC_COMPILER_MISMATCH` until you run
82
+ `agentdoc compile` again.
83
+ - **A descriptor or configuration path that points at a git-ignored file now
84
+ fails** with `AGENTDOC_PATH_UNRESOLVED`, as it would in a clean checkout.
85
+ Commit the file or stop ignoring it ([#45]).
86
+ - **New installs write the documentation skeleton to `agentdoc/docs/`**
87
+ instead of `docs/`. A configuration that declares `docs.*` keeps its paths,
88
+ and no existing file is moved ([#59]).
89
+
90
+ ### Added
91
+
92
+ - A deterministic `baseline-search` routing evaluation using working-tree file
93
+ reads and text search, without compiled relations ([#46]). CLI reports compare
94
+ catalog, corrected targeted and search recall/noise/check selectivity per
95
+ scenario, corpus and aggregate; `docs/EVALS.md` documents every heuristic.
96
+
97
+ ### Changed
98
+
99
+ - Releases are now published only through a manual GitHub Actions workflow,
100
+ using npm trusted publishing (no token) with provenance, so `npm audit
101
+ signatures` can verify that a release was built from this repository
102
+ ([#51]). `RELEASING.md` in the repository describes the process.
103
+
104
+ - **Behaviour change for new installs only:** without a configuration,
105
+ `agentdoc init` (and `scaffold --write`) now writes the documentation
106
+ skeleton to `agentdoc/docs/PRODUCT.md`, `agentdoc/docs/ARCHITECTURE.md`,
107
+ `agentdoc/docs/CONSTRAINTS.md` and `agentdoc/docs/adr/`, and records those
108
+ paths in the `docs:` block of the generated `agentdoc/agentdoc.config.yaml`
109
+ ([#59]). It used to write to `docs/`, which in many repositories is the
110
+ product's own user documentation and the source of a published docs site
111
+ (MkDocs, Docusaurus, GitHub Pages). Authored AgentDoc material now lives in
112
+ `agentdoc/`; generated output stays in `.agentdoc/`. Existing configurations
113
+ are unaffected: a configuration that declares `docs.*` keeps its paths,
114
+ `docs/...` included, and no existing file is moved. To keep `docs/` on a new
115
+ install, declare `docs.product`, `docs.architecture`, `docs.constraints` and
116
+ `docs.adrDir` in `agentdoc/agentdoc.config.yaml` before running `init`.
117
+
118
+ ### Fixed
119
+
120
+ - A component whose descriptor sits at the repository root now owns the files
121
+ no nested component owns ([#50]). Before, `query vite.config.ts` failed with
122
+ `AGENTDOC_REF_UNRESOLVED` and `impact` listed the file in `unmatchedFiles`,
123
+ for both `vite.config.ts` and `./vite.config.ts`. Paths are now compared in
124
+ one canonical repository-relative form (no `./`), by whole path segment
125
+ (`src` never owns `src2/x`), and the longest owning root wins, so nested
126
+ components keep precedence and a contract file still routes to its API. A
127
+ root unit's derived paths (`README.md`, `package.json`, markers, provenance,
128
+ verification config paths) are no longer recorded as `./README.md`, so
129
+ `impact` now recognises them as documentation and compiler inputs. Without a
130
+ root descriptor, root files stay unmatched. Graphs of repositories without a
131
+ root component are unchanged.
132
+
133
+ - `agentdoc scaffold --write` and `agentdoc init` now write the documentation
134
+ skeleton (`PRODUCT.md`, `ARCHITECTURE.md`, `CONSTRAINTS.md`, the ADR README)
135
+ to the configured `docs.product`, `docs.architecture`, `docs.constraints` and
136
+ `docs.adrDir` paths ([#49]). Both used to write to `docs/` even when the
137
+ configuration said otherwise, and `docs/` is often the product's own user
138
+ documentation. The paths are read through the same loader as `compile` and
139
+ `audit`. A role the configuration omits is listed under `notConfigured`
140
+ instead of falling back to `docs/`. An existing file is still skipped unless
141
+ `init --force` is given. A path that leaves the repository, including through
142
+ a symlink, fails with `AGENTDOC_PATH_ESCAPE` before anything is written.
143
+ Without a configuration, `init` uses the `agentdoc/docs/` defaults (see
144
+ [#59] under Changed).
145
+
146
+ - Files git ignores no longer enter the compiler's inputs or `inputHash`
147
+ ([#45]). A local build writing into an ignored directory (`astro build`
148
+ filling `.astro/`, a `build/` tree, `*.log`) used to change the hash, so a
149
+ graph compiled after it failed `check` with `AGENTDOC_GRAPH_FRESHNESS` in CI
150
+ and in every clean checkout, while passing on the author's machine. Reads,
151
+ existence probes, listings and discovery now exclude git-ignored paths, with
152
+ git itself as the oracle: tracked files always count, and nested
153
+ `.gitignore`, `.git/info/exclude`, `core.excludesFile` and negations behave
154
+ exactly as in git. A non-ignored untracked file is still an input. Without
155
+ git the previous behaviour (filesystem minus `discovery.generatedDirs`)
156
+ holds. Committed graphs keep their hash. A descriptor or configuration
157
+ that points at a git-ignored file (for example a spec generated into an
158
+ ignored directory before `compile`) now fails with
159
+ `AGENTDOC_PATH_UNRESOLVED`, as it would in a clean checkout: commit the file
160
+ or stop ignoring it. The `git clean -fdX` before
161
+ `compile` documented as a workaround in lbframe/klozeo's `AGENTS.md` is no
162
+ longer needed.
163
+
164
+ - `npm publish` no longer auto-corrects `package.json`: the `bin` path is
165
+ committed in npm's normalized form, `bin/agentdoc.mjs` ([#55]).
166
+
167
+ - All routing evaluation systems and the noise closure now share fact labels
168
+ ([#46]). Corrected targeted micro-recall is 100/101 (0.9901), rather than
169
+ the previously reported gap. Search matches catalog recall in 21/30 scenarios;
170
+ measurements and limitations are in `docs/BENCHMARK.md`. Normalized full fact
171
+ counts fall below the existing size floor, so aggregate size reduction is
172
+ reported as unmeasured. Thresholds, scenarios and core routing are unchanged.
173
+
174
+ - **Withdrawn benchmark claim.** The README, `docs/BENCHMARK.md` and the 1.2.0
175
+ release notes reported that routing retrieves 100% of the facts a change
176
+ needs against 58% for a baseline that reads only the touched component, with
177
+ noise under 9% against 40%, and an answer about 70% smaller than the whole
178
+ catalog. The gap came from the evaluation, not from
179
+ routing: the baseline already held almost every expected fact under a
180
+ different label, and it read relations from the compiled graph that an agent
181
+ without AgentDoc does not have. The size figure counted the same component
182
+ once per relation label; deduplicated, every corpus falls below the size
183
+ floor. Both claims are
184
+ removed. A baseline that finds
185
+ relations with search alone is now measured in the benchmark. The
186
+ [correction](docs/BENCHMARK.md#correction-the-fact-recall-comparison-is-withdrawn)
187
+ gives the details.
188
+
12
189
  ## [1.2.0] - 2026-10-27
13
190
 
14
191
  Rust and Python are now supported at the same level as Node and Go. Before
@@ -242,7 +419,9 @@ First public release.
242
419
  for project health.
243
420
  - An Agent Skill under `skills/agent-doc-system/`.
244
421
 
245
- [Unreleased]: https://github.com/lbframe/agent-doc-system/compare/v1.2.0...HEAD
422
+ [Unreleased]: https://github.com/lbframe/agent-doc-system/compare/v1.4.0...HEAD
423
+ [1.4.0]: https://github.com/lbframe/agent-doc-system/compare/v1.3.0...v1.4.0
424
+ [1.3.0]: https://github.com/lbframe/agent-doc-system/compare/v1.2.0...v1.3.0
246
425
  [1.2.0]: https://github.com/lbframe/agent-doc-system/compare/v1.1.0...v1.2.0
247
426
  [1.1.0]: https://github.com/lbframe/agent-doc-system/compare/v1.0.0...v1.1.0
248
427
  [1.0.0]: https://github.com/lbframe/agent-doc-system/releases/tag/v1.0.0
@@ -267,3 +446,11 @@ First public release.
267
446
  [#40]: https://github.com/lbframe/agent-doc-system/issues/40
268
447
  [#41]: https://github.com/lbframe/agent-doc-system/issues/41
269
448
  [#43]: https://github.com/lbframe/agent-doc-system/issues/43
449
+ [#45]: https://github.com/lbframe/agent-doc-system/issues/45
450
+ [#46]: https://github.com/lbframe/agent-doc-system/issues/46
451
+ [#49]: https://github.com/lbframe/agent-doc-system/issues/49
452
+ [#50]: https://github.com/lbframe/agent-doc-system/issues/50
453
+ [#51]: https://github.com/lbframe/agent-doc-system/issues/51
454
+ [#55]: https://github.com/lbframe/agent-doc-system/issues/55
455
+ [#59]: https://github.com/lbframe/agent-doc-system/issues/59
456
+ [#63]: https://github.com/lbframe/agent-doc-system/issues/63
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 { canonicalPath, 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;
@@ -47,6 +48,12 @@ export class DatabaseAdapter extends Adapter {
47
48
  }
48
49
  }
49
50
  const pr = prov.add("observed", evidencePath, "migration-extractor", ["der:" + compRef + ":/bindings"]);
51
+ // Where this unit's migrations live, recorded in the graph so `impact`
52
+ // classifies a change from the graph rather than from a list of names.
53
+ // Authority over a directory, never ownership of a database: these records
54
+ // create no binding.
55
+ const recorded = recordMigrationDirs(ctx, unit, migrationDirs, ormConfig, pr);
56
+ const withDirs = (out) => (recorded.length ? { ...out, migrationDirs: recorded } : out);
50
57
 
51
58
  // A component that both migrates and opens a connection owns the database.
52
59
  const opensConnection = hasConnectionEvidence(ctx, unit);
@@ -65,7 +72,7 @@ export class DatabaseAdapter extends Adapter {
65
72
  evidenceClass: "DERIVED", confidence: "candidate", provRecs: [pr],
66
73
  semantics: "a schema exists here, but which component owns the logical database is unresolved",
67
74
  });
68
- return { migrationAuthority: evidencePath };
75
+ return withDirs({ migrationAuthority: evidencePath });
69
76
  }
70
77
 
71
78
  const name = compRef.split("/")[1];
@@ -88,7 +95,7 @@ export class DatabaseAdapter extends Adapter {
88
95
  evidenceClass: "DERIVED", confidence: "candidate", provRecs: [pr],
89
96
  semantics: "this component opens a database connection, but no authored Resource claims ownership",
90
97
  });
91
- return { migrationAuthority: evidencePath };
98
+ return withDirs({ migrationAuthority: evidencePath });
92
99
  }
93
100
  ctx.addFact(compRef, "resource.ownership", target.name, {
94
101
  evidenceClass: "DERIVED", confidence: "deterministic", provRecs: [pr],
@@ -100,10 +107,10 @@ export class DatabaseAdapter extends Adapter {
100
107
  const client = ctx.clientKeyForResourceType("logical-database");
101
108
  const attrs = client ? { via: "migrations+connection", client } : { via: "migrations+connection" };
102
109
  ctx.addEdge("usesResource", compRef, target.ref, attrs, [pr], "DERIVED");
103
- return {
110
+ return withDirs({
104
111
  migrationAuthority: evidencePath,
105
112
  bindings: [{ kind: "database", logicalResourceRef: target.ref, resolution: "resolved", provenanceIds: null, _provs: new Set([pr]) }],
106
- };
113
+ });
107
114
  }
108
115
  }
109
116
 
@@ -128,3 +135,145 @@ function findDatabaseResource(ctx, name) {
128
135
  );
129
136
  return cands.length === 1 ? cands[0] : null;
130
137
  }
138
+
139
+ // ── migration directories ───────────────────────────────────────────────────
140
+ //
141
+ // Two routes, each with its own evidence:
142
+ // - a directory named by convention (`migrations`, `migration`,
143
+ // `alembic/versions`), evidenced by a file inside it;
144
+ // - Drizzle Kit's `out`, evidenced by the config file that states it.
145
+ // Prisma Migrate writes to `prisma/migrations`, which the first route covers.
146
+ function recordMigrationDirs(ctx, unit, conventionDirs, ormConfig, pr) {
147
+ const { repo, prov, compRef } = ctx;
148
+ const subject = "der:" + compRef + ":/migrationDirs";
149
+ const byPath = new Map();
150
+ const add = (dir, via, rec) => {
151
+ if (!byPath.has(dir)) byPath.set(dir, { path: dir, via: new Set(), _provs: new Set() });
152
+ byPath.get(dir).via.add(via);
153
+ byPath.get(dir)._provs.add(rec);
154
+ };
155
+ for (const dir of conventionDirs) {
156
+ // A directory with no file in the input set has nothing a change could
157
+ // touch, and nothing to cite as evidence.
158
+ const file = repo.walkUnit(unit.root, dir)[0];
159
+ if (!file) continue;
160
+ let rec;
161
+ if (file === pr.path) {
162
+ rec = pr;
163
+ pr.subjects.add(subject);
164
+ } else {
165
+ rec = prov.add("observed", file, "migration-extractor", [subject]);
166
+ }
167
+ add(dir, /(^|\/)alembic\/versions$/.test(dir) ? "alembic-versions" : "directory-name", rec);
168
+ }
169
+ if (ormConfig && /(^|\/)drizzle\.config\.[cm]?[jt]s$/.test(ormConfig)) {
170
+ const dir = drizzleOutDir(ctx, unit, ormConfig);
171
+ if (dir) add(dir, "drizzle-config", prov.add("observed", ormConfig, "drizzle-config-extractor", [subject]));
172
+ }
173
+ return [...byPath.values()]
174
+ .sort((a, b) => (a.path < b.path ? -1 : 1))
175
+ .map((d) => ({ path: d.path, via: [...d.via].sort(), provenanceIds: null, _provs: d._provs }));
176
+ }
177
+
178
+ // Drizzle Kit writes migrations to `out`, `./drizzle` when absent. The config
179
+ // is code, and it is never executed: only a string literal is read. Anything
180
+ // else — an expression, an environment variable, a spread that could carry
181
+ // `out`, several `out` keys — is reported and not guessed, because a wrong
182
+ // directory would route migrations to the wrong database silently.
183
+ function drizzleOutDir(ctx, unit, configPath) {
184
+ const { repo, compRef } = ctx;
185
+ const unresolved = (why) => {
186
+ ctx.diagnostics.push({
187
+ severity: "warning",
188
+ code: "AGENTDOC_MIGRATION_DIR_UNRESOLVED",
189
+ subject: compRef,
190
+ refs: [compRef],
191
+ message:
192
+ "Drizzle migration directory in '" + configPath + "' is not recorded: " + why + ". " +
193
+ "Declare it with spec.migrationPaths on the logical-database Resource it migrates",
194
+ paths: [configPath],
195
+ });
196
+ return null;
197
+ };
198
+ const parsed = drizzleOut(repo.readText(configPath));
199
+ if (parsed.error) return unresolved(parsed.error);
200
+ const value = parsed.value ?? "./drizzle";
201
+ // Relative to the directory drizzle-kit runs from, which is the config's own
202
+ // directory in the documented layout (and the unit root here).
203
+ const base = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/")) : ".";
204
+ const dir = value.startsWith("/") ? null : canonicalPath(joinPath(base, value));
205
+ if (!dir || dir === "." || dir === canonicalPath(unit.root)) {
206
+ return unresolved("out " + JSON.stringify(value) + " is not a directory inside the unit at '" + unit.root + "'");
207
+ }
208
+ if (!repo.ownsUnitPath(unit.root, dir)) {
209
+ return unresolved("out " + JSON.stringify(value) + " is outside the unit at '" + unit.root + "'");
210
+ }
211
+ // Not generated yet: nothing to classify. The probe is an input, so the
212
+ // first generated migration invalidates the graph.
213
+ if (!repo.isDir(dir)) return null;
214
+ return dir;
215
+ }
216
+
217
+ // Static read of `out` from a Drizzle config. Comments are removed and string
218
+ // literals set aside first, so neither `// out: "x"` nor a string containing
219
+ // "out:" is mistaken for the key.
220
+ export function drizzleOut(text) {
221
+ const strings = [];
222
+ let code = "";
223
+ for (let i = 0; i < text.length; ) {
224
+ const c = text[i];
225
+ if (c === "/" && text[i + 1] === "/") {
226
+ while (i < text.length && text[i] !== "\n") i++;
227
+ continue;
228
+ }
229
+ if (c === "/" && text[i + 1] === "*") {
230
+ const end = text.indexOf("*/", i + 2);
231
+ i = end < 0 ? text.length : end + 2;
232
+ code += " ";
233
+ continue;
234
+ }
235
+ if (c === '"' || c === "'" || c === "`") {
236
+ let j = i + 1;
237
+ let value = "";
238
+ let closed = false;
239
+ while (j < text.length) {
240
+ if (text[j] === "\\") {
241
+ value += text[j + 1] ?? "";
242
+ j += 2;
243
+ continue;
244
+ }
245
+ if (text[j] === c) { closed = true; break; }
246
+ if (c !== "`" && text[j] === "\n") break;
247
+ value += text[j];
248
+ j++;
249
+ }
250
+ if (!closed) return { error: "the config could not be read statically" };
251
+ // A template with a substitution is an expression, not a literal.
252
+ strings.push(c === "`" && value.includes("${") ? null : value);
253
+ code += "\u0000" + (strings.length - 1) + "\u0000";
254
+ i = j + 1;
255
+ continue;
256
+ }
257
+ code += c;
258
+ i++;
259
+ }
260
+ const hits = [];
261
+ for (const m of code.matchAll(/(?:^|[{,\s])(?:out|\u0000(\d+)\u0000)\s*(:|[,}])/g)) {
262
+ // A quoted key counts only when it says "out".
263
+ if (m[1] !== undefined && strings[Number(m[1])] !== "out") continue;
264
+ hits.push(m);
265
+ }
266
+ if (hits.length === 0) {
267
+ // A spread could bring `out` in from elsewhere; the default is then a guess.
268
+ if (/\.\.\.\s*[A-Za-z_$(]/.test(code)) return { error: "the config spreads another object that may set out" };
269
+ return { value: null };
270
+ }
271
+ if (hits.length > 1) return { error: "out is set more than once" };
272
+ const m = hits[0];
273
+ if (m[2] !== ":") return { error: "out is a shorthand property, not a string literal" };
274
+ const rest = code.slice(m.index + m[0].length);
275
+ const lit = /^\s*\u0000(\d+)\u0000\s*(?:[,}]|$)/.exec(rest);
276
+ const value = lit ? strings[Number(lit[1])] : null;
277
+ if (typeof value !== "string" || value.trim() === "") return { error: "out is not a string literal" };
278
+ return { value };
279
+ }
@@ -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;