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
@@ -0,0 +1,272 @@
1
+ // A deterministic working-tree reader. No graph, adapters, routing or scenario
2
+ // truth enters retrieval; the harness alone scores the returned facts/checks.
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { parseYamlDocuments } from "../core/yaml.mjs";
6
+ import { parse as parseToml } from "../core/toml.mjs";
7
+ import { fact, addRelationFacts } from "./facts.mjs";
8
+
9
+ const SKIP = new Set([".git", ".agentdoc", "node_modules", ".venv", "venv", "__pycache__", "target", "dist", "build", ".next"]);
10
+ const TEXT = /\.(?:[cm]?[jt]sx?|go|rs|py|proto|sql|md|rst|txt|json|ya?ml|toml|ini|cfg|mod|work|lock)$/;
11
+ const SOURCE = /\.(?:[cm]?[jt]sx?|go|rs|py)$/;
12
+ const MANIFEST = /(?:^|\/)(?:package.json|Cargo.toml|pyproject.toml|go.mod|setup.cfg|setup.py|requirements[^/]*\.txt)$/;
13
+ const CHECK = /(?:test|lint|typecheck|check|clippy|vet|pytest|ruff|mypy|unittest)/i;
14
+ const under = (file, dir) => dir === "." || file === dir || file.startsWith(dir + "/");
15
+ const slug = (s) => s.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
16
+ const quoted = (text) => [...text.matchAll(/["'`]([^"'`\n]+)["'`]/g)].map((m) => m[1]);
17
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
18
+ const mentions = (text, name) => new RegExp("(?<![\\w@/.-])" + esc(name) + "(?![\\w@/.-])").test(text);
19
+
20
+ function filesIn(root, dir = "") {
21
+ const out = [];
22
+ for (const e of fs.readdirSync(path.join(root, dir), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name, "en"))) {
23
+ const rel = dir ? dir + "/" + e.name : e.name;
24
+ if (e.isDirectory() && !SKIP.has(e.name)) out.push(...filesIn(root, rel));
25
+ else if (e.isFile() && e.name !== "graph.json" && (TEXT.test(rel) || e.name === "Dockerfile")) out.push(rel);
26
+ }
27
+ return out;
28
+ }
29
+
30
+ function readData(text, file) {
31
+ try {
32
+ if (file.endsWith(".json")) return JSON.parse(text);
33
+ if (file.endsWith(".toml")) return parseToml(text, file);
34
+ } catch { /* Malformed manifests remain searchable text. */ }
35
+ return {};
36
+ }
37
+
38
+ function imports(file, text) {
39
+ if (/\.[cm]?[jt]sx?$/.test(file)) return [...text.matchAll(/(?:\bfrom\s*|\bimport\s*(?:\(\s*)?|\brequire\s*\(\s*)["']([^"']+)["']/g)].map((m) => m[1]);
40
+ if (file.endsWith(".go")) return quoted(text);
41
+ if (file.endsWith(".rs")) {
42
+ const local = new Set([...text.matchAll(/\bmod\s+(\w+)\s*[;{]/g)].map((m) => m[1]));
43
+ return [...text.matchAll(/\b(?:use|extern\s+crate)\s+(\w+)|\b(\w+)::/g)].map((m) => m[1] || m[2]).filter((n) => !local.has(n));
44
+ }
45
+ if (file.endsWith(".py")) return [...text.matchAll(/^\s*(?:from|import)\s+([\w.]+)/gm)].map((m) => m[1]);
46
+ return [];
47
+ }
48
+
49
+ function unitNames(unit, entries) {
50
+ const names = new Set();
51
+ for (const [file, text] of entries) {
52
+ const data = readData(text, file);
53
+ if (file === path.posix.join(unit.dir, "package.json") && data.name) names.add(data.name);
54
+ if (file === path.posix.join(unit.dir, "Cargo.toml") && data.package?.name) {
55
+ names.add(data.package.name);
56
+ names.add(data.package.name.replace(/-/g, "_"));
57
+ }
58
+ if (file === path.posix.join(unit.dir, "go.mod")) {
59
+ const module = /^module\s+(\S+)/m.exec(text)?.[1];
60
+ if (module) names.add(module);
61
+ }
62
+ if (path.posix.basename(file) === "pyproject.toml") {
63
+ const name = data.project?.name || data.tool?.poetry?.name;
64
+ if (name) names.add(name);
65
+ }
66
+ const rel = path.posix.relative(unit.dir, file);
67
+ const py = /^(?:src\/)?([\w]+)\/(?:__init__|__main__)\.py$/.exec(rel);
68
+ if (py) names.add(py[1]);
69
+ }
70
+ return [...names];
71
+ }
72
+
73
+ function externalNames(file, text) {
74
+ const data = readData(text, file);
75
+ if (file.endsWith("package.json")) return Object.keys({ ...data.dependencies, ...data.devDependencies, ...data.peerDependencies, ...data.optionalDependencies });
76
+ if (file.endsWith("Cargo.toml")) return Object.keys({ ...data.dependencies, ...data["dev-dependencies"], ...data["build-dependencies"] });
77
+ if (file.endsWith("go.mod")) return [...text.matchAll(/^\s*(?:require\s+)?([\w.-]+\/[\w/.-]+)\s+v\S+/gm)].map((m) => m[1]);
78
+ const requirements = [...(data.project?.dependencies || []), ...Object.values(data.project?.["optional-dependencies"] || {}).flat(), ...Object.values(data["dependency-groups"] || {}).flat()];
79
+ if (/requirements[^/]*\.txt$/.test(file)) requirements.push(...text.split("\n"));
80
+ return requirements.filter((s) => typeof s === "string").map((s) => /^([\w.-]+)/.exec(s)?.[1]).filter(Boolean);
81
+ }
82
+
83
+ export function searchContext(root, touch) {
84
+ const touches = touch.map((t) => t.replace(/^\.\//, "").replace(/\/$/, ""));
85
+ const content = new Map();
86
+ const units = [];
87
+ for (const file of filesIn(root)) {
88
+ const text = fs.readFileSync(path.join(root, file), "utf8");
89
+ if (text.includes("\0")) continue;
90
+ // Descriptors supply identity and path ownership only. All catalog data,
91
+ // including observations, journeys and relation declarations, is excluded.
92
+ if (/apiVersion:\s*agentdoc\.dev\//.test(text)) {
93
+ if (/\.ya?ml$/.test(file)) {
94
+ for (const { doc: d } of parseYamlDocuments(text, file)) {
95
+ if (d.kind !== "Component") continue;
96
+ units.push({
97
+ ref: "component:" + (d.metadata.namespace || "default") + "/" + d.metadata.name,
98
+ name: d.metadata.name,
99
+ dir: path.posix.dirname(file),
100
+ docs: Object.values(d.spec?.context || {}).filter(Array.isArray).flat().filter((p) => typeof p === "string"),
101
+ });
102
+ }
103
+ }
104
+ continue;
105
+ }
106
+ if (/(?:^|\/)agentdoc\//.test(file)) continue;
107
+ content.set(file, text);
108
+ }
109
+ units.sort((a, b) => b.dir.length - a.dir.length || a.ref.localeCompare(b.ref, "en"));
110
+ const owner = (file) => units.find((u) => u.docs.includes(file)) || units.find((u) => under(file, u.dir));
111
+ const entries = new Map(units.map((u) => [u, [...content].filter(([f]) => owner(f) === u)]));
112
+ for (const u of units) u.names = unitNames(u, entries.get(u));
113
+ const links = new Map(units.map((u) => [u, new Set()]));
114
+ const link = (a, b) => {
115
+ if (!a || !b || a === b) return;
116
+ links.get(a).add(b);
117
+ links.get(b).add(a);
118
+ };
119
+ for (const [file, text] of content) {
120
+ const from = owner(file);
121
+ if (!from || (!SOURCE.test(file) && !MANIFEST.test(file))) continue;
122
+ const names = imports(file, text);
123
+ for (const to of units) {
124
+ if (from === to) continue;
125
+ const found = to.names.some((n) => {
126
+ if (MANIFEST.test(file) && mentions(text, n)) return true;
127
+ return names.some((i) => {
128
+ if (file.endsWith(".py") && [...entries.get(from)].some(([p]) => p.endsWith("/" + i.split(".")[0] + ".py"))) return false;
129
+ return i === n || i.startsWith(n + "/") || i.startsWith(n + ".");
130
+ });
131
+ });
132
+ if (found) link(from, to);
133
+ }
134
+ for (const name of names.filter((n) => n.startsWith("."))) link(from, owner(path.posix.normalize(path.posix.join(path.posix.dirname(file), name))));
135
+ // Local path dependencies are visible even when their import name differs.
136
+ if (MANIFEST.test(file)) {
137
+ for (const m of text.matchAll(/(?:path\s*=|file:|link:)\s*["']?([^"'\s,}]+)/g)) link(from, owner(path.posix.normalize(path.posix.join(path.posix.dirname(file), m[1]))));
138
+ }
139
+ }
140
+
141
+ const contracts = [];
142
+ for (const [file, text] of content) {
143
+ if (/\.proto$/.test(file)) {
144
+ for (const m of text.matchAll(/\bservice\s+(\w+)\s*\{/g)) contracts.push({ file, ref: "api:default/" + slug(m[1]), paths: [], service: m[1] });
145
+ } else if (/^openapi:\s/m.test(text)) {
146
+ let doc;
147
+ try { [{ doc }] = parseYamlDocuments(text, file); } catch { continue; }
148
+ if (doc?.info?.title) contracts.push({ file, ref: "api:default/" + slug(doc.info.title).replace(/-api$/, "") + "-api", paths: Object.keys(doc.paths || {}) });
149
+ }
150
+ }
151
+ for (const c of contracts) {
152
+ c.providers = new Set();
153
+ c.readers = new Set();
154
+ for (const u of units) {
155
+ for (const [file, text] of entries.get(u)) {
156
+ if (text.includes(c.file)) c.readers.add(u);
157
+ if (!SOURCE.test(file)) continue;
158
+ if ((c.service && text.includes("Register" + c.service + "Server")) || c.paths.some((p) => quoted(text).includes(p) || file.endsWith("/app" + p + "/route.ts"))) c.providers.add(u);
159
+ }
160
+ }
161
+ const related = [...new Set([...c.readers, ...c.providers])];
162
+ for (const a of related) for (const b of related) link(a, b);
163
+ }
164
+ // Exact endpoint literals (including URL pathnames) connect clients and route
165
+ // owners. No hostname-to-component or dynamic-string resolution is assumed.
166
+ const routes = new Map();
167
+ for (const [file, text] of content) {
168
+ const u = owner(file);
169
+ if (!u || (!SOURCE.test(file) && !file.endsWith(".toml"))) continue;
170
+ const paths = quoted(text).flatMap((s) => {
171
+ if (/^https?:\/\//.test(s)) { try { return [new URL(s).pathname]; } catch { return []; } }
172
+ return s.startsWith("/") ? [s] : [];
173
+ });
174
+ const route = /\/app(\/api\/.*)\/route\.[jt]s$/.exec(file)?.[1];
175
+ if (route) paths.push(route);
176
+ for (const p of paths.filter((p) => p !== "/")) {
177
+ if (!routes.has(p)) routes.set(p, new Set());
178
+ for (const other of routes.get(p)) link(u, other);
179
+ routes.get(p).add(u);
180
+ }
181
+ }
182
+
183
+ const reached = new Set();
184
+ for (const file of touches) {
185
+ const u = units.find((u) => u.ref === file) || owner(file);
186
+ if (u) reached.add(u);
187
+ for (const c of contracts.filter((c) => c.file === file)) for (const related of [...c.readers, ...c.providers]) reached.add(related);
188
+ }
189
+ for (const u of reached) for (const other of links.get(u)) reached.add(other);
190
+
191
+ const facts = new Set();
192
+ const checks = [];
193
+ const addCheck = (u, command) => {
194
+ if (!command) return;
195
+ const componentRef = u?.ref || null;
196
+ if (!checks.some((c) => c.componentRef === componentRef && c.command === command)) checks.push({ componentRef, command });
197
+ facts.add(fact("verify", command));
198
+ };
199
+ for (const u of reached) {
200
+ facts.add(fact("ref", u.ref));
201
+ const own = entries.get(u);
202
+ const text = own.map(([, t]) => t).join("\n");
203
+ for (const [file, body] of own) {
204
+ if (/\.(md|rst|txt)$/.test(file)) {
205
+ const type = /constraint/i.test(path.posix.basename(file)) ? "constraint" : /runbook/i.test(file) ? "runbook" : "doc";
206
+ facts.add(fact(type, file));
207
+ }
208
+ if (SOURCE.test(file)) {
209
+ for (const line of body.split("\n").filter((l) => /subject|event|topic|publish|subscribe/i.test(l))) {
210
+ for (const s of quoted(line)) if (/^[a-z][\w-]*(?:\.[\w-]+){2,}$/.test(s)) facts.add(fact("event", s));
211
+ }
212
+ }
213
+ if (MANIFEST.test(file)) for (const name of externalNames(file, body)) {
214
+ if (!units.some((other) => other.names.includes(name))) facts.add(fact("external", name));
215
+ }
216
+ if (path.posix.basename(file) === "package.json") {
217
+ const pkg = readData(body, file);
218
+ for (const [name, command] of Object.entries(pkg.scripts || {})) if (CHECK.test(name)) addCheck(u, command);
219
+ const turbo = readData(content.get("turbo.json") || "{}", "turbo.json");
220
+ for (const name of Object.keys(turbo.tasks || turbo.pipeline || {})) if (CHECK.test(name) && pkg.name) addCheck(u, "turbo run " + name + " --filter=" + pkg.name);
221
+ }
222
+ if (path.posix.basename(file) === "go.mod") { addCheck(u, "go test ./..."); addCheck(u, "go vet ./..."); }
223
+ if (path.posix.basename(file) === "Cargo.toml") {
224
+ const name = readData(body, file).package?.name;
225
+ if (name) { addCheck(u, "cargo test -p " + name); addCheck(u, "cargo clippy -p " + name); }
226
+ }
227
+ }
228
+ const python = own.some(([f]) => f.endsWith(".py"));
229
+ if (python) {
230
+ const ancestors = [...content.keys()].filter((f) => /(?:uv|poetry)\.lock$/.test(f) && under(u.dir, path.posix.dirname(f))).sort((a, b) => b.length - a.length);
231
+ const prefix = ancestors[0]?.endsWith("uv.lock") ? "uv run " : ancestors[0] ? "poetry run " : "";
232
+ for (const tool of ["pytest", "ruff", "mypy"]) if (new RegExp("\\b" + tool + "\\b").test(text)) addCheck(u, prefix + (tool === "ruff" ? "ruff check" : tool));
233
+ if (/\bunittest\b/.test(text) && !/\bpytest\b/.test(text)) addCheck(u, "python -m unittest discover");
234
+ }
235
+ // Resource identities are naming conventions, not catalog lookups.
236
+ if (/postgres(?:ql)?(?:\+[\w]+)?:\/\/|sql\.Open\("postgres"|\b(?:pg|psycopg|asyncpg|sqlx|diesel)\b/.test(text) || own.some(([f]) => /\/migrations\/.*\.sql$/.test(f))) {
237
+ addRelationFacts(facts, "usesResource", "resource:default/" + u.name + "-postgres");
238
+ }
239
+ if (/\bredis\b/i.test(text)) addRelationFacts(facts, "usesResource", "resource:default/" + u.name + "-redis");
240
+ for (const m of text.matchAll(/(?:bucket|queue_name)\s*=\s*["']([^"']+)["']/g)) addRelationFacts(facts, "usesResource", "resource:default/" + slug(m[1]));
241
+ }
242
+ for (const c of contracts) {
243
+ const touched = touches.includes(c.file);
244
+ if (!touched && ![...c.readers, ...c.providers].some((u) => reached.has(u))) continue;
245
+ facts.add(fact("ref", c.ref));
246
+ if (touched || [...c.providers].some((u) => reached.has(u))) facts.add(fact("provides", c.ref));
247
+ if ([...c.readers].some((u) => reached.has(u) && !c.providers.has(u))) facts.add(fact("consumes", c.ref));
248
+ for (const u of c.providers) facts.add(fact("provider", u.ref));
249
+ for (const u of c.readers) if (!c.providers.has(u)) facts.add(fact("consumer", u.ref));
250
+ }
251
+ for (const [file, text] of content) {
252
+ if (!/^\.github\/workflows\/.*\.ya?ml$/.test(file)) continue;
253
+ let workflow;
254
+ try { [{ doc: workflow }] = parseYamlDocuments(text, file); } catch { continue; }
255
+ for (const job of Object.values(workflow?.jobs || {})) {
256
+ for (const step of job.steps || []) {
257
+ if (typeof step.run !== "string") continue;
258
+ const cwd = step["working-directory"] || job.defaults?.run?.["working-directory"];
259
+ for (const command of step.run.split("\n").map((s) => s.trim()).filter(Boolean)) {
260
+ if (!CHECK.test(command)) continue;
261
+ if (cwd) { const u = owner(cwd); if (reached.has(u)) addCheck(u, command); }
262
+ else {
263
+ const scoped = units.filter((u) => u.names.some((n) => mentions(command, n)));
264
+ if (scoped.length) { for (const u of scoped) if (reached.has(u)) addCheck(u, command); }
265
+ else if (!/--filter|\bcd\b|\$\{\{/.test(command)) addCheck(null, command);
266
+ }
267
+ }
268
+ }
269
+ }
270
+ }
271
+ return { facts, checks, reached: [...reached].map((u) => u.ref).sort() };
272
+ }
@@ -0,0 +1,28 @@
1
+ // The scoring vocabulary is independent of how a retrieval system found a fact.
2
+ export function fact(type, value) {
3
+ return type === "ref" ? value : type + ":" + value;
4
+ }
5
+
6
+ const RELATIONS = {
7
+ usesResource: ["uses", "usedBy"],
8
+ resourceUsedBy: ["usedBy", "uses"],
9
+ providesApi: ["provides", "provider"],
10
+ apiProvidedBy: ["provider", "provides"],
11
+ consumesApi: ["consumes", "consumer"],
12
+ apiConsumedBy: ["consumer", "consumes"],
13
+ };
14
+
15
+ export function addRelationFacts(out, type, other, direction = "out") {
16
+ // Holding a relation also identifies its other endpoint, as query's
17
+ // neighbour summaries do. Component and placement links use bare refs.
18
+ out.add(fact("ref", other));
19
+ const labels = RELATIONS[type];
20
+ if (labels) out.add(fact(labels[direction === "in" ? 1 : 0], other));
21
+ }
22
+
23
+ export function addGraphRelations(out, graph, ref) {
24
+ for (const r of graph.relations) {
25
+ if (r.sourceRef === ref) addRelationFacts(out, r.type, r.targetRef);
26
+ if (r.targetRef === ref) addRelationFacts(out, r.type, r.sourceRef, "in");
27
+ }
28
+ }