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.
- package/CHANGELOG.md +77 -1
- package/INSTALL_FOR_AGENTS.md +1 -1
- package/README.md +5 -1
- package/adapters/database.mjs +153 -5
- package/bin/agentdoc.mjs +16 -1
- package/bin/commands.mjs +15 -0
- package/bin/usage.txt +4 -0
- package/core/cache/compiled.mjs +34 -0
- package/core/cache/parses.mjs +33 -0
- package/core/codes.mjs +13 -0
- package/core/compile.mjs +30 -2
- package/core/context/index.mjs +173 -0
- package/core/context/symbols.mjs +81 -0
- package/core/descriptors.mjs +49 -0
- package/core/fsx.mjs +50 -3
- package/core/graph.mjs +1 -1
- package/core/impact.mjs +74 -11
- package/core/query.mjs +58 -7
- package/core/session/results.mjs +138 -0
- package/core/snapshot.mjs +123 -0
- package/docs/ADAPTERS.md +48 -4
- package/docs/CI.md +3 -1
- package/docs/CONTEXT.md +136 -0
- package/docs/EVALS.md +124 -0
- package/docs/IMPLEMENTATION-PLAN.md +310 -0
- package/docs/QUERY.md +48 -9
- package/docs/SPEC.md +42 -0
- package/docs/ci/github-actions.yml +1 -1
- package/evals/agent/codex-driver.mjs +32 -0
- package/evals/agent/optimize.mjs +48 -0
- package/evals/agent/results/smoke-gpt-6.1-sol.json +81 -0
- package/evals/agent/run.mjs +163 -0
- package/evals/agent/tasks/corpus.mjs +53 -0
- package/evals/agent/tasks/pins.json +32 -0
- package/package.json +9 -5
- package/schemas/context.schema.json +222 -0
- package/schemas/resource.schema.json +9 -1
- package/skills/agent-doc-system/SKILL.md +6 -0
- package/templates/ci.yml +1 -1
|
@@ -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
|
+
}
|
package/core/descriptors.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
}
|