agent-doc-system 1.1.0 → 1.2.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 +269 -0
- package/README.md +38 -0
- package/adapters/ansible.mjs +1 -1
- package/adapters/cache.mjs +8 -2
- package/adapters/contract-consumers.mjs +1 -1
- package/adapters/database.mjs +18 -4
- package/adapters/events.mjs +1 -1
- package/adapters/github-actions.mjs +244 -85
- package/adapters/go.mjs +1 -1
- package/adapters/index.mjs +3 -1
- package/adapters/object-store.mjs +2 -2
- package/adapters/python.mjs +1142 -0
- package/adapters/resource-client.mjs +1 -1
- package/adapters/rust.mjs +987 -0
- package/adapters/typescript.mjs +1 -1
- package/bin/agentdoc.mjs +2 -2
- package/core/audit.mjs +5 -3
- package/core/codes.mjs +4 -0
- package/core/compile.mjs +124 -11
- package/core/dependents.mjs +42 -0
- package/core/discover.mjs +60 -9
- package/core/fsx.mjs +17 -0
- package/core/graph.mjs +1 -1
- package/core/impact.mjs +55 -44
- package/core/query.mjs +18 -8
- package/core/scaffold.mjs +87 -22
- package/core/sourcescan.mjs +11 -2
- package/core/toml.mjs +463 -140
- package/docs/ADAPTERS.md +196 -6
- package/docs/BENCHMARK.md +68 -0
- package/docs/CI.md +1 -1
- package/docs/EVALS.md +33 -2
- package/docs/MIGRATION.md +4 -2
- package/docs/QUERY.md +53 -4
- package/docs/SPEC.md +22 -0
- package/docs/ci/github-actions.yml +1 -1
- package/evals/harness.mjs +32 -6
- 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 +2 -1
- package/skills/agent-doc-system/references/create-migrate.md +13 -3
- package/templates/ci.yml +1 -1
package/core/impact.mjs
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// When an agent edits a file, the question is not "what does this file do" but
|
|
4
4
|
// "what must I keep consistent, and which gate proves it". This answers that
|
|
5
5
|
// from the graph alone, so the answer is available before any exploration.
|
|
6
|
-
import path from "node:path";
|
|
7
6
|
import { ContextIndex } from "./query.mjs";
|
|
7
|
+
import { dependentScope, verificationForChange } from "./dependents.mjs";
|
|
8
8
|
|
|
9
9
|
// The affected-file classification is derived from the compiled graph, not from
|
|
10
10
|
// a hand-written list of technology filenames. A list would go stale for every
|
|
@@ -26,7 +26,7 @@ function classify(graph, p, idx) {
|
|
|
26
26
|
for (const x of d.runbooks || []) if (x === p) return "documentation";
|
|
27
27
|
}
|
|
28
28
|
if (/\.(md|mdx|adoc|rst|txt)$/.test(p)) return "documentation";
|
|
29
|
-
if (/(^|\/)migrations
|
|
29
|
+
if (/(^|\/)(migrations?|alembic\/versions)\//.test(p)) return "resource";
|
|
30
30
|
// A Resource named directly — by ref, or by the descriptor file that declares
|
|
31
31
|
// it — is a resource change. Without this, `impact resource:default/x` reported
|
|
32
32
|
// nothing at all, and a change to a dataset's owner was invisible. The loop
|
|
@@ -44,7 +44,7 @@ export function impact(graph, changedPaths) {
|
|
|
44
44
|
const idx = new ContextIndex(graph);
|
|
45
45
|
const affectedComponents = new Map(); // ref -> Set(reason)
|
|
46
46
|
const add = (ref, reason) => {
|
|
47
|
-
if (
|
|
47
|
+
if (idx.byRef.get(ref)?.kind !== "Component") return;
|
|
48
48
|
if (!affectedComponents.has(ref)) affectedComponents.set(ref, new Set());
|
|
49
49
|
affectedComponents.get(ref).add(reason);
|
|
50
50
|
};
|
|
@@ -56,6 +56,7 @@ export function impact(graph, changedPaths) {
|
|
|
56
56
|
const dependencyPaths = new Set();
|
|
57
57
|
const catalogPaths = new Set();
|
|
58
58
|
const unmatched = [];
|
|
59
|
+
const matchedVerification = new Set();
|
|
59
60
|
|
|
60
61
|
for (const raw of changedPaths) {
|
|
61
62
|
const p = raw.replace(/^\.\//, "");
|
|
@@ -68,12 +69,36 @@ export function impact(graph, changedPaths) {
|
|
|
68
69
|
const kind = classify(graph, p, idx);
|
|
69
70
|
if (kind === "contract") contracts.add(p);
|
|
70
71
|
if (kind === "documentation") docs.add(p);
|
|
71
|
-
if (kind === "resource")
|
|
72
|
+
if (kind === "resource") {
|
|
73
|
+
resources.add(entity?.kind === "Resource" ? entity.ref : p);
|
|
74
|
+
// Migration authority is not ownership. Only follow an existing binding
|
|
75
|
+
// from the component containing this migration to a logical database.
|
|
76
|
+
if (entity?.kind === "Component") {
|
|
77
|
+
for (const rel of graph.relations) {
|
|
78
|
+
const bound = rel.type === "usesResource" && rel.sourceRef === entity.ref ? rel.targetRef
|
|
79
|
+
: rel.type === "resourceUsedBy" && rel.targetRef === entity.ref ? rel.sourceRef : null;
|
|
80
|
+
if (idx.byRef.get(bound)?.entity.spec.type === "logical-database") resources.add(bound);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
72
84
|
if (kind === "verification") verificationPaths.add(p);
|
|
73
85
|
if (kind === "input") dependencyPaths.add(p);
|
|
74
86
|
if (p.startsWith("agentdoc/")) catalogPaths.add(p);
|
|
75
87
|
}
|
|
76
88
|
|
|
89
|
+
// A check's configuration is evidence of the components it covers, even
|
|
90
|
+
// when the file lives outside every component root (for example CI). Add
|
|
91
|
+
// these before taking the seeds so their dependents' checks follow too.
|
|
92
|
+
for (const v of graph.verification) {
|
|
93
|
+
for (const p of verificationPaths) {
|
|
94
|
+
if (!v.configPaths?.includes(p)) continue;
|
|
95
|
+
matchedVerification.add(p);
|
|
96
|
+
for (const ref of v.componentRefs) add(ref, "verification configuration " + p + " changed");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const gates = graph.gates.filter((g) => g.configPaths?.some((p) => verificationPaths.has(p)));
|
|
100
|
+
for (const g of gates) for (const p of g.configPaths) if (verificationPaths.has(p)) matchedVerification.add(p);
|
|
101
|
+
|
|
77
102
|
// A contract change reaches its provider and every consumer.
|
|
78
103
|
for (const c of contracts) {
|
|
79
104
|
for (const api of graph.entities.filter((e) => e.kind === "API")) {
|
|
@@ -84,36 +109,6 @@ export function impact(graph, changedPaths) {
|
|
|
84
109
|
}
|
|
85
110
|
}
|
|
86
111
|
|
|
87
|
-
// Changing a component reaches the components that depend on it, and the
|
|
88
|
-
// components it schedules or is scheduled by. Two hops: a direct dependent
|
|
89
|
-
// and a dependent-of-a-dependent is the practical blast radius; beyond that
|
|
90
|
-
// the graph stops being a routing aid and becomes a dump.
|
|
91
|
-
for (const start of [...affectedComponents.keys()]) {
|
|
92
|
-
for (let hop = 0; hop < 2; hop++) {
|
|
93
|
-
for (const r of graph.relations) {
|
|
94
|
-
if (r.type === "buildDependencyOf" || r.type === "testDependencyOf" || r.type === "buildDependsOn" || r.type === "testDependsOn") {
|
|
95
|
-
if (r.targetRef === start) add(r.sourceRef, "depends on a changed component");
|
|
96
|
-
if (r.sourceRef === start) add(r.targetRef, "a dependent of a changed component");
|
|
97
|
-
}
|
|
98
|
-
if (r.type === "schedules" || r.type === "scheduledBy" || r.type === "runtimeCalls" || r.type === "runtimeCalledBy") {
|
|
99
|
-
if (r.sourceRef === start) add(r.targetRef, "connected to a changed component by " + r.type);
|
|
100
|
-
if (r.targetRef === start) add(r.sourceRef, "connected to a changed component by " + r.type);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// A dependency manifest change reaches both directions of the edge.
|
|
107
|
-
for (const d of dependencyPaths) {
|
|
108
|
-
for (const r of graph.relations) {
|
|
109
|
-
if (r.type !== "buildDependsOn" && r.type !== "testDependsOn") continue;
|
|
110
|
-
if (r.attributes?.package === path.basename(d)) {
|
|
111
|
-
add(r.sourceRef, "dependency manifest " + d + " changed");
|
|
112
|
-
add(r.targetRef, "depended on by a component whose manifest changed");
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
112
|
// A documentation change reaches the component that owns it.
|
|
118
113
|
for (const d of docs) {
|
|
119
114
|
for (const e of graph.entities) {
|
|
@@ -125,43 +120,59 @@ export function impact(graph, changedPaths) {
|
|
|
125
120
|
}
|
|
126
121
|
}
|
|
127
122
|
|
|
128
|
-
// A resource change reaches every component bound to
|
|
129
|
-
//
|
|
130
|
-
// Resource ref and its migration directory both reach the same components.
|
|
123
|
+
// A resource change reaches every component bound to that exact Resource.
|
|
124
|
+
// Never match a name as a substring of a migration path.
|
|
131
125
|
for (const r of resources) {
|
|
132
|
-
const refMatch = r.match(/^[a-z]+:[^/]+\/(.+)$/);
|
|
133
|
-
const refName = refMatch ? refMatch[1] : null;
|
|
134
126
|
for (const rel of graph.relations) {
|
|
135
127
|
if (rel.type !== "usesResource" && rel.type !== "resourceUsedBy") continue;
|
|
136
128
|
const bound = rel.type === "usesResource" ? rel.targetRef : rel.sourceRef;
|
|
137
129
|
const user = rel.type === "usesResource" ? rel.sourceRef : rel.targetRef;
|
|
138
|
-
|
|
139
|
-
if ((refName && name === refName) || r.includes(name)) {
|
|
130
|
+
if (r === bound) {
|
|
140
131
|
add(user, "resource change touches " + bound);
|
|
141
132
|
}
|
|
142
133
|
}
|
|
143
134
|
}
|
|
144
135
|
|
|
136
|
+
// Preserve the existing direct runtime/schedule context, then follow only
|
|
137
|
+
// reverse build/test edges. Dependencies remain context in query's explicitly
|
|
138
|
+
// typed relations; they are never affected components just for being inputs.
|
|
139
|
+
const directlyAffected = [...affectedComponents.keys()];
|
|
140
|
+
for (const start of directlyAffected) {
|
|
141
|
+
for (const r of graph.relations) {
|
|
142
|
+
if (r.type === "schedules" || r.type === "scheduledBy" || r.type === "runtimeCalls" || r.type === "runtimeCalledBy") {
|
|
143
|
+
if (r.sourceRef === start) add(r.targetRef, "connected to a changed component by " + r.type);
|
|
144
|
+
if (r.targetRef === start) add(r.sourceRef, "connected to a changed component by " + r.type);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const seeds = [...affectedComponents.keys()];
|
|
149
|
+
for (const ref of dependentScope(graph, seeds).keys()) {
|
|
150
|
+
if (!affectedComponents.has(ref)) add(ref, "depends on a changed component");
|
|
151
|
+
}
|
|
152
|
+
|
|
145
153
|
const refs = [...affectedComponents.keys()].sort();
|
|
146
|
-
const verification = graph
|
|
154
|
+
const verification = verificationForChange(graph, seeds, directlyAffected);
|
|
147
155
|
const conflicts = graph.conflicts.filter((c) => refs.includes(c.subject));
|
|
148
156
|
const journeys = graph.journeys.filter((j) => j.components.some((r) => refs.includes(r)));
|
|
149
157
|
|
|
150
158
|
return {
|
|
151
159
|
changedFiles: changedPaths.slice().sort(),
|
|
152
|
-
unmatchedFiles: unmatched.sort(),
|
|
160
|
+
unmatchedFiles: unmatched.filter((p) => !matchedVerification.has(p)).sort(),
|
|
153
161
|
affectedComponents: refs.map((ref) => ({ ref, reasons: [...affectedComponents.get(ref)].sort() })),
|
|
154
162
|
affectedContracts: [...contracts].sort(),
|
|
155
163
|
affectedDocs: [...docs].sort(),
|
|
156
164
|
affectedResources: [...resources].sort(),
|
|
157
165
|
affectedInputs: [...dependencyPaths].sort(),
|
|
158
166
|
affectedVerification: [...verificationPaths].sort(),
|
|
167
|
+
affectedGates: gates.map((v) => ({ id: v.id, tier: v.tier, command: v.command })),
|
|
159
168
|
affectedCatalog: [...catalogPaths].sort(),
|
|
160
169
|
catalogSourceChanged: catalogPaths.size > 0,
|
|
161
170
|
// Any file the compiler read is a graph input: changing it invalidates the
|
|
162
171
|
// committed graph even when no descriptor changed.
|
|
163
172
|
graphWillBeStale: graphAffected(graph, changedPaths),
|
|
164
|
-
recommendedVerification: verification.map((v) => ({ id: v.id, tier: v.tier, command: v.command })).slice(0, 25),
|
|
173
|
+
recommendedVerification: [...verification, ...gates].map((v) => ({ id: v.id, tier: v.tier, command: v.command })).slice(0, 25),
|
|
174
|
+
recommendedVerificationTotal: verification.length + gates.length,
|
|
175
|
+
recommendedVerificationTruncated: verification.length + gates.length > 25,
|
|
165
176
|
openConflicts: conflicts.map((c) => ({ id: c.id, subject: c.subject, key: c.key, status: c.status, kind: c.kind })),
|
|
166
177
|
relatedJourneys: journeys.map((j) => ({ id: j.id, description: j.description })),
|
|
167
178
|
};
|
package/core/query.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// than silently dropped, so an agent can see that it is seeing a slice.
|
|
10
10
|
import { AgentDocError, CODES } from "./codes.mjs";
|
|
11
11
|
import { assertNoSecretsInText } from "./secrets.mjs";
|
|
12
|
+
import { verificationForChange } from "./dependents.mjs";
|
|
12
13
|
|
|
13
14
|
const DEFAULT_BUDGET = {
|
|
14
15
|
relations: 40,
|
|
@@ -151,7 +152,7 @@ export function queryContext(graph, needle, opts = {}) {
|
|
|
151
152
|
const verificationScope = entity.kind === "API"
|
|
152
153
|
? [ref, ...apiConsumers, ...(apiProvider ? [apiProvider] : [])]
|
|
153
154
|
: [ref];
|
|
154
|
-
const verification = g
|
|
155
|
+
const verification = verificationForChange(g, verificationScope);
|
|
155
156
|
const journeys = g.journeys.filter((j) => j.components.includes(ref));
|
|
156
157
|
const contracts = g.contracts.filter((c) => c.apiRef && apiRefs.has(c.apiRef));
|
|
157
158
|
const conflicts = g.conflicts.filter((c) => c.subject === ref);
|
|
@@ -231,9 +232,11 @@ export function queryContext(graph, needle, opts = {}) {
|
|
|
231
232
|
),
|
|
232
233
|
capabilities: take(capabilities.map((c) => ({ name: c.name, evidenceClass: c.evidenceClass })), budget.capabilities),
|
|
233
234
|
constraints: {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
235
|
+
// Components derive these lists (including conventional files); central
|
|
236
|
+
// entities such as Resources and Systems carry them only in their spec.
|
|
237
|
+
docs: entity.derived?.docs || entity.entity.spec?.context?.docs || [],
|
|
238
|
+
constraints: entity.derived?.constraints || entity.entity.spec?.context?.constraints || [],
|
|
239
|
+
runbooks: entity.derived?.runbooks || entity.entity.spec?.context?.runbooks || [],
|
|
237
240
|
},
|
|
238
241
|
verification: take(
|
|
239
242
|
verification.map((v) => ({ id: v.id, tier: v.tier, command: v.command, configPaths: v.configPaths })),
|
|
@@ -329,7 +332,7 @@ function contractRef(graph, apiRef) {
|
|
|
329
332
|
}
|
|
330
333
|
|
|
331
334
|
// Compact markdown rendering for a prompt or a human. Same routing, less tokens.
|
|
332
|
-
export function renderContext(ctx) {
|
|
335
|
+
export function renderContext(ctx, opts = {}) {
|
|
333
336
|
const L = [];
|
|
334
337
|
const e = ctx.entity;
|
|
335
338
|
L.push("# " + e.ref + " (" + e.kind + ")");
|
|
@@ -345,9 +348,16 @@ export function renderContext(ctx) {
|
|
|
345
348
|
if (ctx.events.items.length) L.push("- events: " + ctx.events.items.map((x) => x.pattern + " [" + x.resolution + "]").join(", "));
|
|
346
349
|
if (ctx.externalDependencies.items.length) L.push("- external: " + ctx.externalDependencies.items.map((x) => x.name + " (" + x.role + ")").join(", "));
|
|
347
350
|
if (ctx.capabilities.items.length) L.push("- capabilities: " + ctx.capabilities.items.map((x) => x.name).join(", "));
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
+
for (const key of ["constraints", "docs", "runbooks"]) {
|
|
352
|
+
const paths = ctx.constraints[key];
|
|
353
|
+
if (!paths.length) continue;
|
|
354
|
+
// Constraints are critical routing context; cap them only on request.
|
|
355
|
+
const limit = opts.budget?.[key] ?? (key === "constraints" ? paths.length : 12);
|
|
356
|
+
const shown = paths.slice(0, limit);
|
|
357
|
+
const label = key === "constraints" ? "CONSTRAINTS" : key;
|
|
358
|
+
L.push("- " + label + ": " + shown.join(", ") +
|
|
359
|
+
(shown.length < paths.length ? " [truncated: " + shown.length + " of " + paths.length + "]" : ""));
|
|
360
|
+
}
|
|
351
361
|
if (ctx.verification.items.length) {
|
|
352
362
|
L.push("- verify:");
|
|
353
363
|
for (const v of ctx.verification.items) L.push(" - [" + v.tier + "] " + v.command);
|
package/core/scaffold.mjs
CHANGED
|
@@ -14,7 +14,12 @@ import path from "node:path";
|
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { AgentDocError, CODES } from "./codes.mjs";
|
|
16
16
|
import { Repo } from "./fsx.mjs";
|
|
17
|
+
import { discoverUnits, isNotUnitRoot } from "./discover.mjs";
|
|
17
18
|
import { classifyContractFile } from "./contracts.mjs";
|
|
19
|
+
import { parse as parseToml } from "./toml.mjs";
|
|
20
|
+
import { cargoUnitShape, isExcludedWorkspaceMember, isVirtualWorkspaceManifest } from "../adapters/rust.mjs";
|
|
21
|
+
import { readPythonUnit } from "../adapters/python.mjs";
|
|
22
|
+
import { loadAdapters } from "../adapters/index.mjs";
|
|
18
23
|
|
|
19
24
|
const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "templates");
|
|
20
25
|
|
|
@@ -56,7 +61,7 @@ export function installTemplates(repo, cfg, { write, root, force = false, eligib
|
|
|
56
61
|
};
|
|
57
62
|
|
|
58
63
|
// ── configuration ───────────────────────────────────────────────────────
|
|
59
|
-
const config = buildConfig(scanLayout(r));
|
|
64
|
+
const config = buildConfig(scanLayout(r, cfg));
|
|
60
65
|
put("agentdoc/agentdoc.config.yaml", config);
|
|
61
66
|
put("agentdoc/journeys.yaml", "schemaVersion: agentdoc.dev/journeys/v1\njourneys: []\n");
|
|
62
67
|
for (const f of ["domains.yaml", "systems.yaml", "apis.yaml", "resources.yaml"]) {
|
|
@@ -87,17 +92,17 @@ export function installTemplates(repo, cfg, { write, root, force = false, eligib
|
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
// Layout inference for CREATE mode: which directories look like unit roots.
|
|
90
|
-
//
|
|
91
|
-
function scanLayout(repo) {
|
|
95
|
+
// Combines shallow marker heuristics with the compiler's adapter discovery.
|
|
96
|
+
function scanLayout(repo, cfg) {
|
|
92
97
|
const candidates = new Map();
|
|
93
98
|
const note = (root, kind) => {
|
|
94
99
|
if (!candidates.has(root)) candidates.set(root, { root, kinds: new Set(), evidence: [] });
|
|
95
100
|
candidates.get(root).kinds.add(kind);
|
|
96
101
|
candidates.get(root).evidence.push(kind);
|
|
97
102
|
};
|
|
98
|
-
const skip = new Set(["node_modules", ".git", "dist", "build", "vendor", "target", "
|
|
103
|
+
const skip = new Set(["node_modules", ".git", "dist", "build", "vendor", "target", "test", "tests", "examples", "example"]);
|
|
99
104
|
const walk = (dir, depth) => {
|
|
100
|
-
if (depth > 3) return;
|
|
105
|
+
if (depth > 3 || isNotUnitRoot(dir, cfg)) return;
|
|
101
106
|
let entries;
|
|
102
107
|
try { entries = fs.readdirSync(path.join(repo.root, dir), { withFileTypes: true }); } catch { return; }
|
|
103
108
|
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
@@ -114,15 +119,62 @@ function scanLayout(repo) {
|
|
|
114
119
|
const parent = () => rel.slice(0, -("/" + e.name).length);
|
|
115
120
|
if (e.name === "package.json") note(parent(), "node");
|
|
116
121
|
if (e.name === "go.mod") note(parent(), "go");
|
|
122
|
+
if (e.name === "Cargo.toml" && cargoUnitShape(repo, parent()) && !isExcludedWorkspaceMember(repo, parent())) note(parent(), "rust");
|
|
123
|
+
if (e.name === "pyproject.toml" || e.name === "setup.py" || e.name === "setup.cfg") note(parent(), "python");
|
|
124
|
+
if (/^requirements.*\.txt$/.test(e.name)) note(parent(), "python");
|
|
117
125
|
if (e.name === "Dockerfile") note(parent(), "docker");
|
|
118
126
|
if (/^wrangler\.(toml|json|jsonc)$/.test(e.name)) note(parent(), "platform");
|
|
119
127
|
if (e.name === "go.mod") note(rel.slice(0, -"/go.mod".length), "go");
|
|
120
128
|
}
|
|
121
129
|
};
|
|
122
130
|
walk("", 0);
|
|
131
|
+
// Directory-name/depth heuristics must not hide units declared by adapters
|
|
132
|
+
// (for example a uv or Cargo member under examples/). During init there is
|
|
133
|
+
// no config yet: probe supported marker adapters so those members can also
|
|
134
|
+
// select their adapter in the generated configuration.
|
|
135
|
+
const discoveryCfg = cfg || { discovery: { componentDescriptors: [] } };
|
|
136
|
+
const adapters = loadAdapters(cfg ? cfg.adapters : Object.values(ADAPTER_FOR_MARKER));
|
|
137
|
+
for (const unit of discoverUnits(repo, discoveryCfg, adapters).eligible) {
|
|
138
|
+
if (!unit.root || unit.root === "." || isNotUnitRoot(unit.root, cfg)) continue;
|
|
139
|
+
for (const [kind, adapter] of Object.entries(ADAPTER_FOR_MARKER)) {
|
|
140
|
+
if (unit.markers.some((marker) => marker.adapter === adapter)) note(unit.root, kind);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
for (const u of [...candidates.values()]) {
|
|
144
|
+
// Layout-only candidates need the same artifact evidence as units already
|
|
145
|
+
// discovered by the compiler. A technology name alone proves no shape.
|
|
146
|
+
u.deployable = false;
|
|
147
|
+
u.importable = false;
|
|
148
|
+
for (const adapter of loadAdapters([...u.kinds].map((k) => ADAPTER_FOR_MARKER[k]))) {
|
|
149
|
+
const marker = adapter.detect({ repo, cfg }, u.root || ".");
|
|
150
|
+
if (!marker) continue;
|
|
151
|
+
const shape = marker.shape || "both";
|
|
152
|
+
u.deployable ||= shape === "deployable" || shape === "both";
|
|
153
|
+
u.importable ||= shape === "importable" || shape === "both";
|
|
154
|
+
}
|
|
155
|
+
if (!u.kinds.has("python")) continue;
|
|
156
|
+
const info = readPythonUnit(repo, u.root);
|
|
157
|
+
// A uv workspace root with no distribution is not a unit. Scaffolding it
|
|
158
|
+
// would invent a component for the virtual manifest.
|
|
159
|
+
if (!info.dist && !info.deployable && !info.importable && !info.error && virtualUvWorkspace(repo, u.root)) {
|
|
160
|
+
candidates.delete(u.root);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
123
163
|
return [...candidates.values()].sort((a, b) => (a.root < b.root ? -1 : 1));
|
|
124
164
|
}
|
|
125
165
|
|
|
166
|
+
function virtualUvWorkspace(repo, root) {
|
|
167
|
+
const file = !root || root === "." ? "pyproject.toml" : root + "/pyproject.toml";
|
|
168
|
+
if (!repo.exists(file)) return false;
|
|
169
|
+
try {
|
|
170
|
+
const doc = parseToml(repo.readText(file), file);
|
|
171
|
+
const ws = doc.tool && doc.tool.uv && doc.tool.uv.workspace;
|
|
172
|
+
return Boolean(ws && typeof ws === "object" && !Array.isArray(ws));
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
126
178
|
function discoverUnitNames(repo, cfg, eligible = null) {
|
|
127
179
|
const out = [];
|
|
128
180
|
const nameFor = (root) => {
|
|
@@ -138,6 +190,18 @@ function discoverUnitNames(repo, cfg, eligible = null) {
|
|
|
138
190
|
const m = /^module\s+(\S+)/m.exec(repo.readText(gomod));
|
|
139
191
|
if (m) return kebab(m[1].split("/").pop());
|
|
140
192
|
}
|
|
193
|
+
const cargo = root + "/Cargo.toml";
|
|
194
|
+
if (repo.exists(cargo)) {
|
|
195
|
+
try {
|
|
196
|
+
const doc = parseToml(repo.readText(cargo));
|
|
197
|
+
const n = doc && doc.package && typeof doc.package.name === "string" ? doc.package.name : "";
|
|
198
|
+
// `_` becomes `-u-` before kebab, so `foo_bar` does not collapse into
|
|
199
|
+
// the same component name as `foo-bar`.
|
|
200
|
+
if (n) return kebab(n.replace(/_/g, "-u-"));
|
|
201
|
+
} catch { /* a manifest that does not parse is not a name */ }
|
|
202
|
+
}
|
|
203
|
+
const python = readPythonUnit(repo, root);
|
|
204
|
+
if (python.dist) return kebab(python.dist);
|
|
141
205
|
return kebab(path.basename(root));
|
|
142
206
|
};
|
|
143
207
|
const seen = new Set();
|
|
@@ -152,27 +216,32 @@ function discoverUnitNames(repo, cfg, eligible = null) {
|
|
|
152
216
|
deployable: Boolean(u.deployable),
|
|
153
217
|
importable: Boolean(u.importable),
|
|
154
218
|
})),
|
|
155
|
-
...scanLayout(repo).map((u) => ({
|
|
219
|
+
...scanLayout(repo, cfg).map((u) => ({
|
|
220
|
+
root: u.root,
|
|
221
|
+
kinds: [...u.kinds].sort(),
|
|
222
|
+
deployable: Boolean(u.deployable),
|
|
223
|
+
importable: Boolean(u.importable),
|
|
224
|
+
})),
|
|
156
225
|
];
|
|
157
|
-
for (const
|
|
158
|
-
if (
|
|
226
|
+
for (const found of units) {
|
|
227
|
+
if (found.root === "." || !found.root || seenRoots.has(found.root)) continue;
|
|
228
|
+
if (isNotUnitRoot(found.root, cfg)) continue;
|
|
229
|
+
if (isVirtualWorkspaceManifest(repo, found.root)) continue;
|
|
230
|
+
const u = found;
|
|
159
231
|
seenRoots.add(u.root);
|
|
160
232
|
const name = nameFor(u.root);
|
|
161
233
|
if (seen.has(name)) continue;
|
|
162
234
|
seen.add(name);
|
|
163
235
|
out.push({ ...u, name });
|
|
164
236
|
}
|
|
165
|
-
void cfg;
|
|
166
237
|
return out;
|
|
167
238
|
}
|
|
168
239
|
|
|
169
240
|
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
241
|
// "library" and "service" both have to be corroborated by artifact evidence
|
|
173
242
|
// (AGENTDOC_ARTIFACT_TYPE); a unit the compiler only knows from configuration
|
|
174
243
|
// shows neither, so claiming a concrete type would manufacture an error.
|
|
175
|
-
const type = deployable ? "service" : importable ? "library" : "other";
|
|
244
|
+
const type = u.deployable ? "service" : u.importable ? "library" : "other";
|
|
176
245
|
return [
|
|
177
246
|
"apiVersion: agentdoc.dev/v1",
|
|
178
247
|
"kind: Component",
|
|
@@ -194,6 +263,8 @@ function componentDescriptor(u) {
|
|
|
194
263
|
const ADAPTER_FOR_MARKER = {
|
|
195
264
|
node: "node",
|
|
196
265
|
go: "go",
|
|
266
|
+
rust: "rust",
|
|
267
|
+
python: "python",
|
|
197
268
|
docker: "dockerfile",
|
|
198
269
|
platform: "wrangler",
|
|
199
270
|
};
|
|
@@ -206,13 +277,6 @@ function buildConfig(layout) {
|
|
|
206
277
|
if (!groups.has(head)) groups.set(head, []);
|
|
207
278
|
groups.get(head).push(r);
|
|
208
279
|
}
|
|
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
280
|
|
|
217
281
|
const adapters = new Set(["generic"]);
|
|
218
282
|
for (const l of layout) {
|
|
@@ -239,9 +303,10 @@ function buildConfig(layout) {
|
|
|
239
303
|
"discovery:",
|
|
240
304
|
" componentDescriptorName: agentdoc.yaml",
|
|
241
305
|
" componentDescriptors:",
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
|
|
306
|
+
// Discover every scaffolded descriptor, including adapter roots outside
|
|
307
|
+
// the shallow layout and units added after init. Keep the leading glob
|
|
308
|
+
// quoted because an unquoted "*" is an alias in strict YAML.
|
|
309
|
+
' - "*/**/agentdoc.yaml"',
|
|
245
310
|
" centralDescriptors:",
|
|
246
311
|
" - agentdoc/domains.yaml",
|
|
247
312
|
" - agentdoc/systems.yaml",
|
package/core/sourcescan.mjs
CHANGED
|
@@ -27,7 +27,7 @@ export function sourceFiles(repo, root, { test = false, extensions } = {}) {
|
|
|
27
27
|
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
28
|
const re = extensions || DEFAULT_EXT;
|
|
29
29
|
const out = [];
|
|
30
|
-
for (const f of repo.
|
|
30
|
+
for (const f of repo.walkUnit(root)) {
|
|
31
31
|
if (!re.test(f)) continue;
|
|
32
32
|
const isTest = TEST_PATH.test(f) || TEST_FILE.test(f) || /_test\.[a-z]+$/.test(f);
|
|
33
33
|
if (isTest !== test) continue;
|
|
@@ -64,7 +64,16 @@ export function findMigrationDirs(repo, root, maxDepth = 5) {
|
|
|
64
64
|
if (!e.isDirectory() || e.isSymbolicLink()) continue;
|
|
65
65
|
if (["node_modules", ".git", "vendor", "target", "dist", "build", ".next", "__pycache__"].includes(e.name)) continue;
|
|
66
66
|
const abs = path.join(dirAbs, e.name);
|
|
67
|
+
const rel = path.relative(repo.root, abs).split(path.sep).join("/");
|
|
68
|
+
if (!repo.ownsUnitPath(root, rel)) continue;
|
|
67
69
|
if (e.name === "migrations" || e.name === "migration") {
|
|
70
|
+
out.push(rel);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
// Alembic keeps revisions in versions/ under a directory named alembic.
|
|
74
|
+
// The directory is the migration root; the adapter still reads alembic.ini
|
|
75
|
+
// (or [tool.alembic]) before treating it as ownership evidence.
|
|
76
|
+
if (e.name === "versions" && path.basename(dirAbs) === "alembic") {
|
|
68
77
|
out.push(path.relative(repo.root, abs).split(path.sep).join("/"));
|
|
69
78
|
continue;
|
|
70
79
|
}
|
|
@@ -77,7 +86,7 @@ export function findMigrationDirs(repo, root, maxDepth = 5) {
|
|
|
77
86
|
|
|
78
87
|
export function firstExistingFile(repo, root, names) {
|
|
79
88
|
for (const n of names) {
|
|
80
|
-
if (repo.exists(root + "/" + n)) return root + "/" + n;
|
|
89
|
+
if (repo.ownsUnitPath(root, root + "/" + n) && repo.exists(root + "/" + n)) return root + "/" + n;
|
|
81
90
|
}
|
|
82
91
|
return null;
|
|
83
92
|
}
|