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
package/core/scaffold.mjs CHANGED
@@ -13,8 +13,14 @@ import fs from "node:fs";
13
13
  import path from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { AgentDocError, CODES } from "./codes.mjs";
16
- import { Repo } from "./fsx.mjs";
16
+ import { Repo, assertRepoPath } from "./fsx.mjs";
17
+ import { CONFIG_PATH } from "./descriptors.mjs";
18
+ import { discoverUnits, isNotUnitRoot } from "./discover.mjs";
17
19
  import { classifyContractFile } from "./contracts.mjs";
20
+ import { parse as parseToml } from "./toml.mjs";
21
+ import { cargoUnitShape, isExcludedWorkspaceMember, isVirtualWorkspaceManifest } from "../adapters/rust.mjs";
22
+ import { readPythonUnit } from "../adapters/python.mjs";
23
+ import { loadAdapters } from "../adapters/index.mjs";
18
24
 
19
25
  const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "templates");
20
26
 
@@ -40,23 +46,96 @@ function sentence(s) {
40
46
  return /[.!?]$/.test(t) ? t : t + ".";
41
47
  }
42
48
 
43
- export function installTemplates(repo, cfg, { write, root, force = false, eligible = null } = {}) {
49
+ // Where the documentation skeleton goes when no configuration exists yet. Once
50
+ // a configuration exists, it is the only authority: `docs.*` is what audit and
51
+ // compile read, so the scaffolder writes there and nowhere else (issue #49 —
52
+ // `docs/` is often the product's own user documentation).
53
+ //
54
+ // The default lives under `agentdoc/` (issue #59): authored AgentDoc material
55
+ // lives in `agentdoc/`, generated output in `.agentdoc/`. Not `docs/`, which is
56
+ // the product's user documentation (and a MkDocs/Docusaurus/Pages source), and
57
+ // not `.agentdoc/`, which the compiler skips and many repositories git-ignore.
58
+ export const DEFAULT_DOCS = Object.freeze({
59
+ product: "agentdoc/docs/PRODUCT.md",
60
+ architecture: "agentdoc/docs/ARCHITECTURE.md",
61
+ constraints: "agentdoc/docs/CONSTRAINTS.md",
62
+ adrDir: "agentdoc/docs/adr",
63
+ });
64
+
65
+ // The skeleton roles that have a template. `runbookDir` and `normative` are
66
+ // honoured by never writing anything for them: there is no runbook template,
67
+ // and normative documents are the project's own.
68
+ const SKELETON_ROLES = [
69
+ { key: "product", template: "PRODUCT.md", file: (p) => p },
70
+ { key: "architecture", template: "ARCHITECTURE.md", file: (p) => p },
71
+ { key: "constraints", template: "CONSTRAINTS.md", file: (p) => p },
72
+ { key: "adrDir", template: "ADR.md", file: (p) => p.replace(/\/+$/, "") + "/README.md" },
73
+ ];
74
+
75
+ // Resolve the documentation skeleton for a loaded configuration (`null` when
76
+ // there is none). A configured role is written to its configured path; a role
77
+ // a configuration leaves out is not written anywhere and is reported, so an
78
+ // omitted role can never fall back into `docs/`.
79
+ export function docsSkeleton(cfg) {
80
+ const docs = cfg ? cfg.docs || {} : DEFAULT_DOCS;
81
+ const targets = [];
82
+ const notConfigured = [];
83
+ for (const role of SKELETON_ROLES) {
84
+ const configured = docs[role.key];
85
+ if (!configured) { notConfigured.push("docs." + role.key); continue; }
86
+ // loadConfig already validated these; re-asserting keeps this function
87
+ // safe for any caller that hands it an unvalidated object.
88
+ assertRepoPath(configured, CONFIG_PATH);
89
+ const rel = role.file(configured);
90
+ assertRepoPath(rel, CONFIG_PATH);
91
+ targets.push({ key: role.key, path: rel, template: role.template });
92
+ }
93
+ return { targets, notConfigured, docs: cfg ? docs : DEFAULT_DOCS };
94
+ }
95
+
96
+ // Refuse a write target that leaves the repository, including through a
97
+ // symlinked directory or a (possibly dangling) symlink at the target itself.
98
+ // Lexical checks alone (`isRepoPath`) cannot see a symlink.
99
+ function assertWritableInside(root, rel) {
100
+ const abs = path.resolve(root, rel);
101
+ const inside = path.relative(path.resolve(root), abs);
102
+ if (!inside || inside === ".." || inside.startsWith(".." + path.sep) || path.isAbsolute(inside)) {
103
+ throw new AgentDocError(CODES.PATH_ESCAPE, "path escapes repository: " + rel, { path: rel });
104
+ }
105
+ const realRoot = fs.realpathSync(root);
106
+ let cur = path.resolve(root);
107
+ for (const seg of inside.split(path.sep)) {
108
+ cur = path.join(cur, seg);
109
+ let st;
110
+ try { st = fs.lstatSync(cur); } catch { return; } // nothing further exists yet
111
+ if (!st.isSymbolicLink()) continue;
112
+ let real = null;
113
+ try { real = fs.realpathSync(cur); } catch { /* dangling */ }
114
+ if (real === null || (real !== realRoot && !real.startsWith(realRoot + path.sep))) {
115
+ throw new AgentDocError(CODES.PATH_ESCAPE, "refusing to write through a symlink that leaves the repository: " + rel, { path: rel });
116
+ }
117
+ }
118
+ }
119
+
120
+ // `cfg` is the loaded configuration (scaffold) or null (init). `docsConfig`
121
+ // overrides the configuration the documentation paths are read from: init
122
+ // writes no component descriptors, but it must still honour an existing
123
+ // configuration's `docs.*` paths.
124
+ export function installTemplates(repo, cfg, { write, root, force = false, eligible = null, docsConfig } = {}) {
44
125
  const written = [];
45
126
  const skipped = [];
46
127
  const targetRoot = root || (repo ? repo.root : process.cwd());
47
128
  const r = repo || new Repo(targetRoot, { noGit: false });
129
+ const docsSource = docsConfig === undefined ? cfg : docsConfig;
130
+ const skeleton = docsSkeleton(docsSource);
48
131
 
49
- const put = (rel, content) => {
50
- const abs = path.join(targetRoot, rel);
51
- if (fs.existsSync(abs) && !force) { skipped.push(rel); return; }
52
- if (!write) { written.push(rel + " (dry run)"); return; }
53
- fs.mkdirSync(path.dirname(abs), { recursive: true });
54
- fs.writeFileSync(abs, content.endsWith("\n") ? content : content + "\n");
55
- written.push(rel);
56
- };
132
+ // Plan every file first and check every target before writing anything, so
133
+ // a refused path leaves the repository exactly as it was.
134
+ const plan = [];
135
+ const put = (rel, content) => { plan.push([rel, content]); };
57
136
 
58
137
  // ── configuration ───────────────────────────────────────────────────────
59
- const config = buildConfig(scanLayout(r));
138
+ const config = buildConfig(scanLayout(r, cfg), skeleton.docs);
60
139
  put("agentdoc/agentdoc.config.yaml", config);
61
140
  put("agentdoc/journeys.yaml", "schemaVersion: agentdoc.dev/journeys/v1\njourneys: []\n");
62
141
  for (const f of ["domains.yaml", "systems.yaml", "apis.yaml", "resources.yaml"]) {
@@ -64,12 +143,10 @@ export function installTemplates(repo, cfg, { write, root, force = false, eligib
64
143
  }
65
144
  put("agentdoc/observations/README.md", OBSERVATIONS_README);
66
145
 
67
- // ── documentation hierarchy ──────────────────────────────────────────────
68
- for (const f of ["PRODUCT.md", "ARCHITECTURE.md", "CONSTRAINTS.md"]) {
69
- const tpl = fs.readFileSync(path.join(TEMPLATES_DIR, f), "utf8");
70
- put("docs/" + f, tpl);
146
+ // ── documentation hierarchy (configured docs.* paths) ───────────────────
147
+ for (const t of skeleton.targets) {
148
+ put(t.path, fs.readFileSync(path.join(TEMPLATES_DIR, t.template), "utf8"));
71
149
  }
72
- put("docs/adr/README.md", fs.readFileSync(path.join(TEMPLATES_DIR, "ADR.md"), "utf8"));
73
150
  put(".github/workflows/agentdoc.yml", fs.readFileSync(path.join(TEMPLATES_DIR, "ci.yml"), "utf8"));
74
151
 
75
152
  // ── per-unit component descriptors ──────────────────────────────────────
@@ -83,26 +160,50 @@ export function installTemplates(repo, cfg, { write, root, force = false, eligib
83
160
  put(u.root + "/" + cfg.discovery.componentDescriptorName, componentDescriptor(u));
84
161
  }
85
162
  }
86
- return { written, skipped, note: write ? "files written" : "dry run: pass --write to apply" };
163
+
164
+ const seen = new Set();
165
+ for (const [rel] of plan) assertWritableInside(targetRoot, rel);
166
+ for (const [rel, content] of plan) {
167
+ if (seen.has(rel)) continue; // two roles configured onto one file: first wins
168
+ seen.add(rel);
169
+ const abs = path.join(targetRoot, rel);
170
+ if (fs.existsSync(abs) && !force) { skipped.push(rel); continue; }
171
+ if (!write) { written.push(rel + " (dry run)"); continue; }
172
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
173
+ fs.writeFileSync(abs, content.endsWith("\n") ? content : content + "\n");
174
+ written.push(rel);
175
+ }
176
+ return {
177
+ written,
178
+ skipped,
179
+ ...(docsSource && skeleton.notConfigured.length
180
+ ? {
181
+ notConfigured: skeleton.notConfigured,
182
+ notConfiguredNote: "the configuration declares no path for these roles, so no skeleton was written for them; add them under docs: to scaffold them",
183
+ }
184
+ : {}),
185
+ note: write ? "files written" : "dry run: pass --write to apply",
186
+ };
87
187
  }
88
188
 
89
189
  // Layout inference for CREATE mode: which directories look like unit roots.
90
- // Uses only file-presence heuristics that any repository can answer.
91
- function scanLayout(repo) {
190
+ // Combines shallow marker heuristics with the compiler's adapter discovery.
191
+ function scanLayout(repo, cfg) {
92
192
  const candidates = new Map();
93
193
  const note = (root, kind) => {
94
194
  if (!candidates.has(root)) candidates.set(root, { root, kinds: new Set(), evidence: [] });
95
195
  candidates.get(root).kinds.add(kind);
96
196
  candidates.get(root).evidence.push(kind);
97
197
  };
98
- const skip = new Set(["node_modules", ".git", "dist", "build", "vendor", "target", "agentdoc", "docs", "scripts", ".github", "test", "tests", "examples", "example"]);
198
+ const skip = new Set(["node_modules", ".git", "dist", "build", "vendor", "target", "test", "tests", "examples", "example"]);
99
199
  const walk = (dir, depth) => {
100
- if (depth > 3) return;
200
+ if (depth > 3 || isNotUnitRoot(dir, cfg)) return;
101
201
  let entries;
102
202
  try { entries = fs.readdirSync(path.join(repo.root, dir), { withFileTypes: true }); } catch { return; }
103
203
  for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
104
204
  if (e.isSymbolicLink() || skip.has(e.name)) continue;
105
205
  const rel = dir ? dir + "/" + e.name : e.name;
206
+ if (repo.isIgnored && repo.isIgnored(rel)) continue;
106
207
  if (e.isDirectory()) {
107
208
  if (e.name.startsWith(".")) continue;
108
209
  walk(rel, depth + 1);
@@ -114,15 +215,62 @@ function scanLayout(repo) {
114
215
  const parent = () => rel.slice(0, -("/" + e.name).length);
115
216
  if (e.name === "package.json") note(parent(), "node");
116
217
  if (e.name === "go.mod") note(parent(), "go");
218
+ if (e.name === "Cargo.toml" && cargoUnitShape(repo, parent()) && !isExcludedWorkspaceMember(repo, parent())) note(parent(), "rust");
219
+ if (e.name === "pyproject.toml" || e.name === "setup.py" || e.name === "setup.cfg") note(parent(), "python");
220
+ if (/^requirements.*\.txt$/.test(e.name)) note(parent(), "python");
117
221
  if (e.name === "Dockerfile") note(parent(), "docker");
118
222
  if (/^wrangler\.(toml|json|jsonc)$/.test(e.name)) note(parent(), "platform");
119
223
  if (e.name === "go.mod") note(rel.slice(0, -"/go.mod".length), "go");
120
224
  }
121
225
  };
122
226
  walk("", 0);
227
+ // Directory-name/depth heuristics must not hide units declared by adapters
228
+ // (for example a uv or Cargo member under examples/). During init there is
229
+ // no config yet: probe supported marker adapters so those members can also
230
+ // select their adapter in the generated configuration.
231
+ const discoveryCfg = cfg || { discovery: { componentDescriptors: [] } };
232
+ const adapters = loadAdapters(cfg ? cfg.adapters : Object.values(ADAPTER_FOR_MARKER));
233
+ for (const unit of discoverUnits(repo, discoveryCfg, adapters).eligible) {
234
+ if (!unit.root || unit.root === "." || isNotUnitRoot(unit.root, cfg)) continue;
235
+ for (const [kind, adapter] of Object.entries(ADAPTER_FOR_MARKER)) {
236
+ if (unit.markers.some((marker) => marker.adapter === adapter)) note(unit.root, kind);
237
+ }
238
+ }
239
+ for (const u of [...candidates.values()]) {
240
+ // Layout-only candidates need the same artifact evidence as units already
241
+ // discovered by the compiler. A technology name alone proves no shape.
242
+ u.deployable = false;
243
+ u.importable = false;
244
+ for (const adapter of loadAdapters([...u.kinds].map((k) => ADAPTER_FOR_MARKER[k]))) {
245
+ const marker = adapter.detect({ repo, cfg }, u.root || ".");
246
+ if (!marker) continue;
247
+ const shape = marker.shape || "both";
248
+ u.deployable ||= shape === "deployable" || shape === "both";
249
+ u.importable ||= shape === "importable" || shape === "both";
250
+ }
251
+ if (!u.kinds.has("python")) continue;
252
+ const info = readPythonUnit(repo, u.root);
253
+ // A uv workspace root with no distribution is not a unit. Scaffolding it
254
+ // would invent a component for the virtual manifest.
255
+ if (!info.dist && !info.deployable && !info.importable && !info.error && virtualUvWorkspace(repo, u.root)) {
256
+ candidates.delete(u.root);
257
+ }
258
+ }
123
259
  return [...candidates.values()].sort((a, b) => (a.root < b.root ? -1 : 1));
124
260
  }
125
261
 
262
+ function virtualUvWorkspace(repo, root) {
263
+ const file = !root || root === "." ? "pyproject.toml" : root + "/pyproject.toml";
264
+ if (!repo.exists(file)) return false;
265
+ try {
266
+ const doc = parseToml(repo.readText(file), file);
267
+ const ws = doc.tool && doc.tool.uv && doc.tool.uv.workspace;
268
+ return Boolean(ws && typeof ws === "object" && !Array.isArray(ws));
269
+ } catch {
270
+ return false;
271
+ }
272
+ }
273
+
126
274
  function discoverUnitNames(repo, cfg, eligible = null) {
127
275
  const out = [];
128
276
  const nameFor = (root) => {
@@ -138,6 +286,18 @@ function discoverUnitNames(repo, cfg, eligible = null) {
138
286
  const m = /^module\s+(\S+)/m.exec(repo.readText(gomod));
139
287
  if (m) return kebab(m[1].split("/").pop());
140
288
  }
289
+ const cargo = root + "/Cargo.toml";
290
+ if (repo.exists(cargo)) {
291
+ try {
292
+ const doc = parseToml(repo.readText(cargo));
293
+ const n = doc && doc.package && typeof doc.package.name === "string" ? doc.package.name : "";
294
+ // `_` becomes `-u-` before kebab, so `foo_bar` does not collapse into
295
+ // the same component name as `foo-bar`.
296
+ if (n) return kebab(n.replace(/_/g, "-u-"));
297
+ } catch { /* a manifest that does not parse is not a name */ }
298
+ }
299
+ const python = readPythonUnit(repo, root);
300
+ if (python.dist) return kebab(python.dist);
141
301
  return kebab(path.basename(root));
142
302
  };
143
303
  const seen = new Set();
@@ -152,27 +312,32 @@ function discoverUnitNames(repo, cfg, eligible = null) {
152
312
  deployable: Boolean(u.deployable),
153
313
  importable: Boolean(u.importable),
154
314
  })),
155
- ...scanLayout(repo).map((u) => ({ root: u.root, kinds: [...u.kinds].sort(), deployable: false, importable: false })),
315
+ ...scanLayout(repo, cfg).map((u) => ({
316
+ root: u.root,
317
+ kinds: [...u.kinds].sort(),
318
+ deployable: Boolean(u.deployable),
319
+ importable: Boolean(u.importable),
320
+ })),
156
321
  ];
157
- for (const u of units) {
158
- if (u.root === "." || !u.root || seenRoots.has(u.root)) continue;
322
+ for (const found of units) {
323
+ if (found.root === "." || !found.root || seenRoots.has(found.root)) continue;
324
+ if (isNotUnitRoot(found.root, cfg)) continue;
325
+ if (isVirtualWorkspaceManifest(repo, found.root)) continue;
326
+ const u = found;
159
327
  seenRoots.add(u.root);
160
328
  const name = nameFor(u.root);
161
329
  if (seen.has(name)) continue;
162
330
  seen.add(name);
163
331
  out.push({ ...u, name });
164
332
  }
165
- void cfg;
166
333
  return out;
167
334
  }
168
335
 
169
336
  function componentDescriptor(u) {
170
- const deployable = u.deployable || u.kinds.some((k) => k === "docker" || k === "platform");
171
- const importable = u.importable || u.kinds.some((k) => k === "node" || k === "go");
172
337
  // "library" and "service" both have to be corroborated by artifact evidence
173
338
  // (AGENTDOC_ARTIFACT_TYPE); a unit the compiler only knows from configuration
174
339
  // shows neither, so claiming a concrete type would manufacture an error.
175
- const type = deployable ? "service" : importable ? "library" : "other";
340
+ const type = u.deployable ? "service" : u.importable ? "library" : "other";
176
341
  return [
177
342
  "apiVersion: agentdoc.dev/v1",
178
343
  "kind: Component",
@@ -194,11 +359,13 @@ function componentDescriptor(u) {
194
359
  const ADAPTER_FOR_MARKER = {
195
360
  node: "node",
196
361
  go: "go",
362
+ rust: "rust",
363
+ python: "python",
197
364
  docker: "dockerfile",
198
365
  platform: "wrangler",
199
366
  };
200
367
 
201
- function buildConfig(layout) {
368
+ function buildConfig(layout, docs = DEFAULT_DOCS) {
202
369
  const roots = layout.map((l) => l.root);
203
370
  const groups = new Map();
204
371
  for (const r of roots) {
@@ -206,13 +373,6 @@ function buildConfig(layout) {
206
373
  if (!groups.has(head)) groups.set(head, []);
207
374
  groups.get(head).push(r);
208
375
  }
209
- // A manifest at the repository root yields an empty head; emitting
210
- // "/agentdoc.yaml" would be an absolute glob, which the loader rejects.
211
- const componentGlobs = [...groups.entries()]
212
- .filter(([head]) => head.length > 0)
213
- .sort((a, b) => (a[0] < b[0] ? -1 : 1))
214
- .map(([head, list]) => (list.length === 1 && list[0] === head ? head + "/agentdoc.yaml" : head + "/**/agentdoc.yaml"));
215
- if (!componentGlobs.length) componentGlobs.push("*/**/agentdoc.yaml");
216
376
 
217
377
  const adapters = new Set(["generic"]);
218
378
  for (const l of layout) {
@@ -239,9 +399,10 @@ function buildConfig(layout) {
239
399
  "discovery:",
240
400
  " componentDescriptorName: agentdoc.yaml",
241
401
  " componentDescriptors:",
242
- // Globs are quoted: an unquoted leading "*" is an alias in strict YAML,
243
- // and a configuration the compiler cannot parse is worse than none.
244
- ...componentGlobs.map((g) => " - " + yamlStr(g)),
402
+ // Discover every scaffolded descriptor, including adapter roots outside
403
+ // the shallow layout and units added after init. Keep the leading glob
404
+ // quoted because an unquoted "*" is an alias in strict YAML.
405
+ ' - "*/**/agentdoc.yaml"',
245
406
  " centralDescriptors:",
246
407
  " - agentdoc/domains.yaml",
247
408
  " - agentdoc/systems.yaml",
@@ -281,15 +442,26 @@ function buildConfig(layout) {
281
442
  " graph: .agentdoc/graph.json",
282
443
  " observations: agentdoc/observations",
283
444
  "",
284
- "docs:",
285
- " product: docs/PRODUCT.md",
286
- " architecture: docs/ARCHITECTURE.md",
287
- " constraints: docs/CONSTRAINTS.md",
288
- " adrDir: docs/adr",
445
+ ...docsSection(docs),
289
446
  "",
290
447
  ].join("\n");
291
448
  }
292
449
 
450
+ // The `docs:` block of a generated configuration. It repeats the configured
451
+ // paths (init --force regenerates the file and must not move documentation
452
+ // elsewhere), or the agentdoc/docs/ defaults when there is no configuration yet.
453
+ function docsSection(docs) {
454
+ const scalar = (v) => (/^[A-Za-z0-9._/-]+$/.test(v) ? v : yamlStr(v));
455
+ const lines = [];
456
+ for (const key of ["product", "architecture", "constraints", "adrDir", "runbookDir"]) {
457
+ if (docs[key]) lines.push(" " + key + ": " + scalar(docs[key]));
458
+ }
459
+ if (Array.isArray(docs.normative) && docs.normative.length) {
460
+ lines.push(" normative:", ...docs.normative.map((g) => " - " + yamlStr(g)));
461
+ }
462
+ return lines.length ? ["docs:", ...lines] : ["# docs: {} # no documentation roles configured"];
463
+ }
464
+
293
465
  const OBSERVATIONS_README = `# Runtime observations
294
466
 
295
467
  An ObservationSet records what an authoritative external system actually said,
@@ -7,6 +7,7 @@
7
7
  // knows the namespace convention.
8
8
  import path from "node:path";
9
9
  import fs from "node:fs";
10
+ import { joinPath } from "./paths.mjs";
10
11
 
11
12
  const SPECIFIER_RE = /["'`]([A-Za-z@][A-Za-z0-9@._/+-]*(?:\.[A-Za-z0-9@._/+-]+)*(?:\/[A-Za-z0-9@._+*-]+)*)["'`]/g;
12
13
 
@@ -27,7 +28,7 @@ export function sourceFiles(repo, root, { test = false, extensions } = {}) {
27
28
  const DEFAULT_EXT = /\.(ts|tsx|js|jsx|mts|cts|mjs|cjs|go|py|rb|java|kt|rs|ex|exs|cs|php|swift|scala|sh)$/;
28
29
  const re = extensions || DEFAULT_EXT;
29
30
  const out = [];
30
- for (const f of repo.walk(root)) {
31
+ for (const f of repo.walkUnit(root)) {
31
32
  if (!re.test(f)) continue;
32
33
  const isTest = TEST_PATH.test(f) || TEST_FILE.test(f) || /_test\.[a-z]+$/.test(f);
33
34
  if (isTest !== test) continue;
@@ -64,7 +65,17 @@ export function findMigrationDirs(repo, root, maxDepth = 5) {
64
65
  if (!e.isDirectory() || e.isSymbolicLink()) continue;
65
66
  if (["node_modules", ".git", "vendor", "target", "dist", "build", ".next", "__pycache__"].includes(e.name)) continue;
66
67
  const abs = path.join(dirAbs, e.name);
68
+ const rel = path.relative(repo.root, abs).split(path.sep).join("/");
69
+ if (!repo.ownsUnitPath(root, rel)) continue;
70
+ if (repo.isIgnored && repo.isIgnored(rel)) continue;
67
71
  if (e.name === "migrations" || e.name === "migration") {
72
+ out.push(rel);
73
+ continue;
74
+ }
75
+ // Alembic keeps revisions in versions/ under a directory named alembic.
76
+ // The directory is the migration root; the adapter still reads alembic.ini
77
+ // (or [tool.alembic]) before treating it as ownership evidence.
78
+ if (e.name === "versions" && path.basename(dirAbs) === "alembic") {
68
79
  out.push(path.relative(repo.root, abs).split(path.sep).join("/"));
69
80
  continue;
70
81
  }
@@ -77,7 +88,8 @@ export function findMigrationDirs(repo, root, maxDepth = 5) {
77
88
 
78
89
  export function firstExistingFile(repo, root, names) {
79
90
  for (const n of names) {
80
- if (repo.exists(root + "/" + n)) return root + "/" + n;
91
+ const p = joinPath(root, n);
92
+ if (repo.ownsUnitPath(root, p) && repo.exists(p)) return p;
81
93
  }
82
94
  return null;
83
95
  }