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,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
|
+
}
|