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,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
|
package/dist/lib/ids.js
ADDED
|
@@ -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
|
+
}
|