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.
- package/CHANGELOG.md +134 -1
- package/README.md +1 -1
- package/adapters/cache.mjs +5 -4
- package/adapters/database.mjs +3 -2
- package/adapters/deploy-config.mjs +3 -2
- package/adapters/dockerfile.mjs +3 -2
- package/adapters/generic.mjs +2 -1
- package/adapters/github-actions.mjs +2 -1
- package/adapters/go.mjs +4 -3
- package/adapters/node.mjs +3 -2
- package/adapters/rust.mjs +3 -0
- package/adapters/turborepo.mjs +3 -2
- package/adapters/typescript.mjs +7 -5
- package/adapters/wrangler.mjs +3 -2
- package/bin/agentdoc.mjs +9 -2
- package/bin/usage.txt +2 -0
- package/core/audit.mjs +7 -5
- package/core/compile.mjs +7 -5
- package/core/derive.mjs +4 -3
- package/core/descriptors.mjs +3 -2
- package/core/discover.mjs +3 -2
- package/core/fsx.mjs +174 -42
- package/core/graph.mjs +1 -1
- package/core/impact.mjs +11 -4
- package/core/paths.mjs +71 -0
- package/core/query.mjs +58 -23
- package/core/scaffold.mjs +130 -23
- package/core/sourcescan.mjs +4 -1
- package/docs/ADAPTERS.md +1 -1
- package/docs/BENCHMARK.md +83 -21
- package/docs/CI.md +7 -1
- package/docs/EVALS.md +209 -17
- package/docs/MIGRATION.md +22 -4
- package/docs/PROVENANCE.md +6 -0
- package/docs/QUERY.md +25 -3
- package/docs/SPEC.md +47 -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 +120 -116
- package/package.json +4 -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 +8 -2
- package/templates/ci.yml +1 -1
package/core/discover.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { AgentDocError, CODES } from "./codes.mjs";
|
|
9
9
|
import { expandGlob, globToRegExp, assertGlob } from "../core/fsx.mjs";
|
|
10
|
+
import { joinPath, ownsPath } from "./paths.mjs";
|
|
10
11
|
|
|
11
12
|
// Roots excluded from inference and scaffolding. Authored descriptors strictly
|
|
12
13
|
// below them can still claim units; descriptors at these exact roots cannot.
|
|
@@ -20,7 +21,7 @@ function notUnitRoots(cfg) {
|
|
|
20
21
|
// mode shares this policy even for units with an existing authored descriptor.
|
|
21
22
|
export function isNotUnitRoot(root, cfg) {
|
|
22
23
|
return notUnitRoots(cfg)
|
|
23
|
-
.some((excluded) =>
|
|
24
|
+
.some((excluded) => ownsPath(excluded, root));
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export function discoverUnits(repo, cfg, adapters) {
|
|
@@ -54,7 +55,7 @@ export function discoverUnits(repo, cfg, adapters) {
|
|
|
54
55
|
for (const r of cfg.discovery.supplementalRoots || []) {
|
|
55
56
|
candidates.add(r);
|
|
56
57
|
// The descriptor loader also reads these fixed paths outside the globs.
|
|
57
|
-
if (repo.exists(r
|
|
58
|
+
if (repo.exists(joinPath(r, cfg.discovery.componentDescriptorName))) descriptorRoots.add(r);
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
// Adapters may contribute roots the descriptor globs cannot know about: a
|
package/core/fsx.mjs
CHANGED
|
@@ -5,11 +5,21 @@
|
|
|
5
5
|
// is what the graph's inputHash is computed over, so freshness detection can
|
|
6
6
|
// never miss an input. This is the single most important property of the
|
|
7
7
|
// original system and is preserved verbatim.
|
|
8
|
+
//
|
|
9
|
+
// The input set is the checkout as git sees it: paths git ignores are not part
|
|
10
|
+
// of the repository (issue #45). A local build writing into an ignored
|
|
11
|
+
// directory — `astro build` filling `.astro/`, a `build/` tree, a `*.log` —
|
|
12
|
+
// must not move the inputHash, or a graph compiled after that build can never
|
|
13
|
+
// be reproduced by a clean checkout or by CI. Git itself is the oracle (see
|
|
14
|
+
// `ignoredPaths`); nothing here re-implements gitignore matching. Every read,
|
|
15
|
+
// probe and listing below consults the same oracle, and `check` recomputes the
|
|
16
|
+
// hash by running the same compile, so compile and check cannot disagree.
|
|
8
17
|
import { createHash } from "node:crypto";
|
|
9
18
|
import { execFileSync } from "node:child_process";
|
|
10
19
|
import fs from "node:fs";
|
|
11
20
|
import path from "node:path";
|
|
12
21
|
import { AgentDocError, CODES } from "./codes.mjs";
|
|
22
|
+
import { canonicalPath, ownsPath, REPO_ROOT } from "./paths.mjs";
|
|
13
23
|
|
|
14
24
|
export function sha256Hex(buf) {
|
|
15
25
|
return createHash("sha256").update(buf).digest("hex");
|
|
@@ -39,6 +49,9 @@ export function assertRepoPath(p, ctx) {
|
|
|
39
49
|
|
|
40
50
|
// Build/tooling output is never repository authority. Configurable per install
|
|
41
51
|
// so a project with e.g. "target" or "vendor" trees keeps them out too.
|
|
52
|
+
// This list applies everywhere, and is the *only* exclusion when there is no
|
|
53
|
+
// git oracle (no repository, no git binary, or `noGit`). Inside a git
|
|
54
|
+
// checkout, git-ignored paths are excluded in addition — see `ignoredPaths`.
|
|
42
55
|
// Build and tooling output. Tooling caches and dependency directories only: a
|
|
43
56
|
// name like `build`, `out` or `tmp` is far more likely to be a real unit in some
|
|
44
57
|
// repository than a generated directory, and a repository with a deployable
|
|
@@ -62,6 +75,11 @@ export class Repo {
|
|
|
62
75
|
this.nestedUnitRoots = new Map();
|
|
63
76
|
this.generatedDirs = new Set(opts.generatedDirs || DEFAULT_GENERATED_DIRS);
|
|
64
77
|
this.noGit = Boolean(opts.noGit);
|
|
78
|
+
// Per-Repo git caches. undefined = not asked yet; null = no answer.
|
|
79
|
+
this._hasGit = undefined;
|
|
80
|
+
this._prefix = undefined;
|
|
81
|
+
this._status = undefined;
|
|
82
|
+
this._ignored = undefined;
|
|
65
83
|
}
|
|
66
84
|
abs(rel) {
|
|
67
85
|
const a = path.resolve(this.root, rel);
|
|
@@ -70,14 +88,71 @@ export class Repo {
|
|
|
70
88
|
}
|
|
71
89
|
return a;
|
|
72
90
|
}
|
|
91
|
+
// The paths git ignores under this.root, asked once per Repo and cached, so
|
|
92
|
+
// every read, probe and listing in a run sees the same snapshot.
|
|
93
|
+
//
|
|
94
|
+
// `git status --ignored=matching` is the oracle (git-status(1)): it reports
|
|
95
|
+
// exactly the untracked paths that match an ignore pattern, and an ignored
|
|
96
|
+
// directory as one `dir/` entry without its contents. That gives git's own
|
|
97
|
+
// semantics for free:
|
|
98
|
+
// - tracked files are never reported, so a tracked file that matches a
|
|
99
|
+
// pattern stays an input ("tracked always wins", gitignore(5));
|
|
100
|
+
// - nested .gitignore files, $GIT_DIR/info/exclude and core.excludesFile
|
|
101
|
+
// apply with gitignore(5) precedence, negations included, and a file
|
|
102
|
+
// whose parent directory is excluded cannot be re-included;
|
|
103
|
+
// - a directory that is not itself ignored is never collapsed, only its
|
|
104
|
+
// ignored contents are listed. (`git ls-files -o -i --directory` gets
|
|
105
|
+
// this wrong: it also prints a non-ignored parent whose only content is
|
|
106
|
+
// ignored, and aborts when run from inside an ignored directory.)
|
|
107
|
+
//
|
|
108
|
+
// The user's global core.excludesFile deliberately counts. An ignored
|
|
109
|
+
// untracked file is, by definition, absent from every clean checkout, so
|
|
110
|
+
// whichever ignore source matched it, dropping it moves this machine's input
|
|
111
|
+
// set *towards* what CI sees, never away from it — no ignore rule can hide a
|
|
112
|
+
// tracked file. Ignoring core.excludesFile would bring back issue #45 for
|
|
113
|
+
// anything a developer ignores globally (editor swap files, `.envrc`): the
|
|
114
|
+
// file would enter the hash while `git status`, and therefore the graph's
|
|
115
|
+
// `dirty` flag, stays silent about it.
|
|
116
|
+
//
|
|
117
|
+
// The snapshot comes from `statusRecords`, the same single `git status`
|
|
118
|
+
// call the compile's dirty check reads, so the input set and the `dirty`
|
|
119
|
+
// flag describe the same moment. Paths come back relative to the git
|
|
120
|
+
// toplevel and are re-keyed by `toRepoPaths`; an ignored *ancestor* of
|
|
121
|
+
// this.root is dropped there, so a project the user pointed the compiler at
|
|
122
|
+
// is never blanked out by an outer .gitignore.
|
|
123
|
+
//
|
|
124
|
+
// null when there is no oracle — `noGit`, not a git checkout, git missing
|
|
125
|
+
// or failing. The input set is then the filesystem minus generatedDirs,
|
|
126
|
+
// exactly as before #45.
|
|
127
|
+
ignoredPaths() {
|
|
128
|
+
if (this._ignored !== undefined) return this._ignored;
|
|
129
|
+
const recs = this.statusRecords();
|
|
130
|
+
const prefix = recs ? this.gitPrefix() : null;
|
|
131
|
+
this._ignored = recs && prefix !== null
|
|
132
|
+
? new Set(this.toRepoPaths(recs.filter((r) => r.xy === "!!").map((r) => r.path), prefix))
|
|
133
|
+
: null;
|
|
134
|
+
return this._ignored;
|
|
135
|
+
}
|
|
136
|
+
// True when git ignores `rel` or one of its parent directories. Directory
|
|
137
|
+
// entries carry a trailing "/" in git's output.
|
|
138
|
+
isIgnored(rel) {
|
|
139
|
+
const set = this.ignoredPaths();
|
|
140
|
+
if (!set || set.size === 0 || !rel || rel === ".") return false;
|
|
141
|
+
if (set.has(rel) || set.has(rel + "/")) return true;
|
|
142
|
+
for (let i = rel.indexOf("/"); i >= 0; i = rel.indexOf("/", i + 1)) {
|
|
143
|
+
if (set.has(rel.slice(0, i + 1))) return true;
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
// An ignored path is probed as absent: that is what a clean checkout sees.
|
|
73
148
|
exists(rel) {
|
|
74
|
-
const r = fs.existsSync(this.abs(rel));
|
|
149
|
+
const r = !this.isIgnored(rel) && fs.existsSync(this.abs(rel));
|
|
75
150
|
this.probes.set(rel, r);
|
|
76
151
|
return r;
|
|
77
152
|
}
|
|
78
153
|
isDir(rel) {
|
|
79
154
|
try {
|
|
80
|
-
const r = fs.statSync(this.abs(rel)).isDirectory();
|
|
155
|
+
const r = !this.isIgnored(rel) && fs.statSync(this.abs(rel)).isDirectory();
|
|
81
156
|
this.probes.set(rel, r);
|
|
82
157
|
return r;
|
|
83
158
|
} catch {
|
|
@@ -87,6 +162,16 @@ export class Repo {
|
|
|
87
162
|
}
|
|
88
163
|
readBytes(rel, { track = true } = {}) {
|
|
89
164
|
const a = this.abs(rel);
|
|
165
|
+
// A tracked (input) read of an ignored path fails exactly as a read of a
|
|
166
|
+
// missing file does, so a descriptor that points into build output breaks
|
|
167
|
+
// locally the same way it breaks in CI. Untracked reads are not inputs.
|
|
168
|
+
if (track && this.isIgnored(rel)) {
|
|
169
|
+
throw new AgentDocError(
|
|
170
|
+
CODES.PATH_UNRESOLVED,
|
|
171
|
+
"path is ignored by git, so it is not a repository input: " + rel,
|
|
172
|
+
{ path: rel }
|
|
173
|
+
);
|
|
174
|
+
}
|
|
90
175
|
let buf;
|
|
91
176
|
try {
|
|
92
177
|
buf = fs.readFileSync(a);
|
|
@@ -114,10 +199,13 @@ export class Repo {
|
|
|
114
199
|
return JSON.parse(this.readText(rel, { track: false }));
|
|
115
200
|
}
|
|
116
201
|
// Recursive listing, byte-sorted, never following symlinks out of the repo.
|
|
202
|
+
// Ignored directories are pruned, not descended: an ignored `node_modules`
|
|
203
|
+
// or `.astro` costs one Set lookup.
|
|
117
204
|
walk(relDir, { exclude = () => false } = {}) {
|
|
118
205
|
const base = relDir ? this.abs(relDir) : this.root;
|
|
119
206
|
const out = [];
|
|
120
|
-
const
|
|
207
|
+
const ignored = this.ignoredPaths();
|
|
208
|
+
const stack = relDir && this.isIgnored(relDir) ? [] : [base];
|
|
121
209
|
while (stack.length) {
|
|
122
210
|
const dir = stack.pop();
|
|
123
211
|
let entries;
|
|
@@ -132,6 +220,9 @@ export class Repo {
|
|
|
132
220
|
const full = path.join(dir, ent.name);
|
|
133
221
|
const rel = path.relative(this.root, full).split(path.sep).join("/");
|
|
134
222
|
if (ent.isSymbolicLink()) continue;
|
|
223
|
+
// Parents are already known not to be ignored, so the entry itself is
|
|
224
|
+
// the only lookup needed.
|
|
225
|
+
if (ignored && ignored.has(ent.isDirectory() ? rel + "/" : rel)) continue;
|
|
135
226
|
if (exclude(rel, ent)) continue;
|
|
136
227
|
if (ent.isDirectory()) stack.push(full);
|
|
137
228
|
else if (ent.isFile()) out.push(rel);
|
|
@@ -144,12 +235,11 @@ export class Repo {
|
|
|
144
235
|
// Per-unit scans use the discovery result, never filesystem heuristics.
|
|
145
236
|
// Keep walk() repository-wide for discovery, contracts and authored inputs.
|
|
146
237
|
ownsUnitPath(unitRoot, rel) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (target === child || target.startsWith(child + "/")) return false;
|
|
238
|
+
if (!ownsPath(unitRoot, rel)) return false;
|
|
239
|
+
// nestedUnitRoots is keyed with the root spelled "" (see discoverUnits).
|
|
240
|
+
const root = canonicalPath(unitRoot);
|
|
241
|
+
for (const child of this.nestedUnitRoots.get(root === REPO_ROOT ? "" : root) || []) {
|
|
242
|
+
if (ownsPath(child, rel)) return false;
|
|
153
243
|
}
|
|
154
244
|
return true;
|
|
155
245
|
}
|
|
@@ -179,12 +269,17 @@ export class Repo {
|
|
|
179
269
|
// A directory listing is a compiler input like any other, and is recorded so
|
|
180
270
|
// the input hash covers it. An untracked listing would let a change to a
|
|
181
271
|
// directory's contents escape freshness detection.
|
|
272
|
+
// Ignored entries are left out, as if absent.
|
|
182
273
|
listDir(relDir) {
|
|
183
274
|
let names;
|
|
184
275
|
try {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
276
|
+
const prefix = relDir && relDir !== "." ? relDir + "/" : "";
|
|
277
|
+
names = this.isIgnored(relDir)
|
|
278
|
+
? []
|
|
279
|
+
: fs.readdirSync(this.abs(relDir), { withFileTypes: true })
|
|
280
|
+
.map((e) => (e.isDirectory() ? e.name + "/" : e.name))
|
|
281
|
+
.filter((n) => !this.isIgnored(prefix + n.replace(/\/$/, "")))
|
|
282
|
+
.sort();
|
|
188
283
|
} catch {
|
|
189
284
|
names = [];
|
|
190
285
|
}
|
|
@@ -204,17 +299,21 @@ export class Repo {
|
|
|
204
299
|
});
|
|
205
300
|
return trim ? out.trim() : out;
|
|
206
301
|
}
|
|
302
|
+
// Cached per Repo, like gitPrefix: whether this.root is a git checkout does
|
|
303
|
+
// not change during a run, and each probe is a process spawn.
|
|
207
304
|
hasGit() {
|
|
208
305
|
if (this.noGit) return false;
|
|
306
|
+
if (this._hasGit !== undefined) return this._hasGit;
|
|
209
307
|
try {
|
|
210
308
|
const probe = execFileSync("git", ["rev-parse", "--git-dir"], {
|
|
211
309
|
cwd: this.root,
|
|
212
310
|
stdio: ["ignore", "pipe", "ignore"],
|
|
213
311
|
});
|
|
214
|
-
|
|
312
|
+
this._hasGit = probe.toString().trim().length > 0;
|
|
215
313
|
} catch {
|
|
216
|
-
|
|
314
|
+
this._hasGit = false;
|
|
217
315
|
}
|
|
316
|
+
return this._hasGit;
|
|
218
317
|
}
|
|
219
318
|
// A checkout without git — a source tarball, a vendored copy, a CI runner with
|
|
220
319
|
// no VCS — still compiles. Freshness then rests on the input hash alone, and
|
|
@@ -236,14 +335,15 @@ export class Repo {
|
|
|
236
335
|
// tracker uses.
|
|
237
336
|
gitPrefix() {
|
|
238
337
|
if (this.noGit || !this.hasGit()) return null;
|
|
338
|
+
if (this._prefix !== undefined) return this._prefix;
|
|
239
339
|
try {
|
|
240
|
-
|
|
340
|
+
this._prefix = this.git(["rev-parse", "--show-prefix"]);
|
|
241
341
|
} catch {
|
|
242
|
-
|
|
342
|
+
this._prefix = null;
|
|
243
343
|
}
|
|
344
|
+
return this._prefix;
|
|
244
345
|
}
|
|
245
|
-
toRepoPaths(gitPaths) {
|
|
246
|
-
const prefix = this.gitPrefix();
|
|
346
|
+
toRepoPaths(gitPaths, prefix = this.gitPrefix()) {
|
|
247
347
|
if (prefix === null) return [];
|
|
248
348
|
const out = [];
|
|
249
349
|
for (const p of gitPaths) {
|
|
@@ -253,31 +353,46 @@ export class Repo {
|
|
|
253
353
|
}
|
|
254
354
|
return out;
|
|
255
355
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
356
|
+
// One `git status` for both questions a compile asks git about the
|
|
357
|
+
// working tree: what is dirty (`XY` records) and what is ignored (`!!`).
|
|
358
|
+
// `--untracked-files=all` lists untracked files individually, so they can
|
|
359
|
+
// be matched against the read tracker; `--ignored=matching` adds the
|
|
360
|
+
// ignored paths without descending into ignored directories.
|
|
361
|
+
// `--no-optional-locks` keeps it read-only: no refreshed index is written
|
|
362
|
+
// back, so it never contends with a git command the user is running.
|
|
363
|
+
// `-- .` scopes the scan to this.root, which may sit inside a larger
|
|
364
|
+
// checkout. Cached unless `fresh`: the ignore oracle must answer from one
|
|
365
|
+
// snapshot for the whole run.
|
|
366
|
+
statusRecords({ fresh = false } = {}) {
|
|
367
|
+
if (!fresh && this._status !== undefined) return this._status;
|
|
368
|
+
let recs = null;
|
|
369
|
+
if (!this.noGit && this.hasGit()) {
|
|
370
|
+
try {
|
|
371
|
+
// Untrimmed: a porcelain record is `XY <path>` with fixed columns, and
|
|
372
|
+
// an unstaged change has X = " ". Trimming would eat that space from
|
|
373
|
+
// the first record, so `.slice(3)` would drop the path's first
|
|
374
|
+
// character — ` M .agentdoc/graph.json` became `agentdoc/graph.json`,
|
|
375
|
+
// slipped past the output exclusion and counted as a dirty input
|
|
376
|
+
// (issue #11).
|
|
377
|
+
recs = parseStatusZ(this.git(
|
|
378
|
+
["--no-optional-locks", "status", "--porcelain", "-z",
|
|
379
|
+
"--untracked-files=all", "--ignored=matching", "--", "."],
|
|
380
|
+
{ trim: false }
|
|
381
|
+
));
|
|
382
|
+
} catch {
|
|
383
|
+
recs = null;
|
|
384
|
+
}
|
|
279
385
|
}
|
|
280
|
-
|
|
386
|
+
if (!fresh) this._status = recs;
|
|
387
|
+
return recs;
|
|
388
|
+
}
|
|
389
|
+
// Fresh by default, so a caller holding a Repo sees the current tree. The
|
|
390
|
+
// compile passes `snapshot: true` to read the same status the ignore oracle
|
|
391
|
+
// used — one git call, and a dirty flag consistent with the input set.
|
|
392
|
+
dirtyPaths({ snapshot = false } = {}) {
|
|
393
|
+
const recs = this.statusRecords({ fresh: !snapshot });
|
|
394
|
+
if (!recs) return [];
|
|
395
|
+
return this.toRepoPaths(recs.filter((r) => r.xy !== "!!").map((r) => r.path));
|
|
281
396
|
}
|
|
282
397
|
// null on failure (bad ref, missing object store) so callers can tell
|
|
283
398
|
// "the diff failed" apart from "the diff is empty".
|
|
@@ -291,6 +406,23 @@ export class Repo {
|
|
|
291
406
|
}
|
|
292
407
|
}
|
|
293
408
|
|
|
409
|
+
// `git status --porcelain -z` records (git-status(1), "Porcelain Format
|
|
410
|
+
// Version 1"): `XY <path>`, NUL-terminated, never quoted.
|
|
411
|
+
function parseStatusZ(raw) {
|
|
412
|
+
const out = [];
|
|
413
|
+
let skipNext = false;
|
|
414
|
+
for (const rec of raw.split("\0")) {
|
|
415
|
+
if (!rec) continue;
|
|
416
|
+
// In -z format a rename/copy is two records: `XY <to>` then a bare
|
|
417
|
+
// `<from>` with no status. The bare record must be consumed, not parsed
|
|
418
|
+
// as a status line.
|
|
419
|
+
if (skipNext) { skipNext = false; continue; }
|
|
420
|
+
if (rec[0] === "R" || rec[0] === "C" || rec[1] === "R" || rec[1] === "C") skipNext = true;
|
|
421
|
+
out.push({ xy: rec.slice(0, 2), path: rec.slice(3) });
|
|
422
|
+
}
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
|
|
294
426
|
// Minimal glob: '*' matches within one segment, '**' matches any depth.
|
|
295
427
|
export function globToRegExp(glob) {
|
|
296
428
|
const segs = glob.split("/");
|
package/core/graph.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { assertDeterministic, structuralProjection } from "./determinism.mjs";
|
|
|
9
9
|
import { sha256Hex } from "./fsx.mjs";
|
|
10
10
|
|
|
11
11
|
export const COMPILER_NAME = "agentdoc-compiler";
|
|
12
|
-
export const COMPILER_VERSION = "1.
|
|
12
|
+
export const COMPILER_VERSION = "1.3.0";
|
|
13
13
|
export const GRAPH_SCHEMA_VERSION = "agentdoc.dev/graph/v1";
|
|
14
14
|
|
|
15
15
|
// Resolve symbolic provenance subjects to RFC 6901 pointers into the graph.
|
package/core/impact.mjs
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
// from the graph alone, so the answer is available before any exploration.
|
|
6
6
|
import { ContextIndex } from "./query.mjs";
|
|
7
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
|
|
@@ -59,7 +65,7 @@ export function impact(graph, changedPaths) {
|
|
|
59
65
|
const matchedVerification = new Set();
|
|
60
66
|
|
|
61
67
|
for (const raw of changedPaths) {
|
|
62
|
-
const p = raw
|
|
68
|
+
const p = canonical(raw);
|
|
63
69
|
const entity = idx.resolve(p);
|
|
64
70
|
// A Resource is not a component. Adding it to `affectedComponents` put a
|
|
65
71
|
// `resource:` ref in a list an agent reads as "components you must check",
|
|
@@ -83,7 +89,7 @@ export function impact(graph, changedPaths) {
|
|
|
83
89
|
}
|
|
84
90
|
if (kind === "verification") verificationPaths.add(p);
|
|
85
91
|
if (kind === "input") dependencyPaths.add(p);
|
|
86
|
-
if (
|
|
92
|
+
if (ownsPath("agentdoc", p)) catalogPaths.add(p);
|
|
87
93
|
}
|
|
88
94
|
|
|
89
95
|
// A check's configuration is evidence of the components it covers, even
|
|
@@ -180,7 +186,8 @@ export function impact(graph, changedPaths) {
|
|
|
180
186
|
|
|
181
187
|
function graphAffected(graph, changedPaths) {
|
|
182
188
|
const inputs = new Set(graph.provenance.map((p) => p.path));
|
|
183
|
-
|
|
189
|
+
const paths = changedPaths.map(canonical);
|
|
190
|
+
for (const p of paths) if (inputs.has(p)) return true;
|
|
184
191
|
const idx = new ContextIndex(graph);
|
|
185
|
-
return
|
|
192
|
+
return paths.some((p) => idx.resolve(p));
|
|
186
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
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { AgentDocError, CODES } from "./codes.mjs";
|
|
11
11
|
import { assertNoSecretsInText } from "./secrets.mjs";
|
|
12
12
|
import { verificationForChange } from "./dependents.mjs";
|
|
13
|
+
import { canonicalPath, longestOwner, ownsPath, REPO_ROOT } from "./paths.mjs";
|
|
13
14
|
|
|
14
15
|
const DEFAULT_BUDGET = {
|
|
15
16
|
relations: 40,
|
|
@@ -28,13 +29,13 @@ export class ContextIndex {
|
|
|
28
29
|
constructor(graph) {
|
|
29
30
|
this.graph = graph;
|
|
30
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.
|
|
31
34
|
this.byPath = new Map();
|
|
32
35
|
for (const e of graph.entities) {
|
|
33
|
-
const sp = e.derived && e.derived.sourcePath;
|
|
34
|
-
if (
|
|
36
|
+
const sp = e.derived && canonicalPath(e.derived.sourcePath);
|
|
37
|
+
if (sp === null || sp === undefined) continue;
|
|
35
38
|
this.byPath.set(sp, e);
|
|
36
|
-
// Longest path first so a nested path resolves to its own component
|
|
37
|
-
// rather than to a parent.
|
|
38
39
|
}
|
|
39
40
|
// Documentation and constraint files a descriptor links resolve to their
|
|
40
41
|
// component, even when they live outside the source root. Following a
|
|
@@ -49,9 +50,12 @@ export class ContextIndex {
|
|
|
49
50
|
...(e.derived || {}).constraints || [],
|
|
50
51
|
...(e.derived || {}).runbooks || [],
|
|
51
52
|
];
|
|
52
|
-
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
|
+
}
|
|
53
57
|
}
|
|
54
|
-
this.paths = [...this.byPath.keys()]
|
|
58
|
+
this.paths = [...this.byPath.keys()];
|
|
55
59
|
this.relationsByRef = new Map();
|
|
56
60
|
for (const r of graph.relations) {
|
|
57
61
|
if (!this.relationsByRef.has(r.sourceRef)) this.relationsByRef.set(r.sourceRef, []);
|
|
@@ -62,34 +66,64 @@ export class ContextIndex {
|
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
// A contract file resolves to the API entity that claims it, so "change this
|
|
65
|
-
// 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`.
|
|
66
73
|
contractFor(needle) {
|
|
67
|
-
const norm = needle
|
|
74
|
+
const norm = canonicalPath(needle);
|
|
75
|
+
if (norm === null) return null;
|
|
68
76
|
let best = null;
|
|
69
77
|
for (const c of (this.graph.contracts || [])) {
|
|
70
|
-
if (
|
|
78
|
+
if (contractCovers(c.ref, norm)) {
|
|
71
79
|
if (!best || c.ref.length > best.ref.length) best = c;
|
|
72
80
|
}
|
|
73
81
|
}
|
|
74
82
|
return best;
|
|
75
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
|
+
}
|
|
76
91
|
resolve(needle) {
|
|
77
92
|
if (this.byRef.has(needle)) return this.byRef.get(needle);
|
|
78
|
-
|
|
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;
|
|
79
98
|
if (this.byDoc.has(norm)) return this.byDoc.get(norm);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
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;
|
|
90
108
|
}
|
|
91
109
|
}
|
|
92
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
|
+
|
|
93
127
|
function contractsOf(graph) {
|
|
94
128
|
return graph.contracts || [];
|
|
95
129
|
}
|
|
@@ -136,14 +170,15 @@ export function queryContext(graph, needle, opts = {}) {
|
|
|
136
170
|
const apiProvider = entity.kind === "API" ? providerOf(ref) : null;
|
|
137
171
|
|
|
138
172
|
const apiRefs = new Set([...providedApis, ...consumedApis]);
|
|
139
|
-
const srcPath = (entity.derived && entity.derived.sourcePath) || null;
|
|
140
173
|
const events = g.interfaces.events.filter(
|
|
141
174
|
(e) =>
|
|
142
175
|
e.producers.includes(ref) ||
|
|
143
176
|
e.consumers.includes(ref) ||
|
|
144
177
|
// A contract library that ships the subject catalog owns those subjects,
|
|
145
|
-
// even when another component is the one that subscribes to them.
|
|
146
|
-
|
|
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)
|
|
147
182
|
);
|
|
148
183
|
const externals = g.externalDependencies.filter((e) => e.componentRef === ref);
|
|
149
184
|
const capabilities = g.capabilities.filter((e) => e.componentRef === ref);
|