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/discover.mjs
CHANGED
|
@@ -7,13 +7,24 @@
|
|
|
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
|
-
//
|
|
12
|
+
// Roots excluded from inference and scaffolding. Authored descriptors strictly
|
|
13
|
+
// below them can still claim units; descriptors at these exact roots cannot.
|
|
13
14
|
export const DEFAULT_NOT_UNIT_ROOTS = ["agentdoc", "docs", "scripts", "node_modules", ".github"];
|
|
14
15
|
|
|
16
|
+
function notUnitRoots(cfg) {
|
|
17
|
+
return cfg?.discovery.notUnitRoots || DEFAULT_NOT_UNIT_ROOTS;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Inference excludes the whole subtree, not similarly named siblings. CREATE
|
|
21
|
+
// mode shares this policy even for units with an existing authored descriptor.
|
|
22
|
+
export function isNotUnitRoot(root, cfg) {
|
|
23
|
+
return notUnitRoots(cfg)
|
|
24
|
+
.some((excluded) => ownsPath(excluded, root));
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
export function discoverUnits(repo, cfg, adapters) {
|
|
16
|
-
const notUnit = new Set(cfg.discovery.notUnitRoots || DEFAULT_NOT_UNIT_ROOTS);
|
|
17
28
|
const extraDeployable = new Set();
|
|
18
29
|
const extraImportable = new Set();
|
|
19
30
|
const units = new Map();
|
|
@@ -35,13 +46,17 @@ export function discoverUnits(repo, cfg, adapters) {
|
|
|
35
46
|
assertGlob(g, "agentdoc config");
|
|
36
47
|
for (const f of expandGlob(repo, g)) {
|
|
37
48
|
const dir = path.posix.dirname(f);
|
|
38
|
-
if (
|
|
49
|
+
if (repo.isDir(dir)) {
|
|
39
50
|
candidates.add(dir);
|
|
40
51
|
descriptorRoots.add(dir);
|
|
41
52
|
}
|
|
42
53
|
}
|
|
43
54
|
}
|
|
44
|
-
for (const r of cfg.discovery.supplementalRoots || [])
|
|
55
|
+
for (const r of cfg.discovery.supplementalRoots || []) {
|
|
56
|
+
candidates.add(r);
|
|
57
|
+
// The descriptor loader also reads these fixed paths outside the globs.
|
|
58
|
+
if (repo.exists(joinPath(r, cfg.discovery.componentDescriptorName))) descriptorRoots.add(r);
|
|
59
|
+
}
|
|
45
60
|
|
|
46
61
|
// Adapters may contribute roots the descriptor globs cannot know about: a
|
|
47
62
|
// Go workspace member, a workspace file member with no descriptor yet. The
|
|
@@ -59,7 +74,10 @@ export function discoverUnits(repo, cfg, adapters) {
|
|
|
59
74
|
}
|
|
60
75
|
|
|
61
76
|
for (const root of [...candidates].sort()) {
|
|
62
|
-
|
|
77
|
+
// Preserve explicit human claims below an exclusion, while keeping inferred
|
|
78
|
+
// roots out of the subtree and refusing descriptors at the exact root.
|
|
79
|
+
if (notUnitRoots(cfg).includes(root)) continue;
|
|
80
|
+
if (isNotUnitRoot(root, cfg) && !descriptorRoots.has(root)) continue;
|
|
63
81
|
if (!repo.isDir(root)) continue;
|
|
64
82
|
const u = get(root);
|
|
65
83
|
u.explicit = (cfg.discovery.supplementalRoots || []).includes(root) || descriptorRoots.has(root);
|
|
@@ -72,13 +90,38 @@ export function discoverUnits(repo, cfg, adapters) {
|
|
|
72
90
|
const shape = markerShape(marker);
|
|
73
91
|
if (shape === "deployable" || shape === "both") u.deployable = true;
|
|
74
92
|
if (shape === "importable" || shape === "both") u.importable = true;
|
|
93
|
+
// A manifest with no artifact shape is still a unit. deployable and
|
|
94
|
+
// importable stay false, so scaffold does not invent a service or a
|
|
95
|
+
// library. A descriptor is not required before the unit is recognised.
|
|
96
|
+
if (shape === "present") u.present = true;
|
|
75
97
|
}
|
|
76
98
|
}
|
|
77
99
|
}
|
|
78
100
|
|
|
79
101
|
const list = [...units.values()].sort((a, b) => (a.root < b.root ? -1 : 1));
|
|
80
|
-
const eligible = list.filter((u) => u.deployable || u.importable || u.explicit);
|
|
81
|
-
|
|
102
|
+
const eligible = list.filter((u) => u.deployable || u.importable || u.explicit || u.present);
|
|
103
|
+
// Only eligible units create ownership boundaries. Record immediate children
|
|
104
|
+
// once; pruning one of them also prunes every deeper descendant. Normalise
|
|
105
|
+
// the repository root so descriptor roots (".") and workspace roots agree.
|
|
106
|
+
const nestedRoots = new Map(eligible.map((u) => [unitRoot(u.root), new Set()]));
|
|
107
|
+
for (const root of nestedRoots.keys()) {
|
|
108
|
+
if (!root) continue;
|
|
109
|
+
let parent = unitRoot(path.posix.dirname(root));
|
|
110
|
+
while (true) {
|
|
111
|
+
if (nestedRoots.has(parent)) {
|
|
112
|
+
nestedRoots.get(parent).add(root);
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
if (!parent) break;
|
|
116
|
+
parent = unitRoot(path.posix.dirname(parent));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { units: list, eligible, nestedRoots };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function unitRoot(root) {
|
|
123
|
+
const normalized = path.posix.normalize(root || ".");
|
|
124
|
+
return normalized === "." ? "" : normalized;
|
|
82
125
|
}
|
|
83
126
|
|
|
84
127
|
function markerShape(marker) {
|
|
@@ -93,7 +136,7 @@ export function descriptorDir(file) {
|
|
|
93
136
|
}
|
|
94
137
|
|
|
95
138
|
// Exactly one descriptor per eligible unit, and no descriptor outside one.
|
|
96
|
-
export function matchComponents(eligible, componentEntities) {
|
|
139
|
+
export function matchComponents(eligible, componentEntities, cfg) {
|
|
97
140
|
const errors = [];
|
|
98
141
|
const byDir = new Map();
|
|
99
142
|
for (const e of componentEntities) {
|
|
@@ -124,6 +167,15 @@ export function matchComponents(eligible, componentEntities) {
|
|
|
124
167
|
for (const e of componentEntities) {
|
|
125
168
|
const dir = descriptorDir(e.file);
|
|
126
169
|
if (unitRoots.has(dir)) continue;
|
|
170
|
+
if (notUnitRoots(cfg).includes(dir)) {
|
|
171
|
+
errors.push(new AgentDocError(
|
|
172
|
+
CODES.COMPONENT_UNCORROBORATED,
|
|
173
|
+
"component descriptor at '" + dir + "' is in discovery.notUnitRoots (or a default not-unit root); " +
|
|
174
|
+
"remove it from notUnitRoots or move the descriptor",
|
|
175
|
+
{ path: e.file, ref: e.ref }
|
|
176
|
+
));
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
127
179
|
// A descriptor is a human claim that a component exists. When no adapter
|
|
128
180
|
// can corroborate a deployable or importable artifact in that source root,
|
|
129
181
|
// the claim is unsupported. This is reported rather than ignored: it is
|
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
|
|
@@ -59,8 +72,14 @@ export class Repo {
|
|
|
59
72
|
this.probes = new Map();
|
|
60
73
|
this.walks = new Map();
|
|
61
74
|
this.listings = new Map();
|
|
75
|
+
this.nestedUnitRoots = new Map();
|
|
62
76
|
this.generatedDirs = new Set(opts.generatedDirs || DEFAULT_GENERATED_DIRS);
|
|
63
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;
|
|
64
83
|
}
|
|
65
84
|
abs(rel) {
|
|
66
85
|
const a = path.resolve(this.root, rel);
|
|
@@ -69,14 +88,71 @@ export class Repo {
|
|
|
69
88
|
}
|
|
70
89
|
return a;
|
|
71
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.
|
|
72
148
|
exists(rel) {
|
|
73
|
-
const r = fs.existsSync(this.abs(rel));
|
|
149
|
+
const r = !this.isIgnored(rel) && fs.existsSync(this.abs(rel));
|
|
74
150
|
this.probes.set(rel, r);
|
|
75
151
|
return r;
|
|
76
152
|
}
|
|
77
153
|
isDir(rel) {
|
|
78
154
|
try {
|
|
79
|
-
const r = fs.statSync(this.abs(rel)).isDirectory();
|
|
155
|
+
const r = !this.isIgnored(rel) && fs.statSync(this.abs(rel)).isDirectory();
|
|
80
156
|
this.probes.set(rel, r);
|
|
81
157
|
return r;
|
|
82
158
|
} catch {
|
|
@@ -86,6 +162,16 @@ export class Repo {
|
|
|
86
162
|
}
|
|
87
163
|
readBytes(rel, { track = true } = {}) {
|
|
88
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
|
+
}
|
|
89
175
|
let buf;
|
|
90
176
|
try {
|
|
91
177
|
buf = fs.readFileSync(a);
|
|
@@ -113,10 +199,13 @@ export class Repo {
|
|
|
113
199
|
return JSON.parse(this.readText(rel, { track: false }));
|
|
114
200
|
}
|
|
115
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.
|
|
116
204
|
walk(relDir, { exclude = () => false } = {}) {
|
|
117
205
|
const base = relDir ? this.abs(relDir) : this.root;
|
|
118
206
|
const out = [];
|
|
119
|
-
const
|
|
207
|
+
const ignored = this.ignoredPaths();
|
|
208
|
+
const stack = relDir && this.isIgnored(relDir) ? [] : [base];
|
|
120
209
|
while (stack.length) {
|
|
121
210
|
const dir = stack.pop();
|
|
122
211
|
let entries;
|
|
@@ -131,6 +220,9 @@ export class Repo {
|
|
|
131
220
|
const full = path.join(dir, ent.name);
|
|
132
221
|
const rel = path.relative(this.root, full).split(path.sep).join("/");
|
|
133
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;
|
|
134
226
|
if (exclude(rel, ent)) continue;
|
|
135
227
|
if (ent.isDirectory()) stack.push(full);
|
|
136
228
|
else if (ent.isFile()) out.push(rel);
|
|
@@ -140,6 +232,21 @@ export class Repo {
|
|
|
140
232
|
this.walks.set(relDir || ".", out);
|
|
141
233
|
return out;
|
|
142
234
|
}
|
|
235
|
+
// Per-unit scans use the discovery result, never filesystem heuristics.
|
|
236
|
+
// Keep walk() repository-wide for discovery, contracts and authored inputs.
|
|
237
|
+
ownsUnitPath(unitRoot, rel) {
|
|
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;
|
|
243
|
+
}
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
walkUnit(unitRoot, relDir = unitRoot, { exclude = () => false } = {}) {
|
|
247
|
+
if (!this.ownsUnitPath(unitRoot, relDir)) return [];
|
|
248
|
+
return this.walk(relDir, { exclude: (rel, ent) => !this.ownsUnitPath(unitRoot, rel) || exclude(rel, ent) });
|
|
249
|
+
}
|
|
143
250
|
// Files under a path or a path *prefix*. A contract is often named by a module
|
|
144
251
|
// prefix rather than a single file (`events.go`, `events/*.go`), and
|
|
145
252
|
// requiring an exact directory would reject a perfectly good contract.
|
|
@@ -162,12 +269,17 @@ export class Repo {
|
|
|
162
269
|
// A directory listing is a compiler input like any other, and is recorded so
|
|
163
270
|
// the input hash covers it. An untracked listing would let a change to a
|
|
164
271
|
// directory's contents escape freshness detection.
|
|
272
|
+
// Ignored entries are left out, as if absent.
|
|
165
273
|
listDir(relDir) {
|
|
166
274
|
let names;
|
|
167
275
|
try {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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();
|
|
171
283
|
} catch {
|
|
172
284
|
names = [];
|
|
173
285
|
}
|
|
@@ -187,17 +299,21 @@ export class Repo {
|
|
|
187
299
|
});
|
|
188
300
|
return trim ? out.trim() : out;
|
|
189
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.
|
|
190
304
|
hasGit() {
|
|
191
305
|
if (this.noGit) return false;
|
|
306
|
+
if (this._hasGit !== undefined) return this._hasGit;
|
|
192
307
|
try {
|
|
193
308
|
const probe = execFileSync("git", ["rev-parse", "--git-dir"], {
|
|
194
309
|
cwd: this.root,
|
|
195
310
|
stdio: ["ignore", "pipe", "ignore"],
|
|
196
311
|
});
|
|
197
|
-
|
|
312
|
+
this._hasGit = probe.toString().trim().length > 0;
|
|
198
313
|
} catch {
|
|
199
|
-
|
|
314
|
+
this._hasGit = false;
|
|
200
315
|
}
|
|
316
|
+
return this._hasGit;
|
|
201
317
|
}
|
|
202
318
|
// A checkout without git — a source tarball, a vendored copy, a CI runner with
|
|
203
319
|
// no VCS — still compiles. Freshness then rests on the input hash alone, and
|
|
@@ -219,14 +335,15 @@ export class Repo {
|
|
|
219
335
|
// tracker uses.
|
|
220
336
|
gitPrefix() {
|
|
221
337
|
if (this.noGit || !this.hasGit()) return null;
|
|
338
|
+
if (this._prefix !== undefined) return this._prefix;
|
|
222
339
|
try {
|
|
223
|
-
|
|
340
|
+
this._prefix = this.git(["rev-parse", "--show-prefix"]);
|
|
224
341
|
} catch {
|
|
225
|
-
|
|
342
|
+
this._prefix = null;
|
|
226
343
|
}
|
|
344
|
+
return this._prefix;
|
|
227
345
|
}
|
|
228
|
-
toRepoPaths(gitPaths) {
|
|
229
|
-
const prefix = this.gitPrefix();
|
|
346
|
+
toRepoPaths(gitPaths, prefix = this.gitPrefix()) {
|
|
230
347
|
if (prefix === null) return [];
|
|
231
348
|
const out = [];
|
|
232
349
|
for (const p of gitPaths) {
|
|
@@ -236,31 +353,46 @@ export class Repo {
|
|
|
236
353
|
}
|
|
237
354
|
return out;
|
|
238
355
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
+
}
|
|
262
385
|
}
|
|
263
|
-
|
|
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));
|
|
264
396
|
}
|
|
265
397
|
// null on failure (bad ref, missing object store) so callers can tell
|
|
266
398
|
// "the diff failed" apart from "the diff is empty".
|
|
@@ -274,6 +406,23 @@ export class Repo {
|
|
|
274
406
|
}
|
|
275
407
|
}
|
|
276
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
|
+
|
|
277
426
|
// Minimal glob: '*' matches within one segment, '**' matches any depth.
|
|
278
427
|
export function globToRegExp(glob) {
|
|
279
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.
|