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.
Files changed (52) hide show
  1. package/README.md +86 -0
  2. package/dist/lib/agent-state.js +119 -0
  3. package/dist/lib/blast.js +462 -0
  4. package/dist/lib/client-config.js +81 -0
  5. package/dist/lib/env-compat.js +66 -0
  6. package/dist/lib/globs.js +0 -0
  7. package/dist/lib/ids.js +24 -0
  8. package/dist/lib/index/aliases.js +244 -0
  9. package/dist/lib/index/call-sites.js +178 -0
  10. package/dist/lib/index/checker-resolver.js +257 -0
  11. package/dist/lib/index/context-card.js +140 -0
  12. package/dist/lib/index/coverage.js +218 -0
  13. package/dist/lib/index/delivery.js +66 -0
  14. package/dist/lib/index/discovery.js +90 -0
  15. package/dist/lib/index/embedding.js +110 -0
  16. package/dist/lib/index/file-index.js +222 -0
  17. package/dist/lib/index/fingerprint.js +0 -0
  18. package/dist/lib/index/git-history.js +136 -0
  19. package/dist/lib/index/graph.js +234 -0
  20. package/dist/lib/index/impact.js +174 -0
  21. package/dist/lib/index/incremental.js +332 -0
  22. package/dist/lib/index/lexical.js +462 -0
  23. package/dist/lib/index/order.js +43 -0
  24. package/dist/lib/index/pages.js +357 -0
  25. package/dist/lib/index/persistence.js +233 -0
  26. package/dist/lib/index/pipeline.js +527 -0
  27. package/dist/lib/index/registry.js +106 -0
  28. package/dist/lib/index/resolve.js +280 -0
  29. package/dist/lib/index/semantic.js +381 -0
  30. package/dist/lib/index/surfaces.js +27 -0
  31. package/dist/lib/index/symbols.js +426 -0
  32. package/dist/lib/index/transformers-embedder.js +73 -0
  33. package/dist/lib/index/typescript-parser.js +532 -0
  34. package/dist/lib/index/vector-cache.js +176 -0
  35. package/dist/lib/index/verification.js +58 -0
  36. package/dist/lib/mcp-compaction.js +241 -0
  37. package/dist/lib/model-roles.js +206 -0
  38. package/dist/lib/path-warnings.js +90 -0
  39. package/dist/lib/protocol.js +95 -0
  40. package/dist/lib/types.js +69 -0
  41. package/dist/lib/version.js +21 -0
  42. package/dist/lib/worktree.js +211 -0
  43. package/dist/mcp/approval.js +0 -0
  44. package/dist/mcp/cloud-connector.js +99 -0
  45. package/dist/mcp/daemon-client.js +156 -0
  46. package/dist/mcp/daemon-protocol.js +100 -0
  47. package/dist/mcp/escalation-waiter.js +183 -0
  48. package/dist/mcp/graph-ops.js +169 -0
  49. package/dist/mcp/index.js +1151 -0
  50. package/dist/mcp/login.js +169 -0
  51. package/dist/mcp/setup.js +90 -0
  52. 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
+ }