agent-doc-system 1.3.0 → 1.5.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.
@@ -0,0 +1,173 @@
1
+ import { CODES } from "../codes.mjs";
2
+ // Task context protocol. Graph authority stays in the compiler; this module
3
+ // selects and transports facts without turning an interpretation into a fact.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { createHash, randomUUID } from 'node:crypto';
7
+ import { getEncoding } from 'js-tiktoken';
8
+ import { compileSnapshot } from '../snapshot.mjs';
9
+ import { ContextIndex, queryContext } from '../query.mjs';
10
+ import { impact } from '../impact.mjs';
11
+ import { canonicalPath } from '../paths.mjs';
12
+ import { observationFreshness } from '../observations.mjs';
13
+ import { assertNoSecretsInText } from '../secrets.mjs';
14
+
15
+ const VERSION = 'agentdoc.dev/context/v1';
16
+ const INTENTS = ['implement', 'change-contract', 'debug-runtime', 'review'];
17
+ const ENCODERS = new Map();
18
+ const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
19
+ function error(code, message) { throw Object.assign(new Error(message), { code }); }
20
+ function closed(value, keys, name) {
21
+ if (!value || typeof value !== 'object' || Array.isArray(value)) error(CODES.CONTEXT_REQUEST, name + ' must be an object');
22
+ for (const k of Object.keys(value)) if (!keys.includes(k)) error(CODES.CONTEXT_REQUEST, 'unknown ' + name + ' field: ' + k);
23
+ }
24
+ export function validateRequest(request) {
25
+ closed(request, ['task', 'paths', 'intent', 'budget', 'cache'], 'request');
26
+ if (typeof request.task !== 'string' || !request.task.trim()) error(CODES.CONTEXT_REQUEST, 'task must be a nonempty string');
27
+ if (!Array.isArray(request.paths) || !request.paths.length || request.paths.some(p => typeof p !== 'string' || !p || (!/^[a-z]+:[a-z0-9-]+\//.test(p) && canonicalPath(p) === null))) error(CODES.CONTEXT_REQUEST, 'paths must be repository paths or entity refs');
28
+ if (request.intent !== undefined && !INTENTS.includes(request.intent)) error(CODES.CONTEXT_REQUEST, 'unsupported intent');
29
+ if (request.cache !== undefined && typeof request.cache !== 'boolean') error(CODES.CONTEXT_REQUEST, 'cache must be boolean');
30
+ if (request.budget !== undefined) closed(request.budget, ['tokens', 'encoding'], 'budget');
31
+ const budget = { tokens: 4096, encoding: 'o200k_base', ...request.budget };
32
+ if (!Number.isSafeInteger(budget.tokens) || budget.tokens < 256 || budget.tokens > 1000000) error(CODES.CONTEXT_BUDGET, 'tokens must be an integer from 256 to 1000000');
33
+ if (!['o200k_base', 'cl100k_base'].includes(budget.encoding)) error(CODES.CONTEXT_TOKENIZER, 'supported pinned encodings: o200k_base, cl100k_base');
34
+ return { task: request.task.trim(), paths: [...new Set(request.paths)].sort(), intent: request.intent || 'implement', budget, cache: request.cache !== false };
35
+ }
36
+ export function countTokens(text, encoding = 'o200k_base') {
37
+ if (!ENCODERS.has(encoding)) ENCODERS.set(encoding, getEncoding(encoding));
38
+ return ENCODERS.get(encoding).encode(text, [], []).length;
39
+ }
40
+ export function serializePacket(packet) { return JSON.stringify(packet) + '\n'; }
41
+ function record(kind, subject, key, value, evidence = [], critical = false) {
42
+ const id = hash([kind, subject, key]).slice(0, 24);
43
+ return JSON.parse(JSON.stringify({ id, version: hash([value, evidence]), kind, subject, key, value, evidence: [...evidence].sort(), critical }));
44
+ }
45
+ function collect(res, request) {
46
+ const g = res.graph, idx = new ContextIndex(g), refs = new Set(), records = new Map();
47
+ const im = impact(g, request.paths);
48
+ const put = r => records.set(r.id, r);
49
+ for (const p of request.paths) { const e = idx.resolve(p); if (e) refs.add(e.ref); }
50
+ for (const e of im.affectedComponents) refs.add(e.ref);
51
+ // Unlimited selection here: the single serialized token budget controls delivery.
52
+ const unlimited = Object.fromEntries(['relations','events','externals','capabilities','verification','journeys','conflicts','assertions','provenance','contracts'].map(k => [k, Number.MAX_SAFE_INTEGER]));
53
+ for (const ref of [...refs].sort()) {
54
+ const ctx = queryContext(g, ref, { budget: unlimited, index: idx });
55
+ put(record('entity', ref, 'identity', { description: ctx.entity.description, derived: ctx.entity.derived }, ctx.entity.sourcePaths));
56
+ for (const r of ctx.relations.items) put(record('relation', ref, r.type + ':' + r.direction + ':' + r.other, r, r.provenanceIds));
57
+ for (const a of ctx.authority.assertions.items) put(record('assertion', ref, a.key + ':' + a.id, a, [], a.key.startsWith('requirement.')));
58
+ for (const c of ctx.authority.conflicts.items) put(record('conflict', ref, c.id, c, [], true));
59
+ for (const [kind, paths] of Object.entries(ctx.constraints)) for (const p of paths) {
60
+ const bytes = res.repo.readBytes(p);
61
+ const expected = res.graph.provenance.filter(x => x.path === p).map(x => x.contentHash.replace(/^sha256:/, ''));
62
+ if (!expected.includes(createHash('sha256').update(bytes).digest('hex'))) error(CODES.CONTEXT_STALE, 'document is not pinned by this snapshot: ' + p);
63
+ const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes).replace(/^\uFEFF/, '');
64
+ put(record(kind === 'constraints' ? 'requirement' : 'document', ref, p,
65
+ { path: p, sha256: createHash('sha256').update(bytes).digest('hex'), text: source }, [p], kind === 'constraints'));
66
+ }
67
+ for (const c of ctx.contracts.items) put(record('contract', ref, c.id || c.ref, c, c.provenanceIds));
68
+ for (const o of ctx.authority.observations) put(record('observation', ref, o.id, o, o.evidenceBundle ? [o.evidenceBundle] : [], true));
69
+ for (const r of ctx.reviewCommitments.filter(r => !r.subject || refs.has(r.subject))) put(record('review', r.subject || 'repository', r.key || r.kind, r, [], true));
70
+ for (const j of ctx.journeys.items) put(record('journey', ref, j.id, j));
71
+ for (const e of ctx.events.items) put(record('event', ref, e.pattern, e, [], true));
72
+ }
73
+ const checks = new Map();
74
+ for (const p of [...refs]) {
75
+ const ctx = queryContext(g, p, { budget: unlimited, index: idx });
76
+ for (const v of [...ctx.verification.items, ...ctx.globalGates]) checks.set(v.id, v);
77
+ }
78
+ for (const v of im.recommendedVerification) checks.set(v.id, v);
79
+ for (const v of checks.values()) {
80
+ const original = [...g.verification, ...g.gates].find(x => x.id === v.id);
81
+ const owners = original?.componentRefs || [];
82
+ // Command text alone cannot prove a working directory; keep that unknown.
83
+ put(record('check', 'repository', v.id, { ...v, componentRefs: owners, execution: { kind: 'shell', cwd: null, status: 'requires-context' }, status: 'recommended' }, original?.provenanceIds, true));
84
+ }
85
+ for (const f of observationFreshness(g.observations)) if (f.stale) put(record('freshness', 'repository', f.id, f, [], true));
86
+ const sort = (a, b) => Number(b.critical) - Number(a.critical) || a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id);
87
+ return { records: [...records.values()].sort(sort), scope: [...refs].sort(), coverage: { unmatched: im.unmatchedFiles, limitations: ['component-level analysis; dynamic and unsupported relationships may be absent'], staleObservations: observationFreshness(g.observations).filter(f => f.stale).map(f => f.id) } };
88
+ }
89
+ function storeDir(root) { return path.join(root, '.agentdoc', 'context'); }
90
+ function save(root, id, data) {
91
+ const dir = storeDir(root); fs.mkdirSync(dir, { recursive: true });
92
+ const temp = path.join(dir, id + '.' + randomUUID() + '.tmp');
93
+ fs.writeFileSync(temp, JSON.stringify({ identity: data.identity }), { mode: 0o600 });
94
+ fs.renameSync(temp, path.join(dir, id + '.json'));
95
+ const retained = fs.readdirSync(dir).filter(n => /^[a-f0-9]{64}\.json$/.test(n)).map(n => ({ n, time: fs.statSync(path.join(dir,n)).mtimeMs })).sort((a,b) => b.time-a.time || a.n.localeCompare(b.n));
96
+ for (const old of retained.slice(64)) if (old.n !== id + '.json') fs.rmSync(path.join(dir,old.n), { force: true });
97
+ }
98
+ function load(root, id) {
99
+ if (!/^[a-f0-9]{64}$/.test(id || '')) error(CODES.CONTEXT_REFERENCE, 'invalid packet reference');
100
+ try { const x = JSON.parse(fs.readFileSync(path.join(storeDir(root), id + '.json'), 'utf8')); if (hash(x.identity) !== id) throw new Error(); return { ...x.identity, id }; }
101
+ catch { error(CODES.CONTEXT_REFERENCE, 'packet unavailable; run prepare again'); }
102
+ }
103
+ function packetPage(state, offset, limit) {
104
+ const p = { schemaVersion: VERSION, packet: state.id, snapshot: state.snapshot, task: state.request.task, intent: state.request.intent, scope: state.scope, coverage: state.coverage,
105
+ records: [], delivery: { offset, total: state.records.length, omitted: state.records.length - offset, criticalOmitted: 0, partial: true },
106
+ continuation: { packet: state.id, offset }, budget: { limit, used: 0, encoding: state.request.budget.encoding, tokenizerVersion: 'js-tiktoken@1.0.21' } };
107
+ const measure = () => {
108
+ // Digit count feeds back into encoding; converge to the exact final size.
109
+ for (let i = 0; i < 12; i++) { const n = countTokens(serializePacket(p), p.budget.encoding); if (n === p.budget.used) return n; p.budget.used = n; }
110
+ error(CODES.CONTEXT_BUDGET, 'token accounting did not converge');
111
+ };
112
+ const update = () => {
113
+ const next = offset + p.records.length;
114
+ p.delivery.omitted = state.records.length - next;
115
+ p.delivery.criticalOmitted = state.records.slice(next).filter(r => r.critical).length;
116
+ p.delivery.partial = next < state.records.length;
117
+ p.continuation = next < state.records.length ? { packet: state.id, offset: next } : null;
118
+ return measure();
119
+ };
120
+ if (update() > limit) error(CODES.CONTEXT_BUDGET, 'budget cannot hold the minimum envelope');
121
+ for (const r of state.records.slice(offset)) {
122
+ p.records.push(r);
123
+ if (update() > limit) { p.records.pop(); update(); break; }
124
+ }
125
+ if (!p.records.length && offset < state.records.length) error(CODES.CONTEXT_BUDGET, 'next record exceeds budget; expand with a larger token limit');
126
+ assertNoSecretsInText(serializePacket(p), 'context packet');
127
+ return p;
128
+ }
129
+ export function prepare(root, input, options = {}) {
130
+ const request = validateRequest(input);
131
+ const res = compileSnapshot(root, { cache: request.cache, ...options });
132
+ if (res.errors.length) error(CODES.CONTEXT_COMPILE, res.errors.map(e => e.message).join('\n'));
133
+ const selected = collect(res, request);
134
+ const snapshot = { inputHash: res.stats.inputHash, compiler: res.graph.compiler, graphHash: hash(res.graph) };
135
+ const identity = { snapshot, request, records: selected.records, logicalRecords: selected.records, scope: selected.scope, coverage: selected.coverage };
136
+ assertNoSecretsInText(JSON.stringify(identity), 'context state');
137
+ const id = hash(identity);
138
+ const state = { identity, id, snapshot, request, ...selected };
139
+ const packet = packetPage(state, 0, request.budget.tokens);
140
+ save(root, id, state);
141
+ return packet;
142
+ }
143
+ export function expand(root, request) {
144
+ closed(request, ['packet', 'offset', 'tokens'], 'expand');
145
+ const state = load(root, request.packet);
146
+ const offset = request.offset ?? 0, tokens = request.tokens ?? state.request.budget.tokens;
147
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > state.records.length || !Number.isSafeInteger(tokens) || tokens < 256 || tokens > 1000000) error(CODES.CONTEXT_REQUEST, 'invalid offset or token limit');
148
+ const res = compileSnapshot(root, { cache: state.request.cache });
149
+ if (res.errors.length || hash(res.graph) !== state.snapshot.graphHash || JSON.stringify(observationFreshness(res.graph.observations).filter(f => f.stale).map(f => f.id)) !== JSON.stringify(state.coverage.staleObservations)) error(CODES.CONTEXT_STALE, 'snapshot changed; use refresh or prepare');
150
+ return packetPage(state, offset, tokens);
151
+ }
152
+ export function refresh(root, request) {
153
+ closed(request, ['packet', 'acknowledged', 'reset'], 'refresh');
154
+ const previous = load(root, request.packet);
155
+ if (request.reset !== undefined && typeof request.reset !== 'boolean') error(CODES.CONTEXT_REQUEST, 'reset must be boolean');
156
+ if (request.acknowledged !== undefined && (!Array.isArray(request.acknowledged) || request.acknowledged.some(x => typeof x !== 'string'))) error(CODES.CONTEXT_REQUEST, 'acknowledged must be record IDs');
157
+ const known = new Set(request.reset ? [] : request.acknowledged || []);
158
+ for (const id of known) if (!(previous.logicalRecords || previous.records).some(r => r.id === id)) error(CODES.CONTEXT_REQUEST, 'acknowledged ID is not in the previous packet');
159
+ const initial = prepare(root, previous.request);
160
+ const current = load(root, initial.packet);
161
+ const old = new Map((previous.logicalRecords || previous.records).filter(r => known.has(r.id)).map(r => [r.id, r]));
162
+ const currentIds = new Set(current.records.map(r => r.id));
163
+ const records = current.records.filter(r => r.critical || !old.has(r.id) || old.get(r.id).version !== r.version);
164
+ for (const [id, r] of old) if (!currentIds.has(id)) records.push(record('retraction', r.subject, id, { id, previousVersion: r.version }, [], true));
165
+ const state = { ...current, records };
166
+ // A delta is itself addressed and can be paged; acknowledge only IDs delivered.
167
+ state.identity = { base: previous.id, snapshot: current.snapshot, request: current.request, records, logicalRecords: current.logicalRecords || current.records, scope: current.scope, coverage: current.coverage };
168
+ state.id = hash(state.identity); save(root, state.id, state);
169
+ return packetPage(state, 0, current.request.budget.tokens);
170
+ }
171
+ export function forget(root, packet) {
172
+ load(root, packet); fs.unlinkSync(path.join(storeDir(root), packet + '.json'));
173
+ }
@@ -0,0 +1,81 @@
1
+ import { CODES } from "../codes.mjs";
2
+ // On-demand lexical experiment. These are source navigation candidates, never
3
+ // complete call/dependency analysis and never grounds for removing checks.
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { createHash } from "node:crypto";
7
+ import { canonicalPath } from "../paths.mjs";
8
+
9
+ function reject(message) { throw Object.assign(new Error(message), { code: CODES.SYMBOL_REQUEST }); }
10
+ // Preserve source offsets while hiding comments and quoted text. Interpolated
11
+ // template expressions and regex literals are deliberately unsupported.
12
+ function codeOnly(text) {
13
+ let out = "", state = "code";
14
+ for (let i = 0; i < text.length; i++) {
15
+ const c = text[i], next = text[i + 1];
16
+ if (state === "code") {
17
+ if (c === "/" && next === "/") { state = "line"; out += " "; i++; }
18
+ else if (c === "/" && next === "*") { state = "block"; out += " "; i++; }
19
+ else if (c === "'" || c === '"' || c === "`") { state = c; out += " "; }
20
+ else out += c;
21
+ } else if (state === "line") {
22
+ out += c === "\n" ? "\n" : " ";
23
+ if (c === "\n") state = "code";
24
+ } else if (state === "block") {
25
+ if (c === "*" && next === "/") { state = "code"; out += " "; i++; }
26
+ else out += c === "\n" ? "\n" : " ";
27
+ } else if (c === "\\") {
28
+ out += " ";
29
+ if (next !== undefined) { out += next === "\n" ? "\n" : " "; i++; }
30
+ } else {
31
+ out += c === "\n" ? "\n" : " ";
32
+ if (c === state) state = "code";
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+
38
+ export function indexTypeScriptSymbols(root, files) {
39
+ if (!Array.isArray(files) || !files.length || files.some((f) => typeof f !== "string")) reject("files must be a nonempty list of TypeScript paths");
40
+ const base = fs.realpathSync(root);
41
+ const paths = files.map((file) => {
42
+ const rel = canonicalPath(file);
43
+ if (rel === null || !/\.(?:[cm]?ts|tsx)$/.test(rel)) reject("only repository-relative TypeScript files are supported");
44
+ return rel;
45
+ });
46
+ const sources = [...new Set(paths)].sort().map((file) => {
47
+ let absolute, source;
48
+ try {
49
+ absolute = fs.realpathSync(path.join(base, file));
50
+ if (!absolute.startsWith(base + path.sep)) reject("source escapes repository");
51
+ source = fs.readFileSync(absolute, "utf8");
52
+ } catch (e) {
53
+ if (e.code === CODES.SYMBOL_REQUEST) throw e;
54
+ reject("source unavailable: " + file);
55
+ }
56
+ const clean = codeOnly(source);
57
+ const declarations = [];
58
+ const pattern = /\b(export\s+)?(?:default\s+)?(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?(function|class|interface|type|enum|const|let|var)\s+([A-Za-z_$][\w$]*)/g;
59
+ for (const match of clean.matchAll(pattern)) {
60
+ const offset = match.index + match[0].lastIndexOf(match[3]);
61
+ const before = source.slice(0, offset);
62
+ declarations.push({ name: match[3], kind: match[2], exported: Boolean(match[1]),
63
+ line: before.split("\n").length, column: offset - before.lastIndexOf("\n") });
64
+ }
65
+ return { path: file, sha256: createHash("sha256").update(source).digest("hex"), declarations, clean };
66
+ });
67
+ const names = new Set(sources.flatMap((s) => s.declarations.map((d) => d.name)));
68
+ const indexed = sources.map(({ clean, ...file }) => {
69
+ const references = [];
70
+ for (const match of clean.matchAll(/[A-Za-z_$][\w$]*/g)) {
71
+ if (!names.has(match[0])) continue;
72
+ const before = clean.slice(0, match.index);
73
+ const line = before.split("\n").length, column = match.index - before.lastIndexOf("\n");
74
+ const declaration = file.declarations.some((d) => d.line === line && d.column === column);
75
+ if (!declaration) references.push({ name: match[0], line, column });
76
+ }
77
+ return { ...file, references };
78
+ });
79
+ return { schemaVersion: "agentdoc.dev/symbol-candidates/v1", coverage: { partial: true, checkSelection: "unchanged",
80
+ limitations: ["lexical candidates only; scope, imports and overloads are not resolved", "dynamic uses, template interpolation and regex literals are not resolved", "coverage is restricted to the explicitly requested files"] }, files: indexed };
81
+ }
@@ -297,6 +297,7 @@ export function loadDescriptors(repo, cfg, bundle, refs) {
297
297
  throw new AgentDocError(CODES.PATH_UNRESOLVED, "context link does not resolve: " + p, { path: e.file, ref: e.ref });
298
298
  }
299
299
  }
300
+ if (e.kind === "Resource" && spec.migrationPaths) checkMigrationPaths(repo, e);
300
301
  if (e.kind === "API") {
301
302
  const prov = spec.provider;
302
303
  if (!refs.isValidRef(prov) || refs.kindOfRef(prov) !== "Component") {
@@ -335,9 +336,57 @@ export function loadDescriptors(repo, cfg, bundle, refs) {
335
336
  }, errors);
336
337
  }
337
338
 
339
+ // One directory migrates one database. Two Resources declaring the same path
340
+ // would make every migration under it ambiguous, and impact must not pick.
341
+ // Nested declarations are fine: the longest prefix wins, as for ownership.
342
+ const declaredBy = new Map();
343
+ for (const e of entities) {
344
+ if (e.kind !== "Resource") continue;
345
+ for (const p of e.doc.spec?.migrationPaths || []) {
346
+ if (!declaredBy.has(p)) declaredBy.set(p, []);
347
+ declaredBy.get(p).push(e);
348
+ }
349
+ }
350
+ for (const [p, owners] of [...declaredBy.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
351
+ if (owners.length < 2) continue;
352
+ const names = owners.map((o) => o.ref).sort();
353
+ errors.push(new AgentDocError(
354
+ CODES.MIGRATION_PATH,
355
+ "migration path " + JSON.stringify(p) + " is declared by several Resources (" + names.join(", ") + "); a migration directory migrates one logical database",
356
+ { path: owners[0].file, ref: names[0] }
357
+ ));
358
+ }
359
+
338
360
  return { entities, byRef, byKindName, componentFiles: new Set(componentFiles), errors, componentSet, centralSet };
339
361
  }
340
362
 
363
+ // `spec.migrationPaths` on a Resource: repository directories (or files) whose
364
+ // changes are changes to this database. They are whole-segment prefixes with
365
+ // the semantics of `core/paths.mjs`, never globs: a glob would need an engine
366
+ // this system does not ship, and `drizzle/**` means nothing a prefix does not.
367
+ function checkMigrationPaths(repo, e) {
368
+ if (e.doc.spec.type !== "logical-database") {
369
+ throw new AgentDocError(
370
+ CODES.MIGRATION_PATH,
371
+ "spec.migrationPaths is only meaningful on a logical-database Resource, not on type '" + e.doc.spec.type + "'",
372
+ { path: e.file, ref: e.ref }
373
+ );
374
+ }
375
+ for (const p of e.doc.spec.migrationPaths) {
376
+ assertRepoPath(p, e.file);
377
+ if (/[*?[\]{}!]/.test(p)) {
378
+ throw new AgentDocError(
379
+ CODES.MIGRATION_PATH,
380
+ "migration path " + JSON.stringify(p) + " is a glob; declare the directory itself (e.g. \"" + p.replace(/\/?[^/]*[*?[\]{}!].*$/, "") + "\"), which covers everything below it",
381
+ { path: e.file, ref: e.ref }
382
+ );
383
+ }
384
+ if (!repo.exists(p)) {
385
+ throw new AgentDocError(CODES.PATH_UNRESOLVED, "migration path does not resolve: " + p, { path: e.file, ref: e.ref });
386
+ }
387
+ }
388
+ }
389
+
341
390
  export function loadJourneys(repo, cfg, bundle, byRef) {
342
391
  const errors = [];
343
392
  const jPath = cfg.discovery.journeyDefinitions;
package/core/fsx.mjs CHANGED
@@ -70,11 +70,19 @@ export class Repo {
70
70
  this.root = path.resolve(root);
71
71
  this.readPaths = new Map();
72
72
  this.probes = new Map();
73
+ this.probeRecords = new Map();
73
74
  this.walks = new Map();
75
+ this.walkInputs = new Map();
74
76
  this.listings = new Map();
75
77
  this.nestedUnitRoots = new Map();
76
78
  this.generatedDirs = new Set(opts.generatedDirs || DEFAULT_GENERATED_DIRS);
77
79
  this.noGit = Boolean(opts.noGit);
80
+ this.snapshotReads = Boolean(opts.snapshotReads);
81
+ this.byteCache = new Map();
82
+ this.textCache = new Map();
83
+ this.jsonCache = new Map();
84
+ this.parseCache = opts.parseCache;
85
+ this.metrics = { physicalReads: 0, bytesRead: 0, readCacheHits: 0, jsonParses: 0, jsonCacheHits: 0, sharedJsonHits: 0 };
78
86
  // Per-Repo git caches. undefined = not asked yet; null = no answer.
79
87
  this._hasGit = undefined;
80
88
  this._prefix = undefined;
@@ -148,15 +156,18 @@ export class Repo {
148
156
  exists(rel) {
149
157
  const r = !this.isIgnored(rel) && fs.existsSync(this.abs(rel));
150
158
  this.probes.set(rel, r);
159
+ this.probeRecords.set("exists:" + rel, { kind: "exists", path: rel, value: r });
151
160
  return r;
152
161
  }
153
162
  isDir(rel) {
154
163
  try {
155
164
  const r = !this.isIgnored(rel) && fs.statSync(this.abs(rel)).isDirectory();
156
165
  this.probes.set(rel, r);
166
+ this.probeRecords.set("isDir:" + rel, { kind: "isDir", path: rel, value: r });
157
167
  return r;
158
168
  } catch {
159
169
  this.probes.set(rel, false);
170
+ this.probeRecords.set("isDir:" + rel, { kind: "isDir", path: rel, value: false });
160
171
  return false;
161
172
  }
162
173
  }
@@ -172,9 +183,15 @@ export class Repo {
172
183
  { path: rel }
173
184
  );
174
185
  }
175
- let buf;
186
+ let buf = this.snapshotReads ? this.byteCache.get(rel) : undefined;
187
+ if (buf) this.metrics.readCacheHits++;
176
188
  try {
177
- buf = fs.readFileSync(a);
189
+ if (!buf) {
190
+ buf = fs.readFileSync(a);
191
+ this.metrics.physicalReads++;
192
+ this.metrics.bytesRead += buf.length;
193
+ if (this.snapshotReads) this.byteCache.set(rel, buf);
194
+ }
178
195
  } catch {
179
196
  throw new AgentDocError(CODES.PATH_UNRESOLVED, "path does not resolve inside the repository: " + rel, { path: rel });
180
197
  }
@@ -183,6 +200,7 @@ export class Repo {
183
200
  }
184
201
  readText(rel, opts = {}) {
185
202
  const buf = this.readBytes(rel, opts);
203
+ if (this.snapshotReads && this.textCache.has(rel)) return this.textCache.get(rel);
186
204
  let text;
187
205
  try {
188
206
  text = new TextDecoder("utf-8", { fatal: true }).decode(buf);
@@ -190,10 +208,26 @@ export class Repo {
190
208
  throw new AgentDocError(CODES.UTF8, "file is not valid UTF-8: " + rel, { path: rel });
191
209
  }
192
210
  if (text.startsWith("")) text = text.slice(1);
211
+ if (this.snapshotReads) this.textCache.set(rel, text);
193
212
  return text;
194
213
  }
195
214
  readJson(rel) {
196
- return JSON.parse(this.readText(rel));
215
+ const text = this.readText(rel);
216
+ if (this.snapshotReads && this.jsonCache.has(rel)) {
217
+ this.metrics.jsonCacheHits++;
218
+ return structuredClone(this.jsonCache.get(rel));
219
+ }
220
+ const shared = this.parseCache?.get(text);
221
+ if (shared !== undefined) {
222
+ this.metrics.sharedJsonHits++;
223
+ if (this.snapshotReads) this.jsonCache.set(rel, structuredClone(shared));
224
+ return shared;
225
+ }
226
+ this.metrics.jsonParses++;
227
+ const value = JSON.parse(text);
228
+ this.parseCache?.set(text, value);
229
+ if (this.snapshotReads) this.jsonCache.set(rel, structuredClone(value));
230
+ return value;
197
231
  }
198
232
  readJsonUntracked(rel) {
199
233
  return JSON.parse(this.readText(rel, { track: false }));
@@ -215,6 +249,10 @@ export class Repo {
215
249
  continue;
216
250
  }
217
251
  entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
252
+ if (this.snapshotReads) {
253
+ const rel = path.relative(this.root, dir).split(path.sep).join("/") || ".";
254
+ this.walkInputs.set(rel, this.directoryState(rel, entries));
255
+ }
218
256
  for (const ent of entries) {
219
257
  if (this.generatedDirs.has(ent.name)) continue;
220
258
  const full = path.join(dir, ent.name);
@@ -232,6 +270,15 @@ export class Repo {
232
270
  this.walks.set(relDir || ".", out);
233
271
  return out;
234
272
  }
273
+ // Raw visited-directory state, independent of extractor filter closures.
274
+ // Those closures may depend on discovery state which evolves during compile.
275
+ directoryState(relDir, entries = fs.readdirSync(this.abs(relDir), { withFileTypes: true })) {
276
+ const prefix = relDir && relDir !== "." ? relDir + "/" : "";
277
+ return entries
278
+ .filter(e => !this.generatedDirs.has(e.name) && !this.isIgnored(prefix + e.name))
279
+ .map(e => [e.name, e.isSymbolicLink() ? "link" : e.isDirectory() ? "dir" : "file"])
280
+ .sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
281
+ }
235
282
  // Per-unit scans use the discovery result, never filesystem heuristics.
236
283
  // Keep walk() repository-wide for discovery, contracts and authored inputs.
237
284
  ownsUnitPath(unitRoot, rel) {
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.3.0";
12
+ export const COMPILER_VERSION = "1.5.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,7 +5,7 @@
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";
8
+ import { canonicalPath, ownsPath, longestOwner } from "./paths.mjs";
9
9
 
10
10
  // Changed paths are compared in the graph's canonical form (git's: no "./").
11
11
  // A path that is not repository-relative is kept verbatim so it is reported
@@ -23,7 +23,10 @@ const canonical = (p) => canonicalPath(p) ?? p;
23
23
  // - every extractor identity, which names the technology -> dependency /
24
24
  // verification
25
25
  // - every documentation and constraint path on an entity -> documentation
26
- function classify(graph, p, idx) {
26
+ // - every migration directory: declared on a logical-database -> resource
27
+ // Resource (`spec.migrationPaths`), or detected per component
28
+ // by the database adapter (`derived.migrationDirs`)
29
+ function classify(graph, p, idx, migrations) {
27
30
  for (const c of graph.contracts) if (c.ref === p) return "contract";
28
31
  for (const e of graph.entities) {
29
32
  const d = e.derived || {};
@@ -32,6 +35,13 @@ function classify(graph, p, idx) {
32
35
  for (const x of d.runbooks || []) if (x === p) return "documentation";
33
36
  }
34
37
  if (/\.(md|mdx|adoc|rst|txt)$/.test(p)) return "documentation";
38
+ if (migrationHit(migrations, p)) return "resource";
39
+ // Kept as a fallback, and the one name-based rule left: before the graph
40
+ // recorded migration directories this regex was the whole classification,
41
+ // and it still has to answer where nothing is recorded — a repository that
42
+ // does not enable the database adapter, a migration directory deeper than
43
+ // the adapter's bounded walk, a graph built by hand. Dropping it would change
44
+ // the answer for those repositories; what the graph records only adds to it.
35
45
  if (/(^|\/)(migrations?|alembic\/versions)\//.test(p)) return "resource";
36
46
  // A Resource named directly — by ref, or by the descriptor file that declares
37
47
  // it — is a resource change. Without this, `impact resource:default/x` reported
@@ -46,8 +56,43 @@ function classify(graph, p, idx) {
46
56
  return null;
47
57
  }
48
58
 
59
+ // Migration directories recorded in the graph, with what made them one.
60
+ function migrationDirs(graph) {
61
+ const declared = new Map(); // path -> Resource ref
62
+ const detected = new Map(); // path -> { componentRef, via }
63
+ for (const e of graph.entities) {
64
+ if (e.kind === "Resource") {
65
+ for (const p of e.entity?.spec?.migrationPaths || []) declared.set(p, e.ref);
66
+ }
67
+ if (e.kind === "Component") {
68
+ for (const d of e.derived?.migrationDirs || []) detected.set(d.path, { componentRef: e.ref, via: d.via || [] });
69
+ }
70
+ }
71
+ return { declared, detected };
72
+ }
73
+
74
+ // The migration directory a path is under, by whole path segments, the most
75
+ // specific first. An authored declaration beats a detected directory: it names
76
+ // the database, where detection only names the component whose bindings say
77
+ // which database it is.
78
+ function migrationHit(migrations, p) {
79
+ if (!migrations) return null;
80
+ const d = longestOwner(migrations.declared.keys(), p);
81
+ if (d) return { dir: d, resourceRef: migrations.declared.get(d), how: "declared on " + migrations.declared.get(d) };
82
+ const x = longestOwner(migrations.detected.keys(), p);
83
+ if (!x) return null;
84
+ const { componentRef, via } = migrations.detected.get(x);
85
+ // A directory found by its conventional name is exactly what the fallback
86
+ // regex already classified; only a directory nothing but the graph knows
87
+ // about is worth a reason of its own.
88
+ const conventional = via.every((v) => v === "directory-name" || v === "alembic-versions");
89
+ return { dir: x, componentRef, conventional, how: "detected from " + via.join(", ") + " in " + componentRef };
90
+ }
91
+
49
92
  export function impact(graph, changedPaths) {
50
93
  const idx = new ContextIndex(graph);
94
+ const migrations = migrationDirs(graph);
95
+ const migrationReasons = new Map(); // Resource ref -> Set(reason)
51
96
  const affectedComponents = new Map(); // ref -> Set(reason)
52
97
  const add = (ref, reason) => {
53
98
  if (idx.byRef.get(ref)?.kind !== "Component") return;
@@ -72,18 +117,35 @@ export function impact(graph, changedPaths) {
72
117
  // while the components actually bound to it went unmentioned.
73
118
  if (entity && entity.kind === "Component") add(entity.ref, "direct file change inside " + entity.ref);
74
119
  else unmatched.push(p);
75
- const kind = classify(graph, p, idx);
120
+ const kind = classify(graph, p, idx, migrations);
76
121
  if (kind === "contract") contracts.add(p);
77
122
  if (kind === "documentation") docs.add(p);
78
123
  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);
124
+ const hit = migrationHit(migrations, p);
125
+ const because = (ref) => {
126
+ if (!hit || hit.conventional) return;
127
+ if (!migrationReasons.has(ref)) migrationReasons.set(ref, new Set());
128
+ migrationReasons.get(ref).add("migration " + p + " changes " + ref + " (migration directory " + hit.dir + ", " + hit.how + ")");
129
+ };
130
+ if (hit?.resourceRef) {
131
+ // Authored: the declaration names the database, whichever component
132
+ // owns the file and whatever that component is bound to.
133
+ resources.add(hit.resourceRef);
134
+ because(hit.resourceRef);
135
+ } else {
136
+ resources.add(entity?.kind === "Resource" ? entity.ref : p);
137
+ // Migration authority is not ownership. Only follow an existing binding
138
+ // from the component containing this migration to a logical database.
139
+ const owner = hit?.componentRef ?? (entity?.kind === "Component" ? entity.ref : null);
140
+ if (owner) {
141
+ for (const rel of graph.relations) {
142
+ const bound = rel.type === "usesResource" && rel.sourceRef === owner ? rel.targetRef
143
+ : rel.type === "resourceUsedBy" && rel.targetRef === owner ? rel.sourceRef : null;
144
+ if (idx.byRef.get(bound)?.entity.spec.type === "logical-database") {
145
+ resources.add(bound);
146
+ because(bound);
147
+ }
148
+ }
87
149
  }
88
150
  }
89
151
  }
@@ -135,6 +197,7 @@ export function impact(graph, changedPaths) {
135
197
  const user = rel.type === "usesResource" ? rel.sourceRef : rel.targetRef;
136
198
  if (r === bound) {
137
199
  add(user, "resource change touches " + bound);
200
+ for (const reason of migrationReasons.get(bound) || []) add(user, reason);
138
201
  }
139
202
  }
140
203
  }