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.
- package/CHANGELOG.md +402 -0
- package/README.md +38 -0
- package/adapters/ansible.mjs +1 -1
- package/adapters/cache.mjs +9 -2
- package/adapters/contract-consumers.mjs +1 -1
- package/adapters/database.mjs +19 -4
- package/adapters/deploy-config.mjs +3 -2
- package/adapters/dockerfile.mjs +3 -2
- package/adapters/events.mjs +1 -1
- package/adapters/generic.mjs +2 -1
- package/adapters/github-actions.mjs +245 -85
- package/adapters/go.mjs +5 -4
- package/adapters/index.mjs +3 -1
- package/adapters/node.mjs +3 -2
- package/adapters/object-store.mjs +2 -2
- package/adapters/python.mjs +1142 -0
- package/adapters/resource-client.mjs +1 -1
- package/adapters/rust.mjs +990 -0
- package/adapters/turborepo.mjs +3 -2
- package/adapters/typescript.mjs +7 -5
- package/adapters/wrangler.mjs +3 -2
- package/bin/agentdoc.mjs +11 -4
- package/bin/usage.txt +2 -0
- package/core/audit.mjs +12 -8
- package/core/codes.mjs +4 -0
- package/core/compile.mjs +131 -16
- package/core/dependents.mjs +42 -0
- package/core/derive.mjs +4 -3
- package/core/descriptors.mjs +3 -2
- package/core/discover.mjs +61 -9
- package/core/fsx.mjs +185 -36
- package/core/graph.mjs +1 -1
- package/core/impact.mjs +66 -48
- package/core/paths.mjs +71 -0
- package/core/query.mjs +76 -31
- package/core/scaffold.mjs +216 -44
- package/core/sourcescan.mjs +14 -2
- package/core/toml.mjs +463 -140
- package/docs/ADAPTERS.md +196 -6
- package/docs/BENCHMARK.md +130 -0
- package/docs/CI.md +7 -1
- package/docs/EVALS.md +239 -16
- package/docs/MIGRATION.md +26 -6
- package/docs/PROVENANCE.md +6 -0
- package/docs/QUERY.md +78 -7
- package/docs/SPEC.md +69 -2
- package/docs/ci/github-actions.yml +1 -1
- package/evals/baseline-search.mjs +272 -0
- package/evals/facts.mjs +28 -0
- package/evals/harness.mjs +149 -119
- package/evals/scenarios/fixture-b-03-change-shared-lib.json +8 -1
- package/evals/scenarios/fixture-c-03-shared-vocabulary.json +7 -1
- package/evals/scenarios/fixture-e-01-change-shared-lib.json +14 -0
- package/evals/scenarios/fixture-e-02-change-manifest.json +9 -0
- package/evals/scenarios/fixture-e-03-local-module.json +9 -0
- package/evals/scenarios/fixture-e-04-ambiguous-crate.json +14 -0
- package/evals/scenarios/fixture-f-01-change-shared-lib.json +9 -0
- package/evals/scenarios/fixture-f-02-change-manifest.json +9 -0
- package/evals/scenarios/fixture-f-03-shadowed-import.json +9 -0
- package/evals/scenarios/fixture-f-04-nested-workspace.json +9 -0
- package/evals/scenarios/fixture-f-05-alembic-database.json +9 -0
- package/evals/scenarios/fixture-f-06-unittest-job.json +9 -0
- package/package.json +5 -2
- package/skills/agent-doc-system/SKILL.md +2 -1
- package/skills/agent-doc-system/references/cli.md +6 -1
- package/skills/agent-doc-system/references/create-migrate.md +21 -5
- 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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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", "
|
|
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) => ({
|
|
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
|
|
158
|
-
if (
|
|
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
|
-
//
|
|
243
|
-
//
|
|
244
|
-
|
|
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
|
-
|
|
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,
|
package/core/sourcescan.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
}
|