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,234 @@
1
+ import { resolvePythonModule, resolveSpecifier } from "./resolve.js";
2
+ import { byCodeUnit } from "./order.js";
3
+ /**
4
+ * Build the graph. Pure: same index in, same graph out, byte for byte.
5
+ *
6
+ * Determinism is not tidiness here. The graph is summarised into a fingerprint
7
+ * that two machines compare to decide whether they are looking at the same
8
+ * repository; if iteration order could change the output, that comparison
9
+ * would fail for reasons that have nothing to do with the code.
10
+ */
11
+ export function buildDependencyGraph(index) {
12
+ const known = new Set(index.files.map((file) => file.path));
13
+ /**
14
+ * Which Python file declares each exported name, where exactly one does.
15
+ *
16
+ * Built because specifier strings are the wrong primitive for a Python graph.
17
+ * `from sqlalchemy import create_engine` names a package, and resolving the
18
+ * string can only reach `sqlalchemy/__init__.py` — so every symbol imported
19
+ * through a facade collapsed onto the same handful of package files. Measured
20
+ * on that graph, expansion promoted the right file 6.8% of the time against
21
+ * 13.0% on TypeScript, and complete misses went up rather than down.
22
+ *
23
+ * The parser recorded the imported *names* alongside the specifier all along;
24
+ * the resolver discarded them. Binding a name to its declaring file turns a
25
+ * facade edge into a leaf edge: `create_engine` resolves to
26
+ * `lib/sqlalchemy/engine/create.py`, not to the package root.
27
+ *
28
+ * Only unambiguous names are indexed. A name defined in several files cannot
29
+ * be bound without knowing which one the importer meant, and guessing would
30
+ * invent an edge — the failure this graph exists to avoid. On sqlalchemy 55.3%
31
+ * of imported symbol references are uniquely defined; on pydantic 23.0%.
32
+ */
33
+ const pythonDeclarations = new Map();
34
+ for (const file of index.files) {
35
+ if (!file.path.endsWith(".py") && !file.path.endsWith(".pyi"))
36
+ continue;
37
+ for (const definition of file.result.definitions ?? []) {
38
+ if (!definition.exported)
39
+ continue;
40
+ const existing = pythonDeclarations.get(definition.name);
41
+ // null marks a name seen in more than one file: ambiguous, never bound.
42
+ pythonDeclarations.set(definition.name, existing === undefined ? file.path : existing === file.path ? existing : null);
43
+ }
44
+ }
45
+ const nodes = [];
46
+ const edges = [];
47
+ const unresolved = [];
48
+ const external = new Set();
49
+ for (const file of index.files) {
50
+ nodes.push({
51
+ path: file.path,
52
+ language: file.language,
53
+ exports: file.result.exports.map((exported) => ({
54
+ name: exported.name,
55
+ kind: exported.kind,
56
+ confidence: exported.confidence,
57
+ })),
58
+ });
59
+ for (const imported of file.result.imports) {
60
+ if (imported.dynamic) {
61
+ // Only a specifier *built* at runtime is unresolvable. `import("./x.js")`
62
+ // is a string literal and is as knowable as a static import — the
63
+ // parser already separates the two, marking a literal `probable` and a
64
+ // computed one `speculative`, and it records `<computed>` for the
65
+ // latter precisely so this can tell them apart.
66
+ //
67
+ // Treating both as unresolved dropped every lazily-loaded edge in the
68
+ // codebase. In this repository that removed 10 of the 27 edges out of
69
+ // `src/mcp/index.ts` — the highest fan-out file there is, and the one
70
+ // whose blast radius most needs to be right.
71
+ const resolution = imported.specifier === "<computed>"
72
+ ? null
73
+ : resolveSpecifier(imported.specifier, file.path, known, index.aliases ?? []);
74
+ if (resolution?.kind === "internal") {
75
+ edges.push({
76
+ from: file.path,
77
+ to: resolution.path,
78
+ names: [...imported.names].sort(),
79
+ confidence: downgrade(imported.confidence, resolution.via),
80
+ via: resolution.via,
81
+ ...(imported.typeOnly ? { typeOnly: true } : {}),
82
+ dynamic: true,
83
+ });
84
+ continue;
85
+ }
86
+ if (resolution?.kind === "external") {
87
+ external.add(resolution.module);
88
+ continue;
89
+ }
90
+ unresolved.push({
91
+ from: file.path,
92
+ specifier: imported.specifier,
93
+ reason: resolution
94
+ ? resolution.reason
95
+ : "specifier is assembled at runtime",
96
+ dynamic: true,
97
+ });
98
+ continue;
99
+ }
100
+ // A Python `from X import a, b` is resolved by its names first. Each name
101
+ // that binds uniquely becomes its own edge to the declaring file, and the
102
+ // module edge is not emitted — pointing at both would put the facade back.
103
+ const isPythonImporter = file.path.endsWith(".py") || file.path.endsWith(".pyi");
104
+ if (isPythonImporter && imported.names.length && !imported.specifier.startsWith(".")) {
105
+ const bound = new Set();
106
+ for (const name of imported.names) {
107
+ const declaredIn = pythonDeclarations.get(name);
108
+ if (declaredIn && declaredIn !== file.path)
109
+ bound.add(declaredIn);
110
+ }
111
+ if (bound.size) {
112
+ for (const target of [...bound].sort(byCodeUnit)) {
113
+ edges.push({
114
+ from: file.path,
115
+ to: target,
116
+ names: [...imported.names].sort(),
117
+ // A unique declaration is strong evidence, but the language
118
+ // permits rebinding at runtime, so this is never "exact".
119
+ confidence: "probable",
120
+ via: "exact",
121
+ ...(imported.typeOnly ? { typeOnly: true } : {}),
122
+ });
123
+ }
124
+ continue;
125
+ }
126
+ // Nothing bound. Try the module itself — `from pkg import submodule` is
127
+ // a real module reference — but without the parent fallback, which is
128
+ // the step that manufactured the facade edges in the first place.
129
+ const asModule = resolvePythonModule(imported.specifier, known, { parentFallback: false });
130
+ if (asModule?.kind === "internal") {
131
+ edges.push({
132
+ from: file.path,
133
+ to: asModule.path,
134
+ names: [...imported.names].sort(),
135
+ confidence: downgrade(imported.confidence, asModule.via),
136
+ via: asModule.via,
137
+ ...(imported.typeOnly ? { typeOnly: true } : {}),
138
+ });
139
+ continue;
140
+ }
141
+ external.add(imported.specifier);
142
+ continue;
143
+ }
144
+ const resolution = resolveSpecifier(imported.specifier, file.path, known, index.aliases ?? []);
145
+ if (resolution.kind === "internal") {
146
+ edges.push({
147
+ from: file.path,
148
+ to: resolution.path,
149
+ names: [...imported.names].sort(),
150
+ // An edge is never more trustworthy than the fact it came from, and
151
+ // a directory-index match is a convention rather than a certainty.
152
+ confidence: downgrade(imported.confidence, resolution.via),
153
+ via: resolution.via,
154
+ ...(imported.typeOnly ? { typeOnly: true } : {}),
155
+ });
156
+ }
157
+ else if (resolution.kind === "external") {
158
+ external.add(resolution.module);
159
+ }
160
+ else {
161
+ unresolved.push({
162
+ from: file.path,
163
+ specifier: imported.specifier,
164
+ reason: resolution.reason,
165
+ dynamic: false,
166
+ });
167
+ }
168
+ }
169
+ }
170
+ const byPath = (a, b) => byCodeUnit(a.path, b.path);
171
+ const byEdge = (a, b) => byCodeUnit(a.from, b.from) || byCodeUnit(a.to, b.to) || byCodeUnit(a.names.join(), b.names.join());
172
+ const byUnresolved = (a, b) => byCodeUnit(a.from, b.from) || byCodeUnit(a.specifier, b.specifier);
173
+ return {
174
+ fingerprint: index.fingerprint,
175
+ nodes: nodes.sort(byPath),
176
+ edges: edges.sort(byEdge),
177
+ unresolved: unresolved.sort(byUnresolved),
178
+ external: [...external].sort(),
179
+ blindSpots: [...index.blindSpots].sort(byPath),
180
+ };
181
+ }
182
+ function downgrade(confidence, via) {
183
+ // An alias is a declared mapping in a config file, not a guess, so it keeps
184
+ // the fact's confidence. A directory-index match is a convention and does not.
185
+ if (via === "exact" || via === "alias")
186
+ return confidence;
187
+ if (confidence === "exact")
188
+ return "probable";
189
+ return confidence;
190
+ }
191
+ /**
192
+ * Who depends on these paths.
193
+ *
194
+ * Transitive by default, because the question an intent actually asks is "what
195
+ * can my change reach", and a caller two hops away is still a caller. Cycles
196
+ * are normal in real code and terminate on the visited set.
197
+ */
198
+ export function dependentsOf(graph, targets) {
199
+ const importers = new Map();
200
+ for (const edge of graph.edges) {
201
+ const list = importers.get(edge.to);
202
+ if (list)
203
+ list.push(edge.from);
204
+ else
205
+ importers.set(edge.to, [edge.from]);
206
+ }
207
+ const targetSet = new Set(targets);
208
+ const direct = new Set();
209
+ const seen = new Set();
210
+ const queue = [...targets];
211
+ while (queue.length > 0) {
212
+ const current = queue.shift();
213
+ for (const importer of importers.get(current) ?? []) {
214
+ if (targetSet.has(current))
215
+ direct.add(importer);
216
+ if (seen.has(importer) || targetSet.has(importer))
217
+ continue;
218
+ seen.add(importer);
219
+ queue.push(importer);
220
+ }
221
+ }
222
+ const caveats = [];
223
+ // An unresolved import is a file that might import the target; we cannot say
224
+ // it does, and we must not imply it does not.
225
+ const unresolvedCount = graph.unresolved.length;
226
+ if (unresolvedCount > 0) {
227
+ caveats.push(`${unresolvedCount} import${unresolvedCount === 1 ? "" : "s"} could not be resolved; ` +
228
+ "any of them may reference these paths");
229
+ }
230
+ if (graph.blindSpots.length > 0) {
231
+ caveats.push(`${graph.blindSpots.length} file${graph.blindSpots.length === 1 ? "" : "s"} could not be fully parsed`);
232
+ }
233
+ return { paths: [...seen].sort(), direct: [...direct].sort(), caveats };
234
+ }
@@ -0,0 +1,174 @@
1
+ import { coverageForRadius } from "./coverage.js";
2
+ import { byCodeUnit } from "./order.js";
3
+ /**
4
+ * Default depth.
5
+ *
6
+ * Transitive reachability in a real codebase converges on "most of the
7
+ * repository" within a few hops, and a radius naming most of the repository is
8
+ * the same useless artefact as a radius naming a random 38 files — it cannot
9
+ * be checked, so it gets ignored. Three hops keeps the answer reviewable while
10
+ * covering the cases that actually break builds: a direct importer, its
11
+ * importer, and the entry point that ties them together. The limit is stated
12
+ * in the caveats whenever it truncates, so a caller is never quietly handed a
13
+ * partial traversal.
14
+ */
15
+ const DEFAULT_MAX_DISTANCE = 3;
16
+ export function computeImpact(graph, calls, declared, options = {}) {
17
+ const maxDistance = options.maxDistance ?? DEFAULT_MAX_DISTANCE;
18
+ const declaredSet = new Set(declared);
19
+ // Reverse adjacency, built once. Import edges and call edges are kept apart
20
+ // because they justify a path differently: importing a changed file is a
21
+ // compile-time relationship, calling a changed symbol is a behavioural one,
22
+ // and a reviewer reads them differently.
23
+ const importersOf = new Map();
24
+ for (const edge of graph.edges) {
25
+ push(importersOf, edge.to, { from: edge.from, confidence: edge.confidence });
26
+ }
27
+ const callersOf = new Map();
28
+ for (const edge of calls.edges) {
29
+ if (edge.from === edge.to)
30
+ continue; // a file calling itself is not impact
31
+ push(callersOf, edge.to, {
32
+ from: edge.from,
33
+ symbol: edge.symbol,
34
+ ...(edge.enclosing ? { enclosing: edge.enclosing } : {}),
35
+ confidence: edge.confidence,
36
+ line: edge.line,
37
+ });
38
+ }
39
+ const found = new Map();
40
+ for (const path of declared) {
41
+ found.set(path, {
42
+ path,
43
+ declared: true,
44
+ distance: 0,
45
+ confidence: "exact",
46
+ reasons: [{ kind: "declared", explanation: "declared by the agent" }],
47
+ });
48
+ }
49
+ let frontier = [...declaredSet];
50
+ let truncated = false;
51
+ for (let distance = 1; distance <= maxDistance && frontier.length > 0; distance += 1) {
52
+ const next = [];
53
+ for (const target of frontier.sort()) {
54
+ for (const caller of (callersOf.get(target) ?? []).sort(byFrom)) {
55
+ // Name the calling function when the backend knew it. "handler() calls
56
+ // query() at line 41" can be checked without opening the file; "calls
57
+ // query()" cannot.
58
+ const site = caller.enclosing ? `${caller.enclosing}() ` : "";
59
+ const at = caller.line === undefined ? "" : ` at line ${caller.line}`;
60
+ const reason = {
61
+ kind: distance === 1 ? "calls" : "transitive",
62
+ explanation: distance === 1
63
+ ? `${site}calls ${caller.symbol}() defined in ${target}${at}`
64
+ : `reaches ${target} in ${distance} steps, via ${caller.symbol}()`,
65
+ through: target,
66
+ symbol: caller.symbol,
67
+ line: caller.line,
68
+ };
69
+ if (record(found, caller.from, distance, caller.confidence, reason, declaredSet, options, () => (truncated = true))) {
70
+ next.push(caller.from);
71
+ }
72
+ }
73
+ for (const importer of (importersOf.get(target) ?? []).sort(byFrom)) {
74
+ const reason = {
75
+ kind: distance === 1 ? "imports" : "transitive",
76
+ explanation: distance === 1
77
+ ? `imports ${target}`
78
+ : `reaches ${target} in ${distance} steps, through imports`,
79
+ through: target,
80
+ };
81
+ if (record(found, importer.from, distance, importer.confidence, reason, declaredSet, options, () => (truncated = true))) {
82
+ next.push(importer.from);
83
+ }
84
+ }
85
+ }
86
+ frontier = [...new Set(next)];
87
+ }
88
+ const caveats = [];
89
+ if (graph.unresolved.length > 0) {
90
+ caveats.push(`${graph.unresolved.length} import${plural(graph.unresolved.length)} could not be resolved; ` +
91
+ "any of them may reach these paths");
92
+ }
93
+ if (calls.unresolved.length > 0) {
94
+ caveats.push(`${calls.unresolved.length} call${plural(calls.unresolved.length)} could not be bound to a definition`);
95
+ }
96
+ if (calls.unanalyzed.length > 0) {
97
+ caveats.push(`${calls.unanalyzed.length} file${plural(calls.unanalyzed.length)} had no call analysis available`);
98
+ }
99
+ if (graph.blindSpots.length > 0) {
100
+ caveats.push(`${graph.blindSpots.length} file${plural(graph.blindSpots.length)} could not be fully parsed`);
101
+ }
102
+ if (truncated) {
103
+ caveats.push(`the result was capped at ${options.maxPaths} added paths`);
104
+ }
105
+ const paths = [...found.values()].sort((a, b) => Number(b.declared) - Number(a.declared) || a.distance - b.distance || byCodeUnit(a.path, b.path));
106
+ return {
107
+ paths,
108
+ added: paths.filter((p) => !p.declared).map((p) => p.path).sort(),
109
+ caveats,
110
+ complete: caveats.length === 0,
111
+ // Scoped to the radius, and only when the caller supplied the facts. Note
112
+ // this is computed over the paths actually returned, so a truncated result
113
+ // reports coverage for what it returned rather than for what it would have.
114
+ ...(options.coverage
115
+ ? { coverage: coverageForRadius(options.coverage, paths.map((p) => p.path)) }
116
+ : {}),
117
+ };
118
+ }
119
+ /** Returns true when the path is newly reached and should be traversed onward. */
120
+ function record(found, path, distance, confidence, reason, declared, options, onTruncate) {
121
+ const existing = found.get(path);
122
+ if (existing) {
123
+ // Already reached. Keep the extra reason — two independent routes to the
124
+ // same file is exactly the evidence a reviewer wants — but do not re-walk.
125
+ if (!existing.reasons.some((r) => r.explanation === reason.explanation)) {
126
+ existing.reasons.push(reason);
127
+ }
128
+ return false;
129
+ }
130
+ if (options.maxPaths !== undefined) {
131
+ const addedSoFar = [...found.values()].filter((p) => !p.declared).length;
132
+ if (addedSoFar >= options.maxPaths) {
133
+ onTruncate();
134
+ return false;
135
+ }
136
+ }
137
+ found.set(path, {
138
+ path,
139
+ declared: declared.has(path),
140
+ distance,
141
+ confidence,
142
+ reasons: [reason],
143
+ });
144
+ return true;
145
+ }
146
+ function push(map, key, value) {
147
+ const list = map.get(key);
148
+ if (list)
149
+ list.push(value);
150
+ else
151
+ map.set(key, [value]);
152
+ }
153
+ const byFrom = (a, b) => byCodeUnit(a.from, b.from);
154
+ const plural = (n) => (n === 1 ? "" : "s");
155
+ /**
156
+ * The impact as lines a human can scan.
157
+ *
158
+ * Deliberately part of this module rather than left to each caller. The reason
159
+ * a path is in the radius is the product; a caller that renders only the paths
160
+ * has thrown away the thing that distinguishes this from grep-v1, and making
161
+ * the readable form the easy default is how that is prevented.
162
+ */
163
+ export function explainImpact(impact) {
164
+ const lines = impact.paths
165
+ .filter((path) => !path.declared)
166
+ .map((path) => `${path.path} — ${path.reasons.map((r) => r.explanation).join("; ")} [${path.confidence}]`);
167
+ if (impact.caveats.length > 0) {
168
+ lines.push(`incomplete: ${impact.caveats.join("; ")}`);
169
+ }
170
+ else if (lines.length === 0) {
171
+ lines.push("no other files reach the declared paths, and the graph resolved everything it saw");
172
+ }
173
+ return lines;
174
+ }