syncstaff-mcp 0.2.3
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/README.md +86 -0
- package/dist/lib/agent-state.js +119 -0
- package/dist/lib/blast.js +462 -0
- package/dist/lib/client-config.js +81 -0
- package/dist/lib/env-compat.js +66 -0
- package/dist/lib/globs.js +0 -0
- package/dist/lib/ids.js +24 -0
- package/dist/lib/index/aliases.js +244 -0
- package/dist/lib/index/call-sites.js +178 -0
- package/dist/lib/index/checker-resolver.js +257 -0
- package/dist/lib/index/context-card.js +140 -0
- package/dist/lib/index/coverage.js +218 -0
- package/dist/lib/index/delivery.js +66 -0
- package/dist/lib/index/discovery.js +90 -0
- package/dist/lib/index/embedding.js +110 -0
- package/dist/lib/index/file-index.js +222 -0
- package/dist/lib/index/fingerprint.js +0 -0
- package/dist/lib/index/git-history.js +136 -0
- package/dist/lib/index/graph.js +234 -0
- package/dist/lib/index/impact.js +174 -0
- package/dist/lib/index/incremental.js +332 -0
- package/dist/lib/index/lexical.js +462 -0
- package/dist/lib/index/order.js +43 -0
- package/dist/lib/index/pages.js +357 -0
- package/dist/lib/index/persistence.js +233 -0
- package/dist/lib/index/pipeline.js +527 -0
- package/dist/lib/index/registry.js +106 -0
- package/dist/lib/index/resolve.js +280 -0
- package/dist/lib/index/semantic.js +381 -0
- package/dist/lib/index/surfaces.js +27 -0
- package/dist/lib/index/symbols.js +426 -0
- package/dist/lib/index/transformers-embedder.js +73 -0
- package/dist/lib/index/typescript-parser.js +532 -0
- package/dist/lib/index/vector-cache.js +176 -0
- package/dist/lib/index/verification.js +58 -0
- package/dist/lib/mcp-compaction.js +241 -0
- package/dist/lib/model-roles.js +206 -0
- package/dist/lib/path-warnings.js +90 -0
- package/dist/lib/protocol.js +95 -0
- package/dist/lib/types.js +69 -0
- package/dist/lib/version.js +21 -0
- package/dist/lib/worktree.js +211 -0
- package/dist/mcp/approval.js +0 -0
- package/dist/mcp/cloud-connector.js +99 -0
- package/dist/mcp/daemon-client.js +156 -0
- package/dist/mcp/daemon-protocol.js +100 -0
- package/dist/mcp/escalation-waiter.js +183 -0
- package/dist/mcp/graph-ops.js +169 -0
- package/dist/mcp/index.js +1151 -0
- package/dist/mcp/login.js +169 -0
- package/dist/mcp/setup.js +90 -0
- package/package.json +42 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call resolution through the TypeScript type checker.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS, given ADR 002 chose a syntactic parser on purpose.
|
|
5
|
+
*
|
|
6
|
+
* The syntactic pass refuses any member call whose receiver type it does not
|
|
7
|
+
* track. That refusal is what keeps it from inventing edges, and it is why its
|
|
8
|
+
* precision has held at 1.00 against the compiler. The cost of that refusal
|
|
9
|
+
* was never measured until now, and it is much larger than assumed:
|
|
10
|
+
*
|
|
11
|
+
* keel (plain functions) 470 edges found, checker finds 695 — 64% recall
|
|
12
|
+
* zod (fluent API) 722 edges found, checker finds 3178 — 14% recall
|
|
13
|
+
*
|
|
14
|
+
* `z.string()` is 2,591 calls in zod that the syntactic pass cannot see, and
|
|
15
|
+
* measuring the cheap escape hatch — receivers imported from inside the same
|
|
16
|
+
* repo — put it at 0-7% across five repositories. There is no middle path
|
|
17
|
+
* between a parser and a checker; the receiver types genuinely have to be
|
|
18
|
+
* inferred.
|
|
19
|
+
*
|
|
20
|
+
* WHAT THIS IS NOT. It does not replace the syntactic backend. It is slower by
|
|
21
|
+
* 6-7x (4.1s on keel, 11.2s on zod, against 0.7s and 1.6s), it needs a
|
|
22
|
+
* tsconfig, and it wants node_modules present to resolve external types. The
|
|
23
|
+
* syntactic pass still answers when those are missing, which is the ordinary
|
|
24
|
+
* case in a fresh checkout. Callers choose, and the result says which backend
|
|
25
|
+
* produced it so nobody has to guess.
|
|
26
|
+
*
|
|
27
|
+
* PRIVACY. Same rule as everything else here: the program is constructed from
|
|
28
|
+
* files on this disk, the checker runs in this process, and nothing leaves it
|
|
29
|
+
* (inv_01KZWPAM9QCZ0DD7789N55X647). A type checker reads more of the code than
|
|
30
|
+
* a parser does; it still reads it locally.
|
|
31
|
+
*
|
|
32
|
+
* ATTRIBUTION. The checker follows aliases to the original declaration, so a
|
|
33
|
+
* call reaching a symbol through a re-export is attributed to the file that
|
|
34
|
+
* declares it rather than the barrel it was imported from. The syntactic pass
|
|
35
|
+
* reports the barrel. Both are defensible; the checker's answer is the more
|
|
36
|
+
* useful one for "which file must I edit", and the difference accounts for
|
|
37
|
+
* most of what looked like disagreement between the two backends.
|
|
38
|
+
*/
|
|
39
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
40
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
41
|
+
import { byCodeUnit } from "./order.js";
|
|
42
|
+
const posix = (p) => p.split("\\").join("/");
|
|
43
|
+
/**
|
|
44
|
+
* Find the tsconfig that actually lists source files.
|
|
45
|
+
*
|
|
46
|
+
* Solution-style configs — a root tsconfig whose only job is `references` —
|
|
47
|
+
* produce a program with zero files, and a zero-file program reports zero
|
|
48
|
+
* edges rather than an error. That silence is indistinguishable from "this
|
|
49
|
+
* repo has no calls", which is how a broken measurement passes for a finding:
|
|
50
|
+
* probing hono this way returned 0 edges from 0 call expressions and it looked
|
|
51
|
+
* like a result until the file count gave it away.
|
|
52
|
+
*/
|
|
53
|
+
export function findProjectConfigs(ts, root) {
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
const withFiles = [];
|
|
56
|
+
const visit = (configPath, depth) => {
|
|
57
|
+
if (depth > 3 || seen.has(configPath) || !existsSync(configPath))
|
|
58
|
+
return;
|
|
59
|
+
seen.add(configPath);
|
|
60
|
+
const raw = ts.readConfigFile(configPath, (p) => {
|
|
61
|
+
try {
|
|
62
|
+
return readFileSync(p, "utf8");
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
const parsed = ts.parseJsonConfigFileContent(raw.config ?? {}, ts.sys, dirname(configPath));
|
|
69
|
+
if (parsed.fileNames.length > 0)
|
|
70
|
+
withFiles.push(configPath);
|
|
71
|
+
for (const ref of parsed.projectReferences ?? []) {
|
|
72
|
+
const target = ref.path.endsWith(".json") ? ref.path : join(ref.path, "tsconfig.json");
|
|
73
|
+
visit(resolve(dirname(configPath), target), depth + 1);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const NAMES = ["tsconfig.json", "tsconfig.base.json", "tsconfig.build.json"];
|
|
77
|
+
for (const candidate of NAMES)
|
|
78
|
+
visit(join(root, candidate), 0);
|
|
79
|
+
// Monorepos keep the tsconfig one level down. `repoRoot()` returns the git
|
|
80
|
+
// root, so asking about a symbol in keel/ resolved to /home/.../sync, which
|
|
81
|
+
// has no tsconfig — findProjectConfigs returned nothing, the checker backend
|
|
82
|
+
// reported "cannot run here", and the hook fell back to the parser while
|
|
83
|
+
// still claiming it had been asked for the checker. A silent downgrade is
|
|
84
|
+
// the worst of the three outcomes.
|
|
85
|
+
if (withFiles.length === 0) {
|
|
86
|
+
let children = [];
|
|
87
|
+
try {
|
|
88
|
+
children = readdirSync(root);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
children = [];
|
|
92
|
+
}
|
|
93
|
+
for (const child of children.sort()) {
|
|
94
|
+
if (child.startsWith(".") || child === "node_modules")
|
|
95
|
+
continue;
|
|
96
|
+
let isDir = false;
|
|
97
|
+
try {
|
|
98
|
+
isDir = statSync(join(root, child)).isDirectory();
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
isDir = false;
|
|
102
|
+
}
|
|
103
|
+
if (!isDir)
|
|
104
|
+
continue;
|
|
105
|
+
for (const candidate of NAMES)
|
|
106
|
+
visit(join(root, child, candidate), 0);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return withFiles;
|
|
110
|
+
}
|
|
111
|
+
/** The nearest enclosing named function/method, for a checkable reason string. */
|
|
112
|
+
function enclosingName(ts, node) {
|
|
113
|
+
for (let n = node.parent; n; n = n.parent) {
|
|
114
|
+
if (ts.isFunctionDeclaration(n) || ts.isMethodDeclaration(n))
|
|
115
|
+
return n.name?.getText();
|
|
116
|
+
if (ts.isVariableDeclaration(n) && n.initializer && (ts.isArrowFunction(n.initializer) || ts.isFunctionExpression(n.initializer))) {
|
|
117
|
+
return n.name.getText();
|
|
118
|
+
}
|
|
119
|
+
if (ts.isPropertyAssignment(n) && n.initializer && (ts.isArrowFunction(n.initializer) || ts.isFunctionExpression(n.initializer))) {
|
|
120
|
+
return n.name.getText();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Every repo-internal call edge the checker can see.
|
|
127
|
+
*
|
|
128
|
+
* Edges to declarations outside the repository are counted, not returned:
|
|
129
|
+
* `console.log` and third-party APIs are real calls but not edges anyone can
|
|
130
|
+
* act on, and reporting them as impact is the miscalibration that made the
|
|
131
|
+
* `Date.now` caveat so expensive.
|
|
132
|
+
*/
|
|
133
|
+
export function buildCheckerCallGraph(ts, root, configPaths) {
|
|
134
|
+
const started = Date.now();
|
|
135
|
+
const configs = configPaths ?? findProjectConfigs(ts, root);
|
|
136
|
+
const fileNames = new Set();
|
|
137
|
+
let options = {};
|
|
138
|
+
for (const configPath of configs) {
|
|
139
|
+
const raw = ts.readConfigFile(configPath, (p) => {
|
|
140
|
+
try {
|
|
141
|
+
return readFileSync(p, "utf8");
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
const parsed = ts.parseJsonConfigFileContent(raw.config ?? {}, ts.sys, dirname(configPath));
|
|
148
|
+
for (const f of parsed.fileNames)
|
|
149
|
+
fileNames.add(f);
|
|
150
|
+
options = { ...parsed.options, ...options };
|
|
151
|
+
}
|
|
152
|
+
const program = ts.createProgram([...fileNames], {
|
|
153
|
+
...options,
|
|
154
|
+
// Nothing is emitted and lib.d.ts is trusted: this is a resolution query,
|
|
155
|
+
// not a build, and a repo that does not currently compile must still be
|
|
156
|
+
// answerable.
|
|
157
|
+
noEmit: true,
|
|
158
|
+
skipLibCheck: true,
|
|
159
|
+
});
|
|
160
|
+
const checker = program.getTypeChecker();
|
|
161
|
+
const edges = [];
|
|
162
|
+
const files = [];
|
|
163
|
+
let considered = 0;
|
|
164
|
+
let external = 0;
|
|
165
|
+
let unresolved = 0;
|
|
166
|
+
const inRepo = (abs) => {
|
|
167
|
+
const rel = posix(relative(root, abs));
|
|
168
|
+
return rel && !rel.startsWith("..") && !rel.includes("node_modules") ? rel : null;
|
|
169
|
+
};
|
|
170
|
+
for (const sf of program.getSourceFiles()) {
|
|
171
|
+
if (sf.isDeclarationFile)
|
|
172
|
+
continue;
|
|
173
|
+
const from = inRepo(sf.fileName);
|
|
174
|
+
if (!from)
|
|
175
|
+
continue;
|
|
176
|
+
files.push(from);
|
|
177
|
+
const visit = (node) => {
|
|
178
|
+
if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
|
|
179
|
+
considered += 1;
|
|
180
|
+
const member = ts.isPropertyAccessExpression(node.expression);
|
|
181
|
+
const target = member ? node.expression.name : node.expression;
|
|
182
|
+
let symbol = checker.getSymbolAtLocation(target);
|
|
183
|
+
if (symbol && symbol.flags & ts.SymbolFlags.Alias) {
|
|
184
|
+
// Follow re-exports to the declaring file. A caller asking what to
|
|
185
|
+
// edit wants where the function lives, not which barrel it came in
|
|
186
|
+
// through.
|
|
187
|
+
try {
|
|
188
|
+
symbol = checker.getAliasedSymbol(symbol);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
/* keep the alias */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const declarations = symbol?.declarations ?? [];
|
|
195
|
+
if (declarations.length === 0)
|
|
196
|
+
unresolved += 1;
|
|
197
|
+
let landedInRepo = false;
|
|
198
|
+
for (const d of declarations) {
|
|
199
|
+
const to = inRepo(d.getSourceFile().fileName);
|
|
200
|
+
if (!to || to === from)
|
|
201
|
+
continue;
|
|
202
|
+
landedInRepo = true;
|
|
203
|
+
edges.push({
|
|
204
|
+
from,
|
|
205
|
+
to,
|
|
206
|
+
symbol: symbol.getName(),
|
|
207
|
+
line: sf.getLineAndCharacterOfPosition(node.getStart()).line + 1,
|
|
208
|
+
enclosing: enclosingName(ts, node),
|
|
209
|
+
member,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (declarations.length > 0 && !landedInRepo)
|
|
213
|
+
external += 1;
|
|
214
|
+
}
|
|
215
|
+
ts.forEachChild(node, visit);
|
|
216
|
+
};
|
|
217
|
+
visit(sf);
|
|
218
|
+
}
|
|
219
|
+
// byCodeUnit, not localeCompare: collation is locale-dependent, and an
|
|
220
|
+
// index that sorts differently on another machine stops being comparable
|
|
221
|
+
// to itself. order.ts carries the incident that established this.
|
|
222
|
+
edges.sort((a, b) => byCodeUnit(a.from, b.from) || a.line - b.line || byCodeUnit(a.symbol, b.symbol));
|
|
223
|
+
return {
|
|
224
|
+
edges,
|
|
225
|
+
files: files.sort(byCodeUnit),
|
|
226
|
+
considered,
|
|
227
|
+
external,
|
|
228
|
+
unresolved,
|
|
229
|
+
backend: "typescript-checker",
|
|
230
|
+
ms: Date.now() - started,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Load the checker backend, or null when it cannot run here.
|
|
235
|
+
*
|
|
236
|
+
* Null is a normal answer, not a failure: a checkout without node_modules, or
|
|
237
|
+
* without a tsconfig that lists files, cannot be type-checked, and the
|
|
238
|
+
* syntactic backend exists for exactly that case.
|
|
239
|
+
*/
|
|
240
|
+
export async function loadCheckerProgram(root) {
|
|
241
|
+
let ts;
|
|
242
|
+
try {
|
|
243
|
+
const loaded = (await import("typescript"));
|
|
244
|
+
ts = (loaded.default ?? loaded);
|
|
245
|
+
if (typeof ts.createProgram !== "function")
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
const configs = findProjectConfigs(ts, root);
|
|
252
|
+
return configs.length > 0 ? { ts, configs } : null;
|
|
253
|
+
}
|
|
254
|
+
/** Callers of one symbol declared in one file — the question the hook asks. */
|
|
255
|
+
export function callersOf(graph, symbol, declaredIn) {
|
|
256
|
+
return graph.edges.filter((e) => e.symbol === symbol && e.to === declaredIn);
|
|
257
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { byCodeUnit } from "./order.js";
|
|
2
|
+
const DEFAULT_SKELETON_LINES = 60;
|
|
3
|
+
const DEFAULT_MAX_CALLERS = 20;
|
|
4
|
+
export function buildContextCard(index, graph, calls, path, options = {}) {
|
|
5
|
+
const file = index.files.find((candidate) => candidate.path === path);
|
|
6
|
+
if (!file)
|
|
7
|
+
return null;
|
|
8
|
+
const maxCallers = options.maxCallers ?? DEFAULT_MAX_CALLERS;
|
|
9
|
+
const maxSkeleton = options.maxSkeletonLines ?? DEFAULT_SKELETON_LINES;
|
|
10
|
+
const notes = [];
|
|
11
|
+
const importEdges = graph.edges.filter((edge) => edge.from === path);
|
|
12
|
+
const resolvedBySpecifier = new Map();
|
|
13
|
+
for (const edge of importEdges)
|
|
14
|
+
resolvedBySpecifier.set(edge.to, edge.to);
|
|
15
|
+
const imports = file.result.imports.map((imported) => ({
|
|
16
|
+
specifier: imported.specifier,
|
|
17
|
+
resolved: graph.edges.find((edge) => edge.from === path && imported.names.every((n) => edge.names.includes(n)))
|
|
18
|
+
?.to ?? null,
|
|
19
|
+
}));
|
|
20
|
+
// Callers: import edges and call edges pointing at this file, merged so a
|
|
21
|
+
// file that both imports and calls appears once with both reasons.
|
|
22
|
+
const callerReasons = new Map();
|
|
23
|
+
for (const edge of graph.edges) {
|
|
24
|
+
if (edge.to !== path)
|
|
25
|
+
continue;
|
|
26
|
+
append(callerReasons, edge.from, `imports this file`);
|
|
27
|
+
}
|
|
28
|
+
for (const edge of calls.edges) {
|
|
29
|
+
if (edge.to !== path || edge.from === path)
|
|
30
|
+
continue;
|
|
31
|
+
append(callerReasons, edge.from, `calls ${edge.symbol}()`);
|
|
32
|
+
}
|
|
33
|
+
const allCallers = [...callerReasons.entries()]
|
|
34
|
+
.map(([caller, reasons]) => ({ path: caller, reason: reasons.sort().join("; ") }))
|
|
35
|
+
.sort((a, b) => byCodeUnit(a.path, b.path));
|
|
36
|
+
const callers = allCallers.slice(0, maxCallers);
|
|
37
|
+
if (allCallers.length > callers.length) {
|
|
38
|
+
notes.push(`${allCallers.length - callers.length} further callers were omitted by the budget`);
|
|
39
|
+
}
|
|
40
|
+
const callees = calls.edges
|
|
41
|
+
.filter((edge) => edge.from === path && edge.to !== path)
|
|
42
|
+
.map((edge) => ({ path: edge.to, symbol: edge.symbol }))
|
|
43
|
+
.sort((a, b) => byCodeUnit(a.path, b.path) || byCodeUnit(a.symbol, b.symbol));
|
|
44
|
+
let skeleton = [];
|
|
45
|
+
let truncated = false;
|
|
46
|
+
if (options.readFile) {
|
|
47
|
+
try {
|
|
48
|
+
const outline = outlineOf(options.readFile(path));
|
|
49
|
+
skeleton = outline.slice(0, maxSkeleton);
|
|
50
|
+
truncated = outline.length > skeleton.length;
|
|
51
|
+
if (truncated)
|
|
52
|
+
notes.push(`skeleton truncated at ${maxSkeleton} lines`);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
notes.push(`source could not be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const parsed = file.result.status === "parsed";
|
|
59
|
+
if (!parsed) {
|
|
60
|
+
notes.push(`this file's parse status is "${file.result.status}", so its surface may be incomplete`);
|
|
61
|
+
}
|
|
62
|
+
// A caller list is only as complete as the graph behind it. Reusing the same
|
|
63
|
+
// honesty rule as impact: unresolved edges anywhere mean the neighbourhood
|
|
64
|
+
// may be larger than shown.
|
|
65
|
+
if (graph.unresolved.length > 0) {
|
|
66
|
+
notes.push(`${graph.unresolved.length} unresolved imports repository-wide may hide further callers`);
|
|
67
|
+
}
|
|
68
|
+
if (calls.unanalyzed.length > 0) {
|
|
69
|
+
notes.push(`${calls.unanalyzed.length} files had no call analysis`);
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
localOnly: true,
|
|
73
|
+
path,
|
|
74
|
+
language: file.language,
|
|
75
|
+
exports: file.result.exports.map((exported) => ({ name: exported.name, kind: exported.kind })),
|
|
76
|
+
imports,
|
|
77
|
+
callers,
|
|
78
|
+
callees,
|
|
79
|
+
skeleton,
|
|
80
|
+
quality: {
|
|
81
|
+
parsed,
|
|
82
|
+
complete: graph.unresolved.length === 0 && calls.unanalyzed.length === 0 && parsed,
|
|
83
|
+
truncated,
|
|
84
|
+
notes,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A structural outline: lines that declare something, with bodies dropped.
|
|
90
|
+
*
|
|
91
|
+
* Intentionally crude and language-agnostic. A real outline belongs to the
|
|
92
|
+
* language backend, which knows what a declaration is; doing it properly here
|
|
93
|
+
* would mean a second, worse parser living outside the registry — the exact
|
|
94
|
+
* duplication the registry exists to prevent. This is a budgeted preview, and
|
|
95
|
+
* anything that depends on precision should use `exports` instead.
|
|
96
|
+
*/
|
|
97
|
+
function outlineOf(source) {
|
|
98
|
+
const lines = source.split("\n");
|
|
99
|
+
const outline = [];
|
|
100
|
+
for (const raw of lines) {
|
|
101
|
+
const line = raw.trimEnd();
|
|
102
|
+
const trimmed = line.trim();
|
|
103
|
+
if (!trimmed)
|
|
104
|
+
continue;
|
|
105
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*"))
|
|
106
|
+
continue;
|
|
107
|
+
// Indentation is the cheap, language-agnostic proxy for "this is a
|
|
108
|
+
// declaration rather than a statement inside one". Without it, a `const`
|
|
109
|
+
// in a function body matches the same pattern as a top-level `const` and
|
|
110
|
+
// the outline quietly becomes a copy of the source — which is the one
|
|
111
|
+
// thing a bounded preview must not be.
|
|
112
|
+
if (/^\s/.test(raw))
|
|
113
|
+
continue;
|
|
114
|
+
if (/^(export|import|class|interface|type|function|async function|const|let|enum|def|impl|struct)\b/.test(trimmed)) {
|
|
115
|
+
// Drop an opening body brace so the outline reads as signatures.
|
|
116
|
+
outline.push(line.replace(/\s*\{\s*$/, ""));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return outline;
|
|
120
|
+
}
|
|
121
|
+
function append(map, key, value) {
|
|
122
|
+
const list = map.get(key);
|
|
123
|
+
if (list)
|
|
124
|
+
list.push(value);
|
|
125
|
+
else
|
|
126
|
+
map.set(key, [value]);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Throws if a context card is about to be transmitted.
|
|
130
|
+
*
|
|
131
|
+
* Call this at any point where a value of unknown provenance is heading for
|
|
132
|
+
* the network. It is a runtime backstop for the case the type system cannot
|
|
133
|
+
* see — a card that arrived as `unknown` from JSON, or through an `any`.
|
|
134
|
+
*/
|
|
135
|
+
export function assertNotTransmissible(value) {
|
|
136
|
+
if (value && typeof value === "object" && value.localOnly === true) {
|
|
137
|
+
throw new Error("refusing to transmit a local context card: it carries source skeletons, which must never " +
|
|
138
|
+
"reach the central server (inv_01KZWPAM9QCZ0DD7789N55X647). Send a PrivacyBoundaryPayload instead.");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { byCodeUnit } from "./order.js";
|
|
2
|
+
/** How many examples to keep per reason. Bounded so a fact never grows without limit. */
|
|
3
|
+
const SAMPLE_LIMIT = 5;
|
|
4
|
+
const take = (values) => ({
|
|
5
|
+
count: values.length,
|
|
6
|
+
sample: [...new Set(values)].sort(byCodeUnit).slice(0, SAMPLE_LIMIT),
|
|
7
|
+
});
|
|
8
|
+
/**
|
|
9
|
+
* Build one fact per file the index knows about.
|
|
10
|
+
*
|
|
11
|
+
* Every input is already keyed by the file that caused it — `UnresolvedEdge.from`,
|
|
12
|
+
* `UnresolvedCall.from`, `blindSpots[].path` — so attribution is a regrouping
|
|
13
|
+
* rather than an inference. That is why this can be exact: it is not guessing
|
|
14
|
+
* which file a repo-wide count came from, it is declining to throw that
|
|
15
|
+
* information away.
|
|
16
|
+
*/
|
|
17
|
+
export function buildCoverageFacts(index, graph, calls) {
|
|
18
|
+
const unresolvedImports = new Map();
|
|
19
|
+
for (const edge of graph.unresolved) {
|
|
20
|
+
// A dynamic specifier is unresolvable by construction rather than by
|
|
21
|
+
// failure, but it still hides a real edge, so it counts. The reason
|
|
22
|
+
// distinguishes them for anyone reading.
|
|
23
|
+
const list = unresolvedImports.get(edge.from);
|
|
24
|
+
const label = edge.dynamic ? `${edge.specifier} (dynamic)` : edge.specifier;
|
|
25
|
+
if (list)
|
|
26
|
+
list.push(label);
|
|
27
|
+
else
|
|
28
|
+
unresolvedImports.set(edge.from, [label]);
|
|
29
|
+
}
|
|
30
|
+
// Names something in this repository actually defines. Without this filter
|
|
31
|
+
// the facts are worse than useless.
|
|
32
|
+
//
|
|
33
|
+
// `resolveCallEdges` reports every callee it did not bind, and in a real file
|
|
34
|
+
// that is overwhelmingly `JSON.stringify`, `Math.floor`, `assert.equal`,
|
|
35
|
+
// `map`, `filter`, `edges.push`. None of them is evidence that a reference to
|
|
36
|
+
// *your* symbol was missed. An unfiltered list said `code-map.ts` had 80
|
|
37
|
+
// unbound calls, which reads as catastrophic and means nothing.
|
|
38
|
+
//
|
|
39
|
+
// `resolveReferenceEdges` already applies exactly this rule, and its comment
|
|
40
|
+
// says why: "a false incompleteness warning destroys a correct answer and
|
|
41
|
+
// teaches the reader to discount the real ones." That is the Experiment C
|
|
42
|
+
// lesson, and coverage facts are precisely the surface where ignoring it
|
|
43
|
+
// would do the most damage — a blind-spot report nobody believes is worse
|
|
44
|
+
// than none, because it still looks like diligence.
|
|
45
|
+
const definedAnywhere = new Set();
|
|
46
|
+
for (const file of index.files) {
|
|
47
|
+
for (const definition of file.result.definitions ?? [])
|
|
48
|
+
definedAnywhere.add(definition.name);
|
|
49
|
+
}
|
|
50
|
+
// Repo-defined is necessary but not sufficient, and the gap between the two
|
|
51
|
+
// is where a name-matching filter turns back into noise. This repository
|
|
52
|
+
// defines local helpers called `push`, `add` and `get`, so `edges.push` and
|
|
53
|
+
// `bySymbol.get` pass a last-segment test and are still not references to
|
|
54
|
+
// anything. An unfiltered run called that 62 unbound calls in one module; the
|
|
55
|
+
// last-segment filter only got it to 16, all of them still spurious.
|
|
56
|
+
//
|
|
57
|
+
// So the rule uses structure rather than name resemblance, per file:
|
|
58
|
+
//
|
|
59
|
+
// bare callee count it when the repository defines the name and THIS file
|
|
60
|
+
// does not — a call to a local helper is not a cross-file
|
|
61
|
+
// dependent, which is the only kind a blast radius cares about.
|
|
62
|
+
//
|
|
63
|
+
// recv.callee count it when `recv` is a binding this file imports. That is
|
|
64
|
+
// the namespace case, `registryMod.LanguageRegistry`, which is
|
|
65
|
+
// exactly the one real miss the reference-edge grading found.
|
|
66
|
+
// `edges.push` fails it because `edges` is a local variable.
|
|
67
|
+
//
|
|
68
|
+
// No denylist of built-in method names, deliberately. A hand-maintained list
|
|
69
|
+
// of what JavaScript defines would be arbitrary, would need upkeep, and would
|
|
70
|
+
// be wrong in both directions.
|
|
71
|
+
const definedIn = new Map();
|
|
72
|
+
for (const file of index.files) {
|
|
73
|
+
definedIn.set(file.path, new Set((file.result.definitions ?? []).map((d) => d.name)));
|
|
74
|
+
}
|
|
75
|
+
// Bindings taken across an import edge that RESOLVED TO A FILE IN THIS REPO.
|
|
76
|
+
//
|
|
77
|
+
// Read from the graph rather than from the file's own import list, and the
|
|
78
|
+
// difference matters: `import assert from "node:assert/strict"` makes
|
|
79
|
+
// `assert` an imported binding, so `assert.deepEqual` passes a naive
|
|
80
|
+
// "receiver is imported" test and is obviously not a dependent of anything
|
|
81
|
+
// here. Graph edges only exist between repo files, so an external package can
|
|
82
|
+
// never contribute one.
|
|
83
|
+
const repoBindings = new Map();
|
|
84
|
+
for (const edge of graph.edges) {
|
|
85
|
+
let bindings = repoBindings.get(edge.from);
|
|
86
|
+
if (!bindings) {
|
|
87
|
+
bindings = new Set();
|
|
88
|
+
repoBindings.set(edge.from, bindings);
|
|
89
|
+
}
|
|
90
|
+
for (const name of edge.names)
|
|
91
|
+
bindings.add(name);
|
|
92
|
+
}
|
|
93
|
+
const couldHideADependent = (from, call) => {
|
|
94
|
+
if (!call.receiver) {
|
|
95
|
+
// A bare callee is now distinguishable from a member call. Count it only
|
|
96
|
+
// when this file does not define the name itself; that is the one case in
|
|
97
|
+
// which an unresolved bare call can hide a cross-file dependent.
|
|
98
|
+
return definedAnywhere.has(call.callee) && !(definedIn.get(from)?.has(call.callee) ?? false);
|
|
99
|
+
}
|
|
100
|
+
// Only the outermost receiver matters: `a.b.c` is reached through `a`.
|
|
101
|
+
const root = call.receiver.split(".")[0];
|
|
102
|
+
return repoBindings.get(from)?.has(root) ?? false;
|
|
103
|
+
};
|
|
104
|
+
const unboundCalls = new Map();
|
|
105
|
+
for (const call of calls.unresolved) {
|
|
106
|
+
if (!couldHideADependent(call.from, call))
|
|
107
|
+
continue;
|
|
108
|
+
const list = unboundCalls.get(call.from);
|
|
109
|
+
if (list)
|
|
110
|
+
list.push(call.callee);
|
|
111
|
+
else
|
|
112
|
+
unboundCalls.set(call.from, [call.callee]);
|
|
113
|
+
}
|
|
114
|
+
const blindSpots = new Map();
|
|
115
|
+
for (const spot of graph.blindSpots) {
|
|
116
|
+
// First reason wins, deterministically: blindSpots is already sorted by the
|
|
117
|
+
// index, and a file with two reasons is not twice as blind.
|
|
118
|
+
if (!blindSpots.has(spot.path))
|
|
119
|
+
blindSpots.set(spot.path, spot.reason);
|
|
120
|
+
}
|
|
121
|
+
const unanalyzed = new Set(calls.unanalyzed);
|
|
122
|
+
const facts = new Map();
|
|
123
|
+
for (const file of index.files) {
|
|
124
|
+
const imports = take(unresolvedImports.get(file.path) ?? []);
|
|
125
|
+
const bound = take(unboundCalls.get(file.path) ?? []);
|
|
126
|
+
const blindSpot = blindSpots.get(file.path) ?? null;
|
|
127
|
+
const isUnanalyzed = unanalyzed.has(file.path);
|
|
128
|
+
facts.set(file.path, {
|
|
129
|
+
path: file.path,
|
|
130
|
+
backend: file.parser ?? null,
|
|
131
|
+
language: file.language ?? null,
|
|
132
|
+
unresolvedImports: imports,
|
|
133
|
+
unboundCalls: bound,
|
|
134
|
+
blindSpot,
|
|
135
|
+
unanalyzed: isUnanalyzed,
|
|
136
|
+
clean: imports.count === 0 && bound.count === 0 && blindSpot === null && !isUnanalyzed,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return facts;
|
|
140
|
+
}
|
|
141
|
+
export function coverageForRadius(facts, paths) {
|
|
142
|
+
const inRadius = new Set(paths);
|
|
143
|
+
const within = [];
|
|
144
|
+
let elsewhereCount = 0;
|
|
145
|
+
for (const fact of facts.values()) {
|
|
146
|
+
if (fact.clean)
|
|
147
|
+
continue;
|
|
148
|
+
if (inRadius.has(fact.path))
|
|
149
|
+
within.push(fact);
|
|
150
|
+
else
|
|
151
|
+
elsewhereCount += 1;
|
|
152
|
+
}
|
|
153
|
+
within.sort((a, b) => byCodeUnit(a.path, b.path));
|
|
154
|
+
return {
|
|
155
|
+
within,
|
|
156
|
+
withinCount: within.length,
|
|
157
|
+
elsewhereCount,
|
|
158
|
+
radiusComplete: within.length === 0,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** One human-checkable line per blind spot. Used by the context hook. */
|
|
162
|
+
export function explainCoverage(coverage) {
|
|
163
|
+
if (coverage.radiusComplete) {
|
|
164
|
+
return coverage.elsewhereCount === 0
|
|
165
|
+
? []
|
|
166
|
+
: [
|
|
167
|
+
`Graph coverage: complete for these files. ${coverage.elsewhereCount} file` +
|
|
168
|
+
`${coverage.elsewhereCount === 1 ? "" : "s"} elsewhere in the repository are blind spots, none of them here.`,
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
const lines = [`Graph blind spots among these files (${coverage.withinCount}):`];
|
|
172
|
+
for (const fact of coverage.within) {
|
|
173
|
+
const parts = [];
|
|
174
|
+
if (fact.blindSpot)
|
|
175
|
+
parts.push(`not parsed (${fact.blindSpot})`);
|
|
176
|
+
if (fact.unanalyzed)
|
|
177
|
+
parts.push("no call analysis for this language");
|
|
178
|
+
if (fact.unresolvedImports.count > 0) {
|
|
179
|
+
parts.push(`${fact.unresolvedImports.count} unresolved import${fact.unresolvedImports.count === 1 ? "" : "s"}` +
|
|
180
|
+
` (${fact.unresolvedImports.sample.join(", ")})`);
|
|
181
|
+
}
|
|
182
|
+
if (fact.unboundCalls.count > 0) {
|
|
183
|
+
parts.push(`${fact.unboundCalls.count} unbound call${fact.unboundCalls.count === 1 ? "" : "s"}` +
|
|
184
|
+
` (${fact.unboundCalls.sample.join(", ")})`);
|
|
185
|
+
}
|
|
186
|
+
lines.push(` ${fact.path}: ${parts.join("; ")}`);
|
|
187
|
+
}
|
|
188
|
+
lines.push(" Any of these could hide a dependent the radius does not name.");
|
|
189
|
+
return lines;
|
|
190
|
+
}
|
|
191
|
+
export function summarizeCoverage(facts) {
|
|
192
|
+
const summary = {
|
|
193
|
+
files: 0,
|
|
194
|
+
clean: 0,
|
|
195
|
+
withUnresolvedImports: 0,
|
|
196
|
+
withUnboundCalls: 0,
|
|
197
|
+
blindSpots: 0,
|
|
198
|
+
unanalyzed: 0,
|
|
199
|
+
totalUnresolvedImports: 0,
|
|
200
|
+
totalUnboundCalls: 0,
|
|
201
|
+
};
|
|
202
|
+
for (const fact of facts.values()) {
|
|
203
|
+
summary.files += 1;
|
|
204
|
+
if (fact.clean)
|
|
205
|
+
summary.clean += 1;
|
|
206
|
+
if (fact.unresolvedImports.count > 0)
|
|
207
|
+
summary.withUnresolvedImports += 1;
|
|
208
|
+
if (fact.unboundCalls.count > 0)
|
|
209
|
+
summary.withUnboundCalls += 1;
|
|
210
|
+
if (fact.blindSpot)
|
|
211
|
+
summary.blindSpots += 1;
|
|
212
|
+
if (fact.unanalyzed)
|
|
213
|
+
summary.unanalyzed += 1;
|
|
214
|
+
summary.totalUnresolvedImports += fact.unresolvedImports.count;
|
|
215
|
+
summary.totalUnboundCalls += fact.unboundCalls.count;
|
|
216
|
+
}
|
|
217
|
+
return summary;
|
|
218
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { byCodeUnit } from "./order.js";
|
|
2
|
+
/** Format ranked retrieval into the one response an agent needs to act. */
|
|
3
|
+
export function answerRetrieval(query, candidates, options = {}) {
|
|
4
|
+
const limit = options.limit ?? 10;
|
|
5
|
+
const offset = Math.max(0, Math.floor(options.offset ?? 0));
|
|
6
|
+
const threshold = options.materialityThreshold ?? 3;
|
|
7
|
+
const excluded = options.excludePaths ?? new Set();
|
|
8
|
+
const leased = options.leasedPaths ?? new Set();
|
|
9
|
+
const unexcluded = [...candidates].filter((candidate) => !excluded.has(candidate.page.target_path));
|
|
10
|
+
// A module page's target_path is a directory, so serving one answers "where
|
|
11
|
+
// do I edit?" with a folder. It cost 15% of slots on the retrieval benchmark
|
|
12
|
+
// — slots that can never contain the file the agent has to open. Directories
|
|
13
|
+
// still rank (their member list is real evidence), but they queue behind
|
|
14
|
+
// every file and only appear once files have run out.
|
|
15
|
+
const byRank = (a, b) => Number(Boolean(b.verified)) - Number(Boolean(a.verified)) || b.score - a.score || byCodeUnit(a.page.page_id, b.page.page_id);
|
|
16
|
+
// A chunk is an indexing artefact, never an answer: it names a span of a file
|
|
17
|
+
// an agent would open whole. Collapsing happens before fusion, so one
|
|
18
|
+
// arriving here means that collapse was bypassed.
|
|
19
|
+
const deliverable = unexcluded.filter((candidate) => candidate.page.page_type !== "chunk");
|
|
20
|
+
const files = deliverable.filter((candidate) => candidate.page.page_type !== "module").sort(byRank);
|
|
21
|
+
const modules = deliverable.filter((candidate) => candidate.page.page_type === "module").sort(byRank);
|
|
22
|
+
const ranked = [...files, ...modules];
|
|
23
|
+
const results = ranked
|
|
24
|
+
.slice(offset, offset + limit)
|
|
25
|
+
.map((candidate) => {
|
|
26
|
+
const isLeased = leased.has(candidate.page.target_path);
|
|
27
|
+
const bounds = candidate.page.span;
|
|
28
|
+
return {
|
|
29
|
+
...candidate,
|
|
30
|
+
leased: isLeased,
|
|
31
|
+
...(isLeased ? { warning: "active lease: do not start here" } : {}),
|
|
32
|
+
// One span per file, deliberately. Several spans from one file would
|
|
33
|
+
// make a limit of ten return fewer than ten files, which silently
|
|
34
|
+
// redefines the recall the whole benchmark is graded on.
|
|
35
|
+
...(bounds && candidate.page.body
|
|
36
|
+
? {
|
|
37
|
+
span: {
|
|
38
|
+
file_path: candidate.page.target_path,
|
|
39
|
+
start_line: bounds.start_line,
|
|
40
|
+
end_line: bounds.end_line,
|
|
41
|
+
skeleton_context: candidate.page.summary,
|
|
42
|
+
span_content: candidate.page.body,
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
: {}),
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
const nextOffset = offset + results.length;
|
|
49
|
+
const hasMore = ranked.length > nextOffset;
|
|
50
|
+
return {
|
|
51
|
+
query,
|
|
52
|
+
fired: ranked.length > threshold,
|
|
53
|
+
results,
|
|
54
|
+
files_served: results.length,
|
|
55
|
+
reasons: results.map((item) => item.reason ?? (item.verified ? "compiler-verified candidate" : "retrieval-ranked candidate")),
|
|
56
|
+
iteration: {
|
|
57
|
+
offset,
|
|
58
|
+
limit,
|
|
59
|
+
excluded_candidates: candidates.length - ranked.length,
|
|
60
|
+
returned_paths: results.map((item) => item.page.target_path),
|
|
61
|
+
next_offset: hasMore ? nextOffset : null,
|
|
62
|
+
has_more: hasMore,
|
|
63
|
+
continue_reason: hasMore ? "more_candidates" : results.length ? "no_more_candidates" : "no_unexcluded_candidates",
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|