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/impact.mjs
CHANGED
|
@@ -3,8 +3,14 @@
|
|
|
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
|
+
import { canonicalPath, ownsPath } from "./paths.mjs";
|
|
9
|
+
|
|
10
|
+
// Changed paths are compared in the graph's canonical form (git's: no "./").
|
|
11
|
+
// A path that is not repository-relative is kept verbatim so it is reported
|
|
12
|
+
// as unmatched rather than silently rewritten.
|
|
13
|
+
const canonical = (p) => canonicalPath(p) ?? p;
|
|
8
14
|
|
|
9
15
|
// The affected-file classification is derived from the compiled graph, not from
|
|
10
16
|
// a hand-written list of technology filenames. A list would go stale for every
|
|
@@ -26,7 +32,7 @@ function classify(graph, p, idx) {
|
|
|
26
32
|
for (const x of d.runbooks || []) if (x === p) return "documentation";
|
|
27
33
|
}
|
|
28
34
|
if (/\.(md|mdx|adoc|rst|txt)$/.test(p)) return "documentation";
|
|
29
|
-
if (/(^|\/)migrations
|
|
35
|
+
if (/(^|\/)(migrations?|alembic\/versions)\//.test(p)) return "resource";
|
|
30
36
|
// A Resource named directly — by ref, or by the descriptor file that declares
|
|
31
37
|
// it — is a resource change. Without this, `impact resource:default/x` reported
|
|
32
38
|
// nothing at all, and a change to a dataset's owner was invisible. The loop
|
|
@@ -44,7 +50,7 @@ export function impact(graph, changedPaths) {
|
|
|
44
50
|
const idx = new ContextIndex(graph);
|
|
45
51
|
const affectedComponents = new Map(); // ref -> Set(reason)
|
|
46
52
|
const add = (ref, reason) => {
|
|
47
|
-
if (
|
|
53
|
+
if (idx.byRef.get(ref)?.kind !== "Component") return;
|
|
48
54
|
if (!affectedComponents.has(ref)) affectedComponents.set(ref, new Set());
|
|
49
55
|
affectedComponents.get(ref).add(reason);
|
|
50
56
|
};
|
|
@@ -56,9 +62,10 @@ export function impact(graph, changedPaths) {
|
|
|
56
62
|
const dependencyPaths = new Set();
|
|
57
63
|
const catalogPaths = new Set();
|
|
58
64
|
const unmatched = [];
|
|
65
|
+
const matchedVerification = new Set();
|
|
59
66
|
|
|
60
67
|
for (const raw of changedPaths) {
|
|
61
|
-
const p = raw
|
|
68
|
+
const p = canonical(raw);
|
|
62
69
|
const entity = idx.resolve(p);
|
|
63
70
|
// A Resource is not a component. Adding it to `affectedComponents` put a
|
|
64
71
|
// `resource:` ref in a list an agent reads as "components you must check",
|
|
@@ -68,11 +75,35 @@ export function impact(graph, changedPaths) {
|
|
|
68
75
|
const kind = classify(graph, p, idx);
|
|
69
76
|
if (kind === "contract") contracts.add(p);
|
|
70
77
|
if (kind === "documentation") docs.add(p);
|
|
71
|
-
if (kind === "resource")
|
|
78
|
+
if (kind === "resource") {
|
|
79
|
+
resources.add(entity?.kind === "Resource" ? entity.ref : p);
|
|
80
|
+
// Migration authority is not ownership. Only follow an existing binding
|
|
81
|
+
// from the component containing this migration to a logical database.
|
|
82
|
+
if (entity?.kind === "Component") {
|
|
83
|
+
for (const rel of graph.relations) {
|
|
84
|
+
const bound = rel.type === "usesResource" && rel.sourceRef === entity.ref ? rel.targetRef
|
|
85
|
+
: rel.type === "resourceUsedBy" && rel.targetRef === entity.ref ? rel.sourceRef : null;
|
|
86
|
+
if (idx.byRef.get(bound)?.entity.spec.type === "logical-database") resources.add(bound);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
72
90
|
if (kind === "verification") verificationPaths.add(p);
|
|
73
91
|
if (kind === "input") dependencyPaths.add(p);
|
|
74
|
-
if (
|
|
92
|
+
if (ownsPath("agentdoc", p)) catalogPaths.add(p);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A check's configuration is evidence of the components it covers, even
|
|
96
|
+
// when the file lives outside every component root (for example CI). Add
|
|
97
|
+
// these before taking the seeds so their dependents' checks follow too.
|
|
98
|
+
for (const v of graph.verification) {
|
|
99
|
+
for (const p of verificationPaths) {
|
|
100
|
+
if (!v.configPaths?.includes(p)) continue;
|
|
101
|
+
matchedVerification.add(p);
|
|
102
|
+
for (const ref of v.componentRefs) add(ref, "verification configuration " + p + " changed");
|
|
103
|
+
}
|
|
75
104
|
}
|
|
105
|
+
const gates = graph.gates.filter((g) => g.configPaths?.some((p) => verificationPaths.has(p)));
|
|
106
|
+
for (const g of gates) for (const p of g.configPaths) if (verificationPaths.has(p)) matchedVerification.add(p);
|
|
76
107
|
|
|
77
108
|
// A contract change reaches its provider and every consumer.
|
|
78
109
|
for (const c of contracts) {
|
|
@@ -84,36 +115,6 @@ export function impact(graph, changedPaths) {
|
|
|
84
115
|
}
|
|
85
116
|
}
|
|
86
117
|
|
|
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
118
|
// A documentation change reaches the component that owns it.
|
|
118
119
|
for (const d of docs) {
|
|
119
120
|
for (const e of graph.entities) {
|
|
@@ -125,43 +126,59 @@ export function impact(graph, changedPaths) {
|
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
128
|
|
|
128
|
-
// A resource change reaches every component bound to
|
|
129
|
-
//
|
|
130
|
-
// Resource ref and its migration directory both reach the same components.
|
|
129
|
+
// A resource change reaches every component bound to that exact Resource.
|
|
130
|
+
// Never match a name as a substring of a migration path.
|
|
131
131
|
for (const r of resources) {
|
|
132
|
-
const refMatch = r.match(/^[a-z]+:[^/]+\/(.+)$/);
|
|
133
|
-
const refName = refMatch ? refMatch[1] : null;
|
|
134
132
|
for (const rel of graph.relations) {
|
|
135
133
|
if (rel.type !== "usesResource" && rel.type !== "resourceUsedBy") continue;
|
|
136
134
|
const bound = rel.type === "usesResource" ? rel.targetRef : rel.sourceRef;
|
|
137
135
|
const user = rel.type === "usesResource" ? rel.sourceRef : rel.targetRef;
|
|
138
|
-
|
|
139
|
-
if ((refName && name === refName) || r.includes(name)) {
|
|
136
|
+
if (r === bound) {
|
|
140
137
|
add(user, "resource change touches " + bound);
|
|
141
138
|
}
|
|
142
139
|
}
|
|
143
140
|
}
|
|
144
141
|
|
|
142
|
+
// Preserve the existing direct runtime/schedule context, then follow only
|
|
143
|
+
// reverse build/test edges. Dependencies remain context in query's explicitly
|
|
144
|
+
// typed relations; they are never affected components just for being inputs.
|
|
145
|
+
const directlyAffected = [...affectedComponents.keys()];
|
|
146
|
+
for (const start of directlyAffected) {
|
|
147
|
+
for (const r of graph.relations) {
|
|
148
|
+
if (r.type === "schedules" || r.type === "scheduledBy" || r.type === "runtimeCalls" || r.type === "runtimeCalledBy") {
|
|
149
|
+
if (r.sourceRef === start) add(r.targetRef, "connected to a changed component by " + r.type);
|
|
150
|
+
if (r.targetRef === start) add(r.sourceRef, "connected to a changed component by " + r.type);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const seeds = [...affectedComponents.keys()];
|
|
155
|
+
for (const ref of dependentScope(graph, seeds).keys()) {
|
|
156
|
+
if (!affectedComponents.has(ref)) add(ref, "depends on a changed component");
|
|
157
|
+
}
|
|
158
|
+
|
|
145
159
|
const refs = [...affectedComponents.keys()].sort();
|
|
146
|
-
const verification = graph
|
|
160
|
+
const verification = verificationForChange(graph, seeds, directlyAffected);
|
|
147
161
|
const conflicts = graph.conflicts.filter((c) => refs.includes(c.subject));
|
|
148
162
|
const journeys = graph.journeys.filter((j) => j.components.some((r) => refs.includes(r)));
|
|
149
163
|
|
|
150
164
|
return {
|
|
151
165
|
changedFiles: changedPaths.slice().sort(),
|
|
152
|
-
unmatchedFiles: unmatched.sort(),
|
|
166
|
+
unmatchedFiles: unmatched.filter((p) => !matchedVerification.has(p)).sort(),
|
|
153
167
|
affectedComponents: refs.map((ref) => ({ ref, reasons: [...affectedComponents.get(ref)].sort() })),
|
|
154
168
|
affectedContracts: [...contracts].sort(),
|
|
155
169
|
affectedDocs: [...docs].sort(),
|
|
156
170
|
affectedResources: [...resources].sort(),
|
|
157
171
|
affectedInputs: [...dependencyPaths].sort(),
|
|
158
172
|
affectedVerification: [...verificationPaths].sort(),
|
|
173
|
+
affectedGates: gates.map((v) => ({ id: v.id, tier: v.tier, command: v.command })),
|
|
159
174
|
affectedCatalog: [...catalogPaths].sort(),
|
|
160
175
|
catalogSourceChanged: catalogPaths.size > 0,
|
|
161
176
|
// Any file the compiler read is a graph input: changing it invalidates the
|
|
162
177
|
// committed graph even when no descriptor changed.
|
|
163
178
|
graphWillBeStale: graphAffected(graph, changedPaths),
|
|
164
|
-
recommendedVerification: verification.map((v) => ({ id: v.id, tier: v.tier, command: v.command })).slice(0, 25),
|
|
179
|
+
recommendedVerification: [...verification, ...gates].map((v) => ({ id: v.id, tier: v.tier, command: v.command })).slice(0, 25),
|
|
180
|
+
recommendedVerificationTotal: verification.length + gates.length,
|
|
181
|
+
recommendedVerificationTruncated: verification.length + gates.length > 25,
|
|
165
182
|
openConflicts: conflicts.map((c) => ({ id: c.id, subject: c.subject, key: c.key, status: c.status, kind: c.kind })),
|
|
166
183
|
relatedJourneys: journeys.map((j) => ({ id: j.id, description: j.description })),
|
|
167
184
|
};
|
|
@@ -169,7 +186,8 @@ export function impact(graph, changedPaths) {
|
|
|
169
186
|
|
|
170
187
|
function graphAffected(graph, changedPaths) {
|
|
171
188
|
const inputs = new Set(graph.provenance.map((p) => p.path));
|
|
172
|
-
|
|
189
|
+
const paths = changedPaths.map(canonical);
|
|
190
|
+
for (const p of paths) if (inputs.has(p)) return true;
|
|
173
191
|
const idx = new ContextIndex(graph);
|
|
174
|
-
return
|
|
192
|
+
return paths.some((p) => idx.resolve(p));
|
|
175
193
|
}
|
package/core/paths.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Repository-relative path semantics, in one place.
|
|
2
|
+
//
|
|
3
|
+
// Every path the catalog stores or matches is in one canonical form, the one
|
|
4
|
+
// git uses for pathspecs and `ls-files` output: relative to the repository
|
|
5
|
+
// root, '/'-separated, with no "./", no empty or "." segments and no trailing
|
|
6
|
+
// slash. The repository root itself is the single value REPO_ROOT (".", which
|
|
7
|
+
// is what a root unit's `sourcePath` has always recorded).
|
|
8
|
+
//
|
|
9
|
+
// Ownership is decided on whole path segments, never on raw string prefixes:
|
|
10
|
+
// `src` owns `src` and `src/x.ts`, never `src2/x.ts`. The root owns every
|
|
11
|
+
// repository path, which makes it the lowest-precedence owner: when several
|
|
12
|
+
// owners match, the longest one wins, as in a routing table or CODEOWNERS.
|
|
13
|
+
|
|
14
|
+
export const REPO_ROOT = ".";
|
|
15
|
+
|
|
16
|
+
// Canonical form of a repository-relative path, or null for a path that is
|
|
17
|
+
// not one: absolute, drive-qualified, backslash-separated or escaping the
|
|
18
|
+
// repository through "..". Those are rejected here rather than resolved, the
|
|
19
|
+
// same rule `isRepoPath` applies to authored paths.
|
|
20
|
+
export function canonicalPath(p) {
|
|
21
|
+
if (typeof p !== "string") return null;
|
|
22
|
+
if (p.startsWith("/") || /^[A-Za-z]:/.test(p) || p.includes("\\")) return null;
|
|
23
|
+
const segs = [];
|
|
24
|
+
for (const seg of p.split("/")) {
|
|
25
|
+
if (seg === "" || seg === ".") continue;
|
|
26
|
+
if (seg === "..") return null;
|
|
27
|
+
segs.push(seg);
|
|
28
|
+
}
|
|
29
|
+
return segs.length ? segs.join("/") : REPO_ROOT;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isRepoRoot(p) {
|
|
33
|
+
return canonicalPath(p) === REPO_ROOT;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// True when `owner` (a directory such as a unit root or a component's
|
|
37
|
+
// sourcePath) contains `file`, segment by segment.
|
|
38
|
+
export function ownsPath(owner, file) {
|
|
39
|
+
const o = canonicalPath(owner);
|
|
40
|
+
const f = canonicalPath(file);
|
|
41
|
+
if (o === null || f === null) return false;
|
|
42
|
+
if (o === REPO_ROOT) return true;
|
|
43
|
+
return f === o || f.startsWith(o + "/");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The most specific owner of `file` among `owners`, or null. Specificity is
|
|
47
|
+
// segment depth, so the root (depth 0) only wins when nothing else matches —
|
|
48
|
+
// a one-character sibling such as `a` must not tie with ".".
|
|
49
|
+
export function longestOwner(owners, file) {
|
|
50
|
+
let best = null;
|
|
51
|
+
let bestDepth = -1;
|
|
52
|
+
for (const o of owners) {
|
|
53
|
+
if (!ownsPath(o, file)) continue;
|
|
54
|
+
const c = canonicalPath(o);
|
|
55
|
+
const depth = c === REPO_ROOT ? 0 : c.split("/").length;
|
|
56
|
+
if (depth > bestDepth) {
|
|
57
|
+
best = o;
|
|
58
|
+
bestDepth = depth;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return best;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Join a unit root and a path below it without ever producing "./x": a root
|
|
65
|
+
// unit's manifest is `package.json`, exactly as git and every other path in
|
|
66
|
+
// the graph spell it.
|
|
67
|
+
export function joinPath(root, rel) {
|
|
68
|
+
const r = canonicalPath(root);
|
|
69
|
+
const base = r === null ? String(root).replace(/\/+$/, "") : r;
|
|
70
|
+
return base === REPO_ROOT || base === "" ? rel : base + "/" + rel;
|
|
71
|
+
}
|
package/core/query.mjs
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
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";
|
|
13
|
+
import { canonicalPath, longestOwner, ownsPath, REPO_ROOT } from "./paths.mjs";
|
|
12
14
|
|
|
13
15
|
const DEFAULT_BUDGET = {
|
|
14
16
|
relations: 40,
|
|
@@ -27,13 +29,13 @@ export class ContextIndex {
|
|
|
27
29
|
constructor(graph) {
|
|
28
30
|
this.graph = graph;
|
|
29
31
|
this.byRef = new Map(graph.entities.map((e) => [e.ref, e]));
|
|
32
|
+
// Keyed by canonical source path, so a root unit's "." and any other
|
|
33
|
+
// spelling of the same directory land on one key.
|
|
30
34
|
this.byPath = new Map();
|
|
31
35
|
for (const e of graph.entities) {
|
|
32
|
-
const sp = e.derived && e.derived.sourcePath;
|
|
33
|
-
if (
|
|
36
|
+
const sp = e.derived && canonicalPath(e.derived.sourcePath);
|
|
37
|
+
if (sp === null || sp === undefined) continue;
|
|
34
38
|
this.byPath.set(sp, e);
|
|
35
|
-
// Longest path first so a nested path resolves to its own component
|
|
36
|
-
// rather than to a parent.
|
|
37
39
|
}
|
|
38
40
|
// Documentation and constraint files a descriptor links resolve to their
|
|
39
41
|
// component, even when they live outside the source root. Following a
|
|
@@ -48,9 +50,12 @@ export class ContextIndex {
|
|
|
48
50
|
...(e.derived || {}).constraints || [],
|
|
49
51
|
...(e.derived || {}).runbooks || [],
|
|
50
52
|
];
|
|
51
|
-
for (const
|
|
53
|
+
for (const raw of ctxPaths) {
|
|
54
|
+
const p = canonicalPath(raw) ?? raw;
|
|
55
|
+
if (!this.byDoc.has(p)) this.byDoc.set(p, e);
|
|
56
|
+
}
|
|
52
57
|
}
|
|
53
|
-
this.paths = [...this.byPath.keys()]
|
|
58
|
+
this.paths = [...this.byPath.keys()];
|
|
54
59
|
this.relationsByRef = new Map();
|
|
55
60
|
for (const r of graph.relations) {
|
|
56
61
|
if (!this.relationsByRef.has(r.sourceRef)) this.relationsByRef.set(r.sourceRef, []);
|
|
@@ -61,34 +66,64 @@ export class ContextIndex {
|
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
// A contract file resolves to the API entity that claims it, so "change this
|
|
64
|
-
// endpoint" routes to the boundary rather than to nothing.
|
|
69
|
+
// endpoint" routes to the boundary rather than to nothing. A contract ref
|
|
70
|
+
// names a file, a directory or a module prefix (`events` covering
|
|
71
|
+
// `events.go`, `events.proto`), the same coverage `Repo.filesWithPrefix`
|
|
72
|
+
// gives it — never a raw string prefix such as `events2/x`.
|
|
65
73
|
contractFor(needle) {
|
|
66
|
-
const norm = needle
|
|
74
|
+
const norm = canonicalPath(needle);
|
|
75
|
+
if (norm === null) return null;
|
|
67
76
|
let best = null;
|
|
68
77
|
for (const c of (this.graph.contracts || [])) {
|
|
69
|
-
if (
|
|
78
|
+
if (contractCovers(c.ref, norm)) {
|
|
70
79
|
if (!best || c.ref.length > best.ref.length) best = c;
|
|
71
80
|
}
|
|
72
81
|
}
|
|
73
82
|
return best;
|
|
74
83
|
}
|
|
84
|
+
// The component whose source root most specifically owns a repository path:
|
|
85
|
+
// the longest owning sourcePath, with a root component (".") as the
|
|
86
|
+
// lowest-precedence fallback. Null when no component owns the path.
|
|
87
|
+
ownerOf(file) {
|
|
88
|
+
const p = longestOwner(this.paths, file);
|
|
89
|
+
return p === null ? null : this.byPath.get(p);
|
|
90
|
+
}
|
|
75
91
|
resolve(needle) {
|
|
76
92
|
if (this.byRef.has(needle)) return this.byRef.get(needle);
|
|
77
|
-
|
|
93
|
+
if (looksLikeRef(needle)) return null;
|
|
94
|
+
// One canonical spelling for every path: `./a/b`, `a//b` and `a/b/` are
|
|
95
|
+
// all `a/b`. A path escaping the repository resolves to nothing.
|
|
96
|
+
const norm = canonicalPath(needle);
|
|
97
|
+
if (norm === null) return null;
|
|
78
98
|
if (this.byDoc.has(norm)) return this.byDoc.get(norm);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return c ? this.byRef.get(c.apiRef) || null : null;
|
|
99
|
+
// Precedence, most specific first: a linked document, a component source
|
|
100
|
+
// root, a contract's API, and only then a root component. The root owns
|
|
101
|
+
// every path, so it is the fallback for files nothing else claims; it
|
|
102
|
+
// must not take a contract file away from the API that declares it.
|
|
103
|
+
const owner = this.ownerOf(norm);
|
|
104
|
+
if (owner && canonicalPath(owner.derived.sourcePath) !== REPO_ROOT) return owner;
|
|
105
|
+
const c = this.contractFor(norm);
|
|
106
|
+
const api = c ? this.byRef.get(c.apiRef) || null : null;
|
|
107
|
+
return api || owner || null;
|
|
89
108
|
}
|
|
90
109
|
}
|
|
91
110
|
|
|
111
|
+
export function contractCovers(ref, file) {
|
|
112
|
+
const r = canonicalPath(ref);
|
|
113
|
+
const f = canonicalPath(file);
|
|
114
|
+
if (r === null || f === null) return false;
|
|
115
|
+
if (ownsPath(r, f)) return true;
|
|
116
|
+
const cut = r.lastIndexOf("/");
|
|
117
|
+
const dir = r.slice(0, cut + 1);
|
|
118
|
+
return f.startsWith(dir) && f.slice(dir.length).startsWith(r.slice(cut + 1) + ".");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// An entity ref is `kind:namespace/name`. A needle shaped like one that names
|
|
122
|
+
// no entity is an unresolved ref, not a repository path a root component owns.
|
|
123
|
+
function looksLikeRef(needle) {
|
|
124
|
+
return /^[A-Za-z][\w-]*:/.test(needle);
|
|
125
|
+
}
|
|
126
|
+
|
|
92
127
|
function contractsOf(graph) {
|
|
93
128
|
return graph.contracts || [];
|
|
94
129
|
}
|
|
@@ -135,14 +170,15 @@ export function queryContext(graph, needle, opts = {}) {
|
|
|
135
170
|
const apiProvider = entity.kind === "API" ? providerOf(ref) : null;
|
|
136
171
|
|
|
137
172
|
const apiRefs = new Set([...providedApis, ...consumedApis]);
|
|
138
|
-
const srcPath = (entity.derived && entity.derived.sourcePath) || null;
|
|
139
173
|
const events = g.interfaces.events.filter(
|
|
140
174
|
(e) =>
|
|
141
175
|
e.producers.includes(ref) ||
|
|
142
176
|
e.consumers.includes(ref) ||
|
|
143
177
|
// A contract library that ships the subject catalog owns those subjects,
|
|
144
|
-
// even when another component is the one that subscribes to them.
|
|
145
|
-
|
|
178
|
+
// even when another component is the one that subscribes to them. The
|
|
179
|
+
// owner is the component that most specifically owns the contract file,
|
|
180
|
+
// so a root component does not claim a nested library's catalog.
|
|
181
|
+
(e.contract && e.contract.ref && idx.ownerOf(e.contract.ref) === entity)
|
|
146
182
|
);
|
|
147
183
|
const externals = g.externalDependencies.filter((e) => e.componentRef === ref);
|
|
148
184
|
const capabilities = g.capabilities.filter((e) => e.componentRef === ref);
|
|
@@ -151,7 +187,7 @@ export function queryContext(graph, needle, opts = {}) {
|
|
|
151
187
|
const verificationScope = entity.kind === "API"
|
|
152
188
|
? [ref, ...apiConsumers, ...(apiProvider ? [apiProvider] : [])]
|
|
153
189
|
: [ref];
|
|
154
|
-
const verification = g
|
|
190
|
+
const verification = verificationForChange(g, verificationScope);
|
|
155
191
|
const journeys = g.journeys.filter((j) => j.components.includes(ref));
|
|
156
192
|
const contracts = g.contracts.filter((c) => c.apiRef && apiRefs.has(c.apiRef));
|
|
157
193
|
const conflicts = g.conflicts.filter((c) => c.subject === ref);
|
|
@@ -231,9 +267,11 @@ export function queryContext(graph, needle, opts = {}) {
|
|
|
231
267
|
),
|
|
232
268
|
capabilities: take(capabilities.map((c) => ({ name: c.name, evidenceClass: c.evidenceClass })), budget.capabilities),
|
|
233
269
|
constraints: {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
270
|
+
// Components derive these lists (including conventional files); central
|
|
271
|
+
// entities such as Resources and Systems carry them only in their spec.
|
|
272
|
+
docs: entity.derived?.docs || entity.entity.spec?.context?.docs || [],
|
|
273
|
+
constraints: entity.derived?.constraints || entity.entity.spec?.context?.constraints || [],
|
|
274
|
+
runbooks: entity.derived?.runbooks || entity.entity.spec?.context?.runbooks || [],
|
|
237
275
|
},
|
|
238
276
|
verification: take(
|
|
239
277
|
verification.map((v) => ({ id: v.id, tier: v.tier, command: v.command, configPaths: v.configPaths })),
|
|
@@ -329,7 +367,7 @@ function contractRef(graph, apiRef) {
|
|
|
329
367
|
}
|
|
330
368
|
|
|
331
369
|
// Compact markdown rendering for a prompt or a human. Same routing, less tokens.
|
|
332
|
-
export function renderContext(ctx) {
|
|
370
|
+
export function renderContext(ctx, opts = {}) {
|
|
333
371
|
const L = [];
|
|
334
372
|
const e = ctx.entity;
|
|
335
373
|
L.push("# " + e.ref + " (" + e.kind + ")");
|
|
@@ -345,9 +383,16 @@ export function renderContext(ctx) {
|
|
|
345
383
|
if (ctx.events.items.length) L.push("- events: " + ctx.events.items.map((x) => x.pattern + " [" + x.resolution + "]").join(", "));
|
|
346
384
|
if (ctx.externalDependencies.items.length) L.push("- external: " + ctx.externalDependencies.items.map((x) => x.name + " (" + x.role + ")").join(", "));
|
|
347
385
|
if (ctx.capabilities.items.length) L.push("- capabilities: " + ctx.capabilities.items.map((x) => x.name).join(", "));
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
386
|
+
for (const key of ["constraints", "docs", "runbooks"]) {
|
|
387
|
+
const paths = ctx.constraints[key];
|
|
388
|
+
if (!paths.length) continue;
|
|
389
|
+
// Constraints are critical routing context; cap them only on request.
|
|
390
|
+
const limit = opts.budget?.[key] ?? (key === "constraints" ? paths.length : 12);
|
|
391
|
+
const shown = paths.slice(0, limit);
|
|
392
|
+
const label = key === "constraints" ? "CONSTRAINTS" : key;
|
|
393
|
+
L.push("- " + label + ": " + shown.join(", ") +
|
|
394
|
+
(shown.length < paths.length ? " [truncated: " + shown.length + " of " + paths.length + "]" : ""));
|
|
395
|
+
}
|
|
351
396
|
if (ctx.verification.items.length) {
|
|
352
397
|
L.push("- verify:");
|
|
353
398
|
for (const v of ctx.verification.items) L.push(" - [" + v.tier + "] " + v.command);
|