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,81 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ function configPath(root) {
4
+ return join(root, ".keel", "config.json");
5
+ }
6
+ function validate(value, path) {
7
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
8
+ throw new Error(`invalid Keel client config at ${path}: expected an object`);
9
+ }
10
+ const candidate = value;
11
+ if (candidate.version !== 1) {
12
+ throw new Error(`invalid Keel client config at ${path}: unsupported version`);
13
+ }
14
+ for (const key of ["server", "project", "token", "vendor"]) {
15
+ if (typeof candidate[key] !== "string" || !candidate[key].trim()) {
16
+ throw new Error(`invalid Keel client config at ${path}: ${key} is required`);
17
+ }
18
+ }
19
+ let server;
20
+ try {
21
+ server = new URL(candidate.server);
22
+ }
23
+ catch {
24
+ throw new Error(`invalid Keel client config at ${path}: server must be an absolute URL`);
25
+ }
26
+ if (!["http:", "https:"].includes(server.protocol) || server.username || server.password) {
27
+ throw new Error(`invalid Keel client config at ${path}: server must be an HTTP(S) URL without credentials`);
28
+ }
29
+ if (server.search || server.hash) {
30
+ throw new Error(`invalid Keel client config at ${path}: server must not contain a query or fragment`);
31
+ }
32
+ if (!/^ch_[A-Za-z0-9_-]+$/.test(candidate.token)) {
33
+ throw new Error(`invalid Keel client config at ${path}: token has an unexpected format`);
34
+ }
35
+ return {
36
+ version: 1,
37
+ server: server.toString().replace(/\/+$/, ""),
38
+ project: candidate.project.trim(),
39
+ token: candidate.token.trim(),
40
+ vendor: candidate.vendor.trim(),
41
+ };
42
+ }
43
+ /** Load repo-local adapter credentials, or null before `keel login`. */
44
+ export function loadClientConfig(root) {
45
+ const path = configPath(root);
46
+ if (!existsSync(path))
47
+ return null;
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(readFileSync(path, "utf8"));
51
+ }
52
+ catch {
53
+ throw new Error(`invalid Keel client config at ${path}: file is not valid JSON`);
54
+ }
55
+ return validate(parsed, path);
56
+ }
57
+ /** Atomically save adapter credentials with owner-only permissions. */
58
+ export function saveClientConfig(root, config) {
59
+ const path = configPath(root);
60
+ const validated = validate(config, path);
61
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
62
+ chmodSync(dirname(path), 0o700);
63
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
64
+ try {
65
+ writeFileSync(temporary, `${JSON.stringify(validated, null, 2)}\n`, {
66
+ encoding: "utf8",
67
+ flag: "wx",
68
+ mode: 0o600,
69
+ });
70
+ renameSync(temporary, path);
71
+ // A pre-existing destination may have had weaker permissions. rename(2)
72
+ // replaces it with the mode-0600 temp file; chmod is belt and braces for
73
+ // platforms whose rename semantics differ.
74
+ chmodSync(path, 0o600);
75
+ }
76
+ finally {
77
+ if (existsSync(temporary))
78
+ unlinkSync(temporary);
79
+ }
80
+ return path;
81
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * KEEL_* ⇄ CHARTER_* environment aliasing.
3
+ *
4
+ * The service is renamed Charter → Keel. Twenty-six environment variables
5
+ * carry the old prefix, and they are read from ~30 files, three agent hook
6
+ * configs, two MCP configs, a CI workflow, and a systemd unit on a host this
7
+ * repo does not control. Renaming all of that in one commit means every one
8
+ * of those has to land simultaneously, and anything that restarts in between
9
+ * comes up misconfigured.
10
+ *
11
+ * That is not a hypothetical here. `src/mcp/index.ts` was switched to KEEL_*
12
+ * before the configs that feed it were, so the adapter would have thrown
13
+ * "KEEL_PROJECT env var is required" on the first tool call after any agent
14
+ * restarted — while `dist/` still held a pre-rename build, so the breakage was
15
+ * invisible until the next `npm run build`. A test run would have armed it.
16
+ *
17
+ * So instead: alias both directions, once, at process start. Every variable
18
+ * answers to both names, the running fleet keeps working on CHARTER_*, new
19
+ * code can read KEEL_*, and the configs migrate one at a time instead of all
20
+ * at once. This module is the reason the rename does not need a window.
21
+ *
22
+ * Remove it only when nothing sets CHARTER_* anywhere — including the VPS
23
+ * systemd unit and every developer's shell profile, which is the part nobody
24
+ * remembers.
25
+ */
26
+ const PREFIXES = ["SYNCSTAFF_", "NAIB_", "KEEL_", "CHARTER_"];
27
+ /**
28
+ * Mirror SYNCSTAFF_x ⇄ NAIB_x ⇄ KEEL_x ⇄ CHARTER_x across all naming eras.
29
+ *
30
+ * An explicitly set value always wins: if multiple names are present with
31
+ * different values, neither is overwritten.
32
+ *
33
+ * Idempotent, so entry points may call it more than once.
34
+ */
35
+ export function aliasEnv(env = process.env) {
36
+ for (const [key, value] of Object.entries(env)) {
37
+ if (value === undefined)
38
+ continue;
39
+ for (const sourcePrefix of PREFIXES) {
40
+ if (key.startsWith(sourcePrefix)) {
41
+ const suffix = key.slice(sourcePrefix.length);
42
+ for (const targetPrefix of PREFIXES) {
43
+ if (targetPrefix !== sourcePrefix) {
44
+ const renamed = targetPrefix + suffix;
45
+ if (env[renamed] === undefined)
46
+ env[renamed] = value;
47
+ }
48
+ }
49
+ break;
50
+ }
51
+ }
52
+ }
53
+ }
54
+ /**
55
+ * Read a variable by its suffix under NAIB_, KEEL_, or CHARTER_ prefix (newest first).
56
+ */
57
+ export function naibEnv(suffix) {
58
+ return process.env["SYNCSTAFF_" + suffix] ?? process.env["NAIB_" + suffix] ?? process.env["KEEL_" + suffix] ?? process.env["CHARTER_" + suffix];
59
+ }
60
+ export const keelEnv = naibEnv;
61
+ // Aliasing on import is deliberate. Several modules read process.env at module
62
+ // scope (`const SERVER = process.env.KEEL_URL ?? …`), which runs before any
63
+ // entry point's first statement. A function an entry point must remember to
64
+ // call would therefore be called too late, and the failure would be a silent
65
+ // fallback to a default rather than an error.
66
+ aliasEnv();
Binary file
@@ -0,0 +1,24 @@
1
+ import { randomBytes } from "node:crypto";
2
+ const B32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford base32
3
+ function encodeTime(ms) {
4
+ let out = "";
5
+ for (let i = 9; i >= 0; i--) {
6
+ out = B32[ms % 32] + out;
7
+ ms = Math.floor(ms / 32);
8
+ }
9
+ return out;
10
+ }
11
+ function encodeRandom(len) {
12
+ const bytes = randomBytes(len);
13
+ let out = "";
14
+ for (let i = 0; i < len; i++)
15
+ out += B32[bytes[i] % 32];
16
+ return out;
17
+ }
18
+ export function ulid(prefix) {
19
+ const id = encodeTime(Date.now()) + encodeRandom(16);
20
+ return prefix ? `${prefix}_${id}` : id;
21
+ }
22
+ export function now() {
23
+ return new Date().toISOString();
24
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Path aliases: the mappings that make a bare specifier internal.
3
+ *
4
+ * `import { BoardItemEditor } from "@/components/board/BoardItemEditor"` is not
5
+ * a package. It is a local file, renamed by a `paths` entry in tsconfig.json.
6
+ * A resolver that does not read that config classifies it as external, and
7
+ * every internal edge in the application disappears.
8
+ *
9
+ * That was true of this repository until now. web/tsconfig.json declares
10
+ * `"@/*": ["./src/*"]`, six files import through it, and the graph saw none of
11
+ * those edges. The real-repo tests passed only because they were scoped to
12
+ * keel/src, which uses relative imports throughout — the blind spot was hiding
13
+ * behind the choice of test corpus, which is the most comfortable place for a
14
+ * blind spot to hide.
15
+ *
16
+ * Three decisions here are deliberate, and each is a place where the obvious
17
+ * implementation is worse:
18
+ *
19
+ * SCOPE. A monorepo has several tsconfigs. web/tsconfig.json's aliases apply
20
+ * to web/, not to keel/. Applying every alias everywhere would invent edges
21
+ * across package boundaries — the same class of error as matching a name in an
22
+ * unrelated language. Each mapping records the directory it governs, and the
23
+ * nearest governing config wins.
24
+ *
25
+ * AMBIGUITY IS NOT A CHOICE. A `paths` entry may list several targets, and
26
+ * TypeScript tries them in order. Picking the first that exists would usually
27
+ * be right and would occasionally invent an edge to a file the compiler would
28
+ * never have chosen. When more than one candidate exists on disk, this reports
29
+ * the specifier as ambiguous and names both, rather than guessing.
30
+ *
31
+ * THE CONFIG IS PART OF THE INDEX IDENTITY. Two checkouts with identical
32
+ * source but different `paths` produce different graphs. If the fingerprint
33
+ * ignored the alias map, those two would claim to be the same index and a
34
+ * blast radius computed on one would be quietly wrong on the other. So
35
+ * `aliasSignature()` folds into the fingerprint alongside the parser version,
36
+ * for the same reason.
37
+ */
38
+ /** POSIX join that tolerates empty segments and normalises "." and "..". */
39
+ function joinPath(...parts) {
40
+ const segments = [];
41
+ for (const part of parts.join("/").split("/")) {
42
+ if (!part || part === ".")
43
+ continue;
44
+ if (part === "..") {
45
+ if (segments.length > 0)
46
+ segments.pop();
47
+ continue;
48
+ }
49
+ segments.push(part);
50
+ }
51
+ return segments.join("/");
52
+ }
53
+ const dirOf = (path) => {
54
+ const slash = path.lastIndexOf("/");
55
+ return slash === -1 ? "" : path.slice(0, slash);
56
+ };
57
+ /**
58
+ * JSON with comments and trailing commas — tsconfig.json is JSONC, and
59
+ * JSON.parse rejects both. Deliberately small: this strips line and block
60
+ * comments outside strings and removes trailing commas. A config exotic enough
61
+ * to defeat it yields no aliases, which degrades to today's behaviour rather
62
+ * than to a wrong one.
63
+ */
64
+ function parseJsonc(text) {
65
+ let out = "";
66
+ let inString = false;
67
+ let quote = "";
68
+ for (let i = 0; i < text.length; i += 1) {
69
+ const char = text[i];
70
+ const next = text[i + 1];
71
+ if (inString) {
72
+ out += char;
73
+ if (char === "\\") {
74
+ out += next ?? "";
75
+ i += 1;
76
+ }
77
+ else if (char === quote) {
78
+ inString = false;
79
+ }
80
+ continue;
81
+ }
82
+ if (char === '"' || char === "'") {
83
+ inString = true;
84
+ quote = char;
85
+ out += char;
86
+ continue;
87
+ }
88
+ if (char === "/" && next === "/") {
89
+ while (i < text.length && text[i] !== "\n")
90
+ i += 1;
91
+ out += "\n";
92
+ continue;
93
+ }
94
+ if (char === "/" && next === "*") {
95
+ i += 2;
96
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
97
+ i += 1;
98
+ i += 1;
99
+ continue;
100
+ }
101
+ out += char;
102
+ }
103
+ out = out.replace(/,(\s*[}\]])/g, "$1");
104
+ try {
105
+ return JSON.parse(out);
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
111
+ /**
112
+ * Collect every alias rule declared by config files in the indexed set.
113
+ *
114
+ * Only files already discovered are read — the index never reaches outside
115
+ * what it was given, which keeps this consistent with the parser's rule about
116
+ * ambient authority.
117
+ */
118
+ export function discoverAliases(files, options) {
119
+ const rules = [];
120
+ const configs = files.filter((path) => path.endsWith("tsconfig.json") || path.endsWith("jsconfig.json") || path.endsWith("package.json"));
121
+ for (const configPath of [...configs].sort()) {
122
+ const scope = dirOf(configPath);
123
+ let parsed;
124
+ try {
125
+ parsed = parseJsonc(options.readFile(configPath));
126
+ }
127
+ catch {
128
+ continue; // unreadable config: no aliases, not a failure
129
+ }
130
+ if (!parsed || typeof parsed !== "object")
131
+ continue;
132
+ if (configPath.endsWith("package.json")) {
133
+ rules.push(...packageImports(parsed, scope, configPath));
134
+ continue;
135
+ }
136
+ rules.push(...tsconfigPaths(parsed, scope, configPath, files, options, 0));
137
+ }
138
+ // Longest prefix first so "@/components/" beats "@/", and the more specific
139
+ // mapping is the one that applies — matching how TypeScript itself picks.
140
+ return rules.sort((a, b) => b.prefix.length - a.prefix.length || (a.scope < b.scope ? -1 : a.scope > b.scope ? 1 : 0));
141
+ }
142
+ function tsconfigPaths(config, scope, source, files, options, depth) {
143
+ const rules = [];
144
+ const compilerOptions = (config.compilerOptions ?? {});
145
+ // `extends` first, so a local `paths` can override an inherited one by
146
+ // being appended later and winning the longest-prefix sort on equal length.
147
+ const extendsRef = config.extends;
148
+ if (typeof extendsRef === "string" && depth < (options.maxExtends ?? 5)) {
149
+ const target = extendsRef.startsWith(".")
150
+ ? joinPath(scope, extendsRef.endsWith(".json") ? extendsRef : `${extendsRef}.json`)
151
+ : null;
152
+ if (target && files.includes(target)) {
153
+ try {
154
+ const parent = parseJsonc(options.readFile(target));
155
+ if (parent && typeof parent === "object") {
156
+ rules.push(...tsconfigPaths(parent, dirOf(target), target, files, options, depth + 1));
157
+ }
158
+ }
159
+ catch {
160
+ /* an unreadable parent contributes nothing */
161
+ }
162
+ }
163
+ }
164
+ const paths = compilerOptions.paths;
165
+ if (!paths || typeof paths !== "object")
166
+ return rules;
167
+ // baseUrl is relative to the config that declares it; targets are relative
168
+ // to baseUrl. Omitting baseUrl means targets are relative to the config.
169
+ const baseUrl = typeof compilerOptions.baseUrl === "string" ? compilerOptions.baseUrl : ".";
170
+ const base = joinPath(scope, baseUrl);
171
+ for (const [pattern, rawTargets] of Object.entries(paths)) {
172
+ if (!Array.isArray(rawTargets))
173
+ continue;
174
+ const wildcard = pattern.endsWith("*");
175
+ const prefix = wildcard ? pattern.slice(0, -1) : pattern;
176
+ const targets = rawTargets
177
+ .filter((t) => typeof t === "string")
178
+ .map((t) => joinPath(base, wildcard ? t.replace(/\*$/, "") : t));
179
+ if (targets.length === 0)
180
+ continue;
181
+ rules.push({ scope, prefix, wildcard, targets, source });
182
+ }
183
+ return rules;
184
+ }
185
+ /**
186
+ * Node's own alias mechanism: package.json "imports", where keys begin with
187
+ * "#". Supported because it is the standard-track answer to the same problem
188
+ * and costs a dozen lines once the tsconfig machinery exists.
189
+ */
190
+ function packageImports(config, scope, source) {
191
+ const imports = config.imports;
192
+ if (!imports || typeof imports !== "object")
193
+ return [];
194
+ const rules = [];
195
+ for (const [pattern, rawTarget] of Object.entries(imports)) {
196
+ if (!pattern.startsWith("#"))
197
+ continue;
198
+ // A conditional object ({"node": "./a.js", "default": "./b.js"}) has no
199
+ // single answer without knowing the runtime condition. Take every leaf as
200
+ // a candidate; if more than one exists on disk the resolver calls it
201
+ // ambiguous, which is the truthful outcome.
202
+ const targets = collectStringLeaves(rawTarget).map((t) => joinPath(scope, t.replace(/\*$/, "")));
203
+ if (targets.length === 0)
204
+ continue;
205
+ const wildcard = pattern.endsWith("*");
206
+ rules.push({
207
+ scope,
208
+ prefix: wildcard ? pattern.slice(0, -1) : pattern,
209
+ wildcard,
210
+ targets,
211
+ source,
212
+ });
213
+ }
214
+ return rules;
215
+ }
216
+ function collectStringLeaves(value, depth = 0) {
217
+ if (typeof value === "string")
218
+ return [value];
219
+ if (depth > 4 || !value || typeof value !== "object")
220
+ return [];
221
+ return Object.values(value).flatMap((v) => collectStringLeaves(v, depth + 1));
222
+ }
223
+ /** Does this rule govern the file doing the importing? */
224
+ export function ruleGoverns(rule, fromPath) {
225
+ if (rule.scope === "")
226
+ return true;
227
+ return fromPath === rule.scope || fromPath.startsWith(`${rule.scope}/`);
228
+ }
229
+ /**
230
+ * The alias configuration's contribution to the index fingerprint.
231
+ *
232
+ * Same argument as the parser version. Identical source with a different
233
+ * `paths` block produces a different graph, so the two indexes must not claim
234
+ * to be the same one. Sorted, because the order rules were discovered in is a
235
+ * property of the walk rather than of the repository.
236
+ */
237
+ export function aliasSignature(aliases) {
238
+ if (aliases.length === 0)
239
+ return "none";
240
+ return aliases
241
+ .map((rule) => `${rule.scope}|${rule.prefix}${rule.wildcard ? "*" : ""}=>${rule.targets.join(",")}`)
242
+ .sort()
243
+ .join(";");
244
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Call-site resolution for blast radius, backed by the local graph.
3
+ *
4
+ * This is the bridge between the graph subsystem and the coordination
5
+ * protocol, and it exists to retire one specific line of code:
6
+ *
7
+ * const callRe = new RegExp(`\\b${escapeRe(lastSeg)}\\s*\\(`);
8
+ *
9
+ * That is `grep-v1`, in lib/blast.ts. It finds a symbol's final dotted
10
+ * segment followed by an open paren, anywhere in any TS/JS/Python file,
11
+ * skipping only lines that look like declarations. It has no idea what a
12
+ * comment is.
13
+ *
14
+ * On 13 August it was asked for the call sites of a TypeScript method named
15
+ * `check` and returned 38, sixteen of them in an unrelated Python package.
16
+ * The worst was:
17
+ *
18
+ * # side of the ORPHAN check (but NOT FTS, which only indexes
19
+ *
20
+ * A word, a space, an open paren, inside a comment. The count tripped a width
21
+ * threshold, the intent escalated, and an agent stopped for a human decision
22
+ * that existed only because a regex cannot tell prose from code. There is a
23
+ * test in local-call-sites.test.ts holding that exact line.
24
+ *
25
+ * What replaces it resolves a call only when the name is bound in the calling
26
+ * file — defined there or imported there — and the target actually exports it.
27
+ * A comment cannot satisfy that, and neither can a same-named function in a
28
+ * package nobody imports.
29
+ *
30
+ * Two rules about honesty, because a quieter analyzer that is quietly wrong
31
+ * would be worse than the loud one it replaces:
32
+ *
33
+ * The analyzer is always named in the report. A consumer must never have to
34
+ * guess whether a result came from the graph or from grep.
35
+ *
36
+ * Where the graph cannot see — a language with no backend, a repository
37
+ * where typescript is not installed — this returns null and the caller falls
38
+ * back to grep rather than reporting an empty, confident answer. Silence
39
+ * from a blind analyzer is the one output worse than noise.
40
+ */
41
+ import { buildFileIndex } from "./file-index.js";
42
+ import { buildDependencyGraph } from "./graph.js";
43
+ import { buildSymbolTable, resolveCallEdges, resolveReferenceEdges, } from "./symbols.js";
44
+ /**
45
+ * Build a call-site index over a checkout, or return null if the graph cannot
46
+ * see it.
47
+ *
48
+ * Null is a real answer and the caller must respect it: no parser backend
49
+ * means no graph, and pretending otherwise would report "no call sites" for a
50
+ * repository nobody analysed.
51
+ *
52
+ * The index is built once and queried many times, because
53
+ * attachClientCallSiteReports asks about several symbols per intent and
54
+ * rebuilding per symbol would make the graph slower than the regex it
55
+ * replaces.
56
+ */
57
+ export function buildGraphCallSiteIndex(repoPath, loadParsers, options = {}) {
58
+ const registry = loadParsers();
59
+ if (!registry)
60
+ return null;
61
+ let index;
62
+ let calls;
63
+ let references;
64
+ try {
65
+ index = buildFileIndex(repoPath, registry, { exclude: options.exclude });
66
+ const graph = buildDependencyGraph(index);
67
+ void graph; // built for its side of the pipeline; edges come from calls below
68
+ const table = buildSymbolTable(index);
69
+ calls = resolveCallEdges(index, table);
70
+ // One table, one resolver, two views of it. Building references from a
71
+ // second symbol table would let the two surfaces drift apart, which is the
72
+ // defect R12.0 names rather than a detail of how they are wired.
73
+ references = resolveReferenceEdges(index, table);
74
+ }
75
+ catch {
76
+ // A graph that failed to build is not a graph that found nothing.
77
+ return null;
78
+ }
79
+ // Nothing was parsed at all — a repository of Markdown, or a backend that
80
+ // matched no extension. Same reasoning as null above.
81
+ if (index.coverage.parsed + index.coverage.partial === 0)
82
+ return null;
83
+ /**
84
+ * Which files EXPORT each name.
85
+ *
86
+ * Both maps below are keyed by name alone, because a caller asking for a
87
+ * blast radius has a name and nothing else. That is the right key for the
88
+ * question and the wrong key for the answer: two unrelated symbols may share
89
+ * a spelling, and the resolver — correctly — binds each reference to
90
+ * whichever one is in scope where it appears. Keying the *result* by name
91
+ * then pools them back together, undoing the resolution that just happened.
92
+ *
93
+ * What that looked like here: `now`, exported by lib/ids.ts, collected the
94
+ * two reads of a `const now` local to lib/slack.ts; `keelEnv`, exported by
95
+ * lib/env-compat.ts, collected five calls to a same-named local in a script
96
+ * that does not import it. Both were labelled "confirmed" — the label that
97
+ * tells a human these are the trustworthy ones. Across this repository 268
98
+ * sites were wrong this way, and they are what has kept the real-code
99
+ * precision test red since 17 August.
100
+ *
101
+ * The rule that removes them: a name declared in an intent is an EXPORTED
102
+ * name — `interfaces_touched` is a list of exports — so a reference the
103
+ * resolver bound to a file-local definition belongs to a different symbol
104
+ * that merely reads the same. When some file exports the name, only
105
+ * references resolving to one of those files are part of its radius.
106
+ *
107
+ * When nothing exports the name, every binding is kept: that is a purely
108
+ * local symbol, and a caller asking about one wants exactly those sites.
109
+ */
110
+ const exportersOf = new Map();
111
+ for (const file of index.files) {
112
+ for (const exported of file.result.exports) {
113
+ const owners = exportersOf.get(exported.name);
114
+ if (owners)
115
+ owners.add(file.path);
116
+ else
117
+ exportersOf.set(exported.name, new Set([file.path]));
118
+ }
119
+ }
120
+ /** Does this edge bind to the name's exported symbol, or to a namesake? */
121
+ const bindsToTheExportedSymbol = (symbol, to) => {
122
+ const owners = exportersOf.get(symbol);
123
+ return owners === undefined || owners.size === 0 || owners.has(to);
124
+ };
125
+ const bySymbol = new Map();
126
+ for (const edge of calls.edges) {
127
+ if (!bindsToTheExportedSymbol(edge.symbol, edge.to))
128
+ continue;
129
+ const list = bySymbol.get(edge.symbol);
130
+ const site = {
131
+ path: edge.from,
132
+ line: edge.line ?? 0,
133
+ symbol: edge.symbol,
134
+ // The calling function, not a line of source. A blast radius consumer
135
+ // needs to know WHERE in the file; it does not need the code, and
136
+ // shipping the code is what the privacy boundary exists to prevent.
137
+ context: edge.enclosing ? `${edge.enclosing}()` : "",
138
+ };
139
+ if (list)
140
+ list.push(site);
141
+ else
142
+ bySymbol.set(edge.symbol, [site]);
143
+ }
144
+ const referencesBySymbol = new Map();
145
+ for (const edge of references.edges) {
146
+ if (!bindsToTheExportedSymbol(edge.symbol, edge.to))
147
+ continue;
148
+ const site = {
149
+ path: edge.from,
150
+ line: edge.line ?? 0,
151
+ symbol: edge.symbol,
152
+ kind: edge.kind,
153
+ context: edge.enclosing ? `${edge.enclosing}()` : "",
154
+ };
155
+ const list = referencesBySymbol.get(edge.symbol);
156
+ if (list)
157
+ list.push(site);
158
+ else
159
+ referencesBySymbol.set(edge.symbol, [site]);
160
+ }
161
+ return {
162
+ sitesFor(symbol) {
163
+ // A declared interface may be written "Class.method"; the graph keys on
164
+ // the member name, which is the last segment.
165
+ const last = symbol.split(".").pop() ?? symbol;
166
+ return bySymbol.get(last) ?? [];
167
+ },
168
+ referencesFor(symbol) {
169
+ const last = symbol.split(".").pop() ?? symbol;
170
+ return referencesBySymbol.get(last) ?? [];
171
+ },
172
+ coverage: index.coverage,
173
+ blindSpots: index.blindSpots.length,
174
+ unresolvedCalls: calls.unresolved.length,
175
+ unresolvedReferences: references.unresolved.length,
176
+ analyzer: `keel-graph-v1(${registry.signature()})`,
177
+ };
178
+ }