brainclaw 1.24.0 → 1.25.0
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/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-code-map.js +9 -2
- package/dist/commands/code-map.js +119 -2
- package/dist/commands/mcp-catalog.js +46 -0
- package/dist/commands/mcp.js +54 -2
- package/dist/core/code-map/backend.js +158 -1
- package/dist/core/code-map/export.js +212 -0
- package/dist/core/code-map/freshness.js +3 -2
- package/dist/core/code-map/impact.js +377 -0
- package/dist/core/code-map/indexes.js +27 -3
- package/dist/core/code-map/lang/typescript/config.js +271 -0
- package/dist/core/code-map/lang/typescript/index.js +20 -4
- package/dist/core/code-map/query.js +76 -13
- package/dist/core/code-map/refresh.js +0 -0
- package/dist/core/code-map/resolve.js +1 -0
- package/dist/core/code-map/types.js +15 -0
- package/dist/core/federation-pull.js +151 -3
- package/dist/core/federation-push.js +16 -3
- package/dist/core/protocol-tool-policy.js +3 -0
- package/dist/core/worktree.js +89 -2
- package/dist/facts.js +13 -10
- package/dist/facts.json +12 -9
- package/docs/cli.md +8 -0
- package/docs/code-map.md +24 -1
- package/docs/integrations/mcp.md +5 -2
- package/docs/mcp-schema-changelog.md +11 -1
- package/package.json +1 -1
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded Code Map subgraph export. Mermaid is rendered from this module's JSON
|
|
3
|
+
* model; it never takes a second, potentially different graph traversal.
|
|
4
|
+
*/
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { withCoarse } from './freshness.js';
|
|
7
|
+
import { listShards, readManifest } from './store.js';
|
|
8
|
+
/** Absolute traversal and response ceilings: a whole-graph export is impossible. */
|
|
9
|
+
export const CODE_EXPORT_MAX_DEPTH = 4;
|
|
10
|
+
export const CODE_EXPORT_NODE_CAP = 100;
|
|
11
|
+
export const CODE_EXPORT_EDGE_CAP = 200;
|
|
12
|
+
/** Low-confidence extracted data is never included by default or opt-out. */
|
|
13
|
+
export const CODE_EXPORT_MIN_CONFIDENCE = 0.5;
|
|
14
|
+
function clampInteger(value, fallback, min, max) {
|
|
15
|
+
if (value === undefined || !Number.isFinite(value))
|
|
16
|
+
return fallback;
|
|
17
|
+
return Math.min(Math.max(Math.floor(value), min), max);
|
|
18
|
+
}
|
|
19
|
+
function clampConfidence(value) {
|
|
20
|
+
if (value === undefined || !Number.isFinite(value))
|
|
21
|
+
return CODE_EXPORT_MIN_CONFIDENCE;
|
|
22
|
+
return Math.min(Math.max(value, CODE_EXPORT_MIN_CONFIDENCE), 1);
|
|
23
|
+
}
|
|
24
|
+
function normalizePath(value) {
|
|
25
|
+
return value.replace(/\\/g, '/');
|
|
26
|
+
}
|
|
27
|
+
function normalizeDirection(value) {
|
|
28
|
+
return value === 'outgoing' || value === 'incoming' || value === 'both' ? value : 'both';
|
|
29
|
+
}
|
|
30
|
+
function normalizeFormat(value) {
|
|
31
|
+
return value === 'mermaid' || value === 'json' ? value : 'json';
|
|
32
|
+
}
|
|
33
|
+
function normalizeTargetKind(value, target) {
|
|
34
|
+
return value === 'symbol' || value === 'file' ? value : inferTargetKind(target);
|
|
35
|
+
}
|
|
36
|
+
function inferTargetKind(target) {
|
|
37
|
+
return /[\\/]/.test(target)
|
|
38
|
+
|| /\.(?:[cm]?[jt]sx?|py|php|java|go|rs|cs|rb|c|cc|cpp|cxx|h|hpp)$/i.test(target)
|
|
39
|
+
? 'file'
|
|
40
|
+
: 'symbol';
|
|
41
|
+
}
|
|
42
|
+
/** No selector may address the project root itself or escape it. */
|
|
43
|
+
function normalizeFileTarget(target, projectRoot) {
|
|
44
|
+
const root = path.resolve(projectRoot);
|
|
45
|
+
const absolute = path.resolve(root, target);
|
|
46
|
+
const relative = path.relative(root, absolute);
|
|
47
|
+
if (!relative || relative === '.' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
|
|
48
|
+
return null;
|
|
49
|
+
return normalizePath(relative);
|
|
50
|
+
}
|
|
51
|
+
function compareNodes(a, b) {
|
|
52
|
+
const as = a.span;
|
|
53
|
+
const bs = b.span;
|
|
54
|
+
return a.path.localeCompare(b.path)
|
|
55
|
+
|| (as?.start_line ?? -1) - (bs?.start_line ?? -1)
|
|
56
|
+
|| (as?.start_col ?? -1) - (bs?.start_col ?? -1)
|
|
57
|
+
|| a.kind.localeCompare(b.kind)
|
|
58
|
+
|| a.name.localeCompare(b.name)
|
|
59
|
+
|| a.id.localeCompare(b.id);
|
|
60
|
+
}
|
|
61
|
+
function compareEdges(a, b) {
|
|
62
|
+
return a.from.localeCompare(b.from)
|
|
63
|
+
|| a.to.localeCompare(b.to)
|
|
64
|
+
|| a.kind.localeCompare(b.kind)
|
|
65
|
+
|| (a.source?.path ?? '').localeCompare(b.source?.path ?? '')
|
|
66
|
+
|| (a.source?.line ?? -1) - (b.source?.line ?? -1)
|
|
67
|
+
|| a.id.localeCompare(b.id);
|
|
68
|
+
}
|
|
69
|
+
function graphNode(node) {
|
|
70
|
+
return { id: node.id, kind: node.kind, subtype: node.subtype ?? null, lang: node.lang, name: node.name, path: node.path,
|
|
71
|
+
span: node.span ?? null, exported: node.exported, confidence: node.confidence };
|
|
72
|
+
}
|
|
73
|
+
function graphEdge(edge) {
|
|
74
|
+
return { id: edge.id, from: edge.from, to: edge.to, kind: edge.kind, confidence: edge.confidence, source: edge.source ?? null };
|
|
75
|
+
}
|
|
76
|
+
function matchesRoot(node, target, targetKind, normalizedPath) {
|
|
77
|
+
return targetKind === 'file'
|
|
78
|
+
? node.kind === 'file' && normalizedPath !== null && normalizePath(node.path) === normalizedPath
|
|
79
|
+
: node.kind === 'symbol' && node.name === target;
|
|
80
|
+
}
|
|
81
|
+
function usableEdge(edge, nodes, minConfidence) {
|
|
82
|
+
return edge.confidence >= minConfidence
|
|
83
|
+
&& (nodes.get(edge.from)?.confidence ?? -Infinity) >= minConfidence
|
|
84
|
+
&& (nodes.get(edge.to)?.confidence ?? -Infinity) >= minConfidence;
|
|
85
|
+
}
|
|
86
|
+
function touches(edge, nodeId, direction) {
|
|
87
|
+
return ((direction === 'outgoing' || direction === 'both') && edge.from === nodeId)
|
|
88
|
+
|| ((direction === 'incoming' || direction === 'both') && edge.to === nodeId);
|
|
89
|
+
}
|
|
90
|
+
function neighbor(edge, nodeId) {
|
|
91
|
+
return edge.from === nodeId ? edge.to : edge.from;
|
|
92
|
+
}
|
|
93
|
+
function emptyOutput(target, targetKind, limits, freshness, format) {
|
|
94
|
+
const graph = {
|
|
95
|
+
target, target_kind: targetKind, root_node_ids: [], nodes: [], edges: [], limits,
|
|
96
|
+
truncated: { roots: false, nodes: false, edges: false, depth: false }, freshness_badge: freshness,
|
|
97
|
+
};
|
|
98
|
+
return format === 'mermaid' ? { ...graph, format, mermaid: toMermaid(graph) } : { ...graph, format };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Select a deterministic, confidence-filtered neighborhood from persisted shards.
|
|
102
|
+
* No refresh, parse, inference, service call, or unbounded response occurs here.
|
|
103
|
+
*/
|
|
104
|
+
export function exportSubgraph(targetInput, options, ctx) {
|
|
105
|
+
const target = targetInput.trim();
|
|
106
|
+
const targetKind = normalizeTargetKind(options?.targetKind, target);
|
|
107
|
+
const format = normalizeFormat(options?.format);
|
|
108
|
+
const limits = {
|
|
109
|
+
direction: normalizeDirection(options?.direction),
|
|
110
|
+
max_depth: clampInteger(options?.depth, 1, 0, CODE_EXPORT_MAX_DEPTH),
|
|
111
|
+
max_nodes: clampInteger(options?.maxNodes, CODE_EXPORT_NODE_CAP, 1, CODE_EXPORT_NODE_CAP),
|
|
112
|
+
max_edges: clampInteger(options?.maxEdges, CODE_EXPORT_EDGE_CAP, 0, CODE_EXPORT_EDGE_CAP),
|
|
113
|
+
min_confidence: clampConfidence(options?.minConfidence),
|
|
114
|
+
};
|
|
115
|
+
const manifest = readManifest(ctx.cwd, ctx.preferredDirName);
|
|
116
|
+
const missing = withCoarse({ status: 'missing_index', details: { hint: 'run refresh' } });
|
|
117
|
+
if (!manifest || manifest.freshness.status === 'missing_index' || !target)
|
|
118
|
+
return emptyOutput(target, targetKind, limits, missing, format);
|
|
119
|
+
const targetPath = targetKind === 'file' ? normalizeFileTarget(target, manifest.project_root) : null;
|
|
120
|
+
if (targetKind === 'file' && !targetPath) {
|
|
121
|
+
return emptyOutput(target, targetKind, limits, withCoarse({
|
|
122
|
+
status: manifest.freshness.status, details: { invalid_target: 'file path must be inside the indexed project' },
|
|
123
|
+
}), format);
|
|
124
|
+
}
|
|
125
|
+
const allNodes = new Map();
|
|
126
|
+
const allEdges = new Map();
|
|
127
|
+
for (const shard of listShards(ctx.cwd, ctx.preferredDirName).sort((a, b) => a.path.localeCompare(b.path) || a.file_id.localeCompare(b.file_id))) {
|
|
128
|
+
for (const node of shard.nodes)
|
|
129
|
+
if (!allNodes.has(node.id))
|
|
130
|
+
allNodes.set(node.id, node);
|
|
131
|
+
for (const edge of shard.edges)
|
|
132
|
+
if (!allEdges.has(edge.id))
|
|
133
|
+
allEdges.set(edge.id, edge);
|
|
134
|
+
}
|
|
135
|
+
const nodes = new Map([...allNodes.entries()].filter(([, node]) => node.confidence >= limits.min_confidence));
|
|
136
|
+
const edges = [...allEdges.values()].filter((edge) => usableEdge(edge, nodes, limits.min_confidence)).sort(compareEdges);
|
|
137
|
+
const roots = [...nodes.values()].filter((node) => matchesRoot(node, target, targetKind, targetPath)).sort(compareNodes);
|
|
138
|
+
const selected = new Set();
|
|
139
|
+
const rootNodeIds = [];
|
|
140
|
+
let rootsTruncated = false;
|
|
141
|
+
for (const root of roots) {
|
|
142
|
+
if (selected.size >= limits.max_nodes) {
|
|
143
|
+
rootsTruncated = true;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
selected.add(root.id);
|
|
147
|
+
rootNodeIds.push(root.id);
|
|
148
|
+
}
|
|
149
|
+
const selectedEdges = new Map();
|
|
150
|
+
const visited = new Set(rootNodeIds);
|
|
151
|
+
let nodesTruncated = false;
|
|
152
|
+
let edgesTruncated = false;
|
|
153
|
+
let depthTruncated = false;
|
|
154
|
+
let frontier = [...rootNodeIds];
|
|
155
|
+
for (let currentDepth = 0; frontier.length > 0; currentDepth++) {
|
|
156
|
+
const next = new Set();
|
|
157
|
+
frontier.sort((a, b) => compareNodes(nodes.get(a), nodes.get(b)));
|
|
158
|
+
for (const nodeId of frontier) {
|
|
159
|
+
const incident = edges.filter((edge) => touches(edge, nodeId, limits.direction));
|
|
160
|
+
if (currentDepth >= limits.max_depth) {
|
|
161
|
+
if (incident.some((edge) => !selectedEdges.has(edge.id)))
|
|
162
|
+
depthTruncated = true;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
for (const edge of incident) {
|
|
166
|
+
const adjacent = neighbor(edge, nodeId);
|
|
167
|
+
if (!selected.has(adjacent) && selected.size >= limits.max_nodes) {
|
|
168
|
+
nodesTruncated = true;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (selectedEdges.size >= limits.max_edges && !selectedEdges.has(edge.id)) {
|
|
172
|
+
edgesTruncated = true;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
selected.add(adjacent);
|
|
176
|
+
selectedEdges.set(edge.id, edge);
|
|
177
|
+
if (!visited.has(adjacent)) {
|
|
178
|
+
visited.add(adjacent);
|
|
179
|
+
next.add(adjacent);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
frontier = [...next];
|
|
184
|
+
}
|
|
185
|
+
const graph = {
|
|
186
|
+
target, target_kind: targetKind, root_node_ids: rootNodeIds,
|
|
187
|
+
nodes: [...selected].map((id) => nodes.get(id)).filter((node) => node !== undefined).sort(compareNodes).map(graphNode),
|
|
188
|
+
edges: [...selectedEdges.values()].sort(compareEdges).map(graphEdge),
|
|
189
|
+
limits, truncated: { roots: rootsTruncated, nodes: nodesTruncated, edges: edgesTruncated, depth: depthTruncated },
|
|
190
|
+
freshness_badge: withCoarse({ status: manifest.freshness.status,
|
|
191
|
+
details: { stale_file_count: manifest.freshness.stale_file_count, partial_reason: manifest.freshness.partial_reason } }),
|
|
192
|
+
};
|
|
193
|
+
return format === 'mermaid' ? { ...graph, format, mermaid: toMermaid(graph) } : { ...graph, format };
|
|
194
|
+
}
|
|
195
|
+
function mermaidLabel(node) {
|
|
196
|
+
return JSON.stringify(`${node.name} (${node.kind})`.replace(/[\r\n]/g, ' '));
|
|
197
|
+
}
|
|
198
|
+
/** Deterministic textual projection of a graph already selected by exportSubgraph. */
|
|
199
|
+
export function toMermaid(graph) {
|
|
200
|
+
const ids = new Map(graph.nodes.map((node, index) => [node.id, `n${index}`]));
|
|
201
|
+
const lines = ['flowchart TD'];
|
|
202
|
+
for (const node of graph.nodes)
|
|
203
|
+
lines.push(` ${ids.get(node.id)}[${mermaidLabel(node)}]`);
|
|
204
|
+
for (const edge of graph.edges) {
|
|
205
|
+
const from = ids.get(edge.from);
|
|
206
|
+
const to = ids.get(edge.to);
|
|
207
|
+
if (from && to)
|
|
208
|
+
lines.push(` ${from} -->|${edge.kind} · ${edge.confidence}| ${to}`);
|
|
209
|
+
}
|
|
210
|
+
return lines.join('\n');
|
|
211
|
+
}
|
|
212
|
+
//# sourceMappingURL=export.js.map
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* extractor_config + the active language set + (P1a) the registry's
|
|
9
9
|
* `configHashInputs()` (provider versions + every query-asset hash). Changing
|
|
10
10
|
* ignore rules, size caps, supported extensions, query budget, active langs, a
|
|
11
|
-
* provider version, OR
|
|
11
|
+
* provider version, query assets, OR local resolver config => stale_extractor.
|
|
12
12
|
* NOTE: grammar/engine hashes are deliberately NOT folded in (spec §6.2):
|
|
13
13
|
* stale_grammar (changed parse binary) is kept separable from stale_extractor.
|
|
14
14
|
* - `shardFreshnessStatus` — classify a stored shard against the current
|
|
@@ -69,11 +69,12 @@ function stableStringify(value) {
|
|
|
69
69
|
* (dec#109 P0#3). Optional + omitted-vs-undefined hash the same so legacy callers
|
|
70
70
|
* (config-only) keep a stable hash for that input combination.
|
|
71
71
|
*/
|
|
72
|
-
export function computeExtractorConfigHash(config, activeLanguages, registryInputs) {
|
|
72
|
+
export function computeExtractorConfigHash(config, activeLanguages, registryInputs, resolverConfigFingerprint) {
|
|
73
73
|
const payload = {
|
|
74
74
|
extractor_config: config,
|
|
75
75
|
active_languages: [...activeLanguages].sort(),
|
|
76
76
|
registry: registryInputs ?? null,
|
|
77
|
+
resolver_config: resolverConfigFingerprint ?? null,
|
|
77
78
|
};
|
|
78
79
|
return `sha256:${crypto.createHash('sha256').update(stableStringify(payload)).digest('hex')}`;
|
|
79
80
|
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded, explainable Code Map impact analysis.
|
|
3
|
+
*
|
|
4
|
+
* This module deliberately traverses only the persisted P1c resolution graph
|
|
5
|
+
* through P1d's reverse ResolutionIndex. It neither reparses files nor infers
|
|
6
|
+
* import edges from a naming convention. Naming is limited to a clearly marked,
|
|
7
|
+
* low-confidence test suggestion after resolved test imports have been reported.
|
|
8
|
+
*/
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { fileId } from './ids.js';
|
|
11
|
+
import { readManifest, readResolutionIndex, readShard, readSymbolsIndex, } from './store.js';
|
|
12
|
+
import { deriveBadge, isTestPath, makeLazyChecker, newAccumulator, validateStoreEntry, } from './query.js';
|
|
13
|
+
/** Direct results and each optional transitive layer are independently bounded. */
|
|
14
|
+
export const IMPACT_DEPENDENT_CAP = 100;
|
|
15
|
+
/** A depth of one is direct only; transitives require an explicit depth of two or more. */
|
|
16
|
+
export const IMPACT_MAX_DEPTH = 4;
|
|
17
|
+
export const IMPACT_NAMING_SUGGESTION_CONFIDENCE = 0.25;
|
|
18
|
+
function normalizeIdentifier(value) {
|
|
19
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
20
|
+
}
|
|
21
|
+
function looksLikePathTarget(target) {
|
|
22
|
+
return /[\\/]/.test(target) || /\.(?:[cm]?[jt]sx?|py|php|java|go|rs|cs|rb|c|cc|cpp|cxx|h|hpp)$/i.test(target);
|
|
23
|
+
}
|
|
24
|
+
function normalizePathTarget(target, projectRoot) {
|
|
25
|
+
const root = path.resolve(projectRoot);
|
|
26
|
+
const absolute = path.resolve(root, target);
|
|
27
|
+
const relative = path.relative(root, absolute);
|
|
28
|
+
if (!relative || relative === '.' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return relative.replace(/\\/g, '/');
|
|
32
|
+
}
|
|
33
|
+
function entriesForToken(index, target) {
|
|
34
|
+
const normalizedTarget = normalizeIdentifier(target);
|
|
35
|
+
if (!normalizedTarget)
|
|
36
|
+
return [];
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
const candidates = [];
|
|
39
|
+
for (const entries of Object.values(index.entries)) {
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
if (seen.has(entry.node_id))
|
|
42
|
+
continue;
|
|
43
|
+
const normalizedName = normalizeIdentifier(entry.name);
|
|
44
|
+
if (normalizedName === normalizedTarget || normalizedName.includes(normalizedTarget)) {
|
|
45
|
+
seen.add(entry.node_id);
|
|
46
|
+
candidates.push(entry);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const exact = candidates.filter((entry) => normalizeIdentifier(entry.name) === normalizedTarget);
|
|
51
|
+
return (exact.length > 0 ? exact : candidates).sort((a, b) => a.path.localeCompare(b.path) || a.name.localeCompare(b.name) || a.node_id.localeCompare(b.node_id));
|
|
52
|
+
}
|
|
53
|
+
function entriesForPath(index, target) {
|
|
54
|
+
const normalizedTarget = target.replace(/\\/g, '/');
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
const entries = [];
|
|
57
|
+
for (const bucket of Object.values(index.entries)) {
|
|
58
|
+
for (const entry of bucket) {
|
|
59
|
+
const candidatePath = entry.path.replace(/\\/g, '/');
|
|
60
|
+
if ((candidatePath === normalizedTarget || candidatePath.endsWith(`/${normalizedTarget}`)) && !seen.has(entry.node_id)) {
|
|
61
|
+
seen.add(entry.node_id);
|
|
62
|
+
entries.push(entry);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return entries.sort((a, b) => a.path.localeCompare(b.path) || a.name.localeCompare(b.name));
|
|
67
|
+
}
|
|
68
|
+
function asDefinition(entry) {
|
|
69
|
+
return {
|
|
70
|
+
node_id: entry.node_id,
|
|
71
|
+
name: entry.name,
|
|
72
|
+
kind: 'symbol',
|
|
73
|
+
subtype: entry.subtype ?? null,
|
|
74
|
+
path: entry.path,
|
|
75
|
+
file_id: entry.file_id,
|
|
76
|
+
span: null,
|
|
77
|
+
confidence: entry.score_hint,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function fallbackReasons(entry, kind) {
|
|
81
|
+
const indexed = entry.reasons.filter((reason) => reason.kind === kind);
|
|
82
|
+
if (indexed.length > 0)
|
|
83
|
+
return indexed;
|
|
84
|
+
// Existing P1d indexes (written before P3) still have a compact aggregate.
|
|
85
|
+
// Preserve their factual resolution evidence rather than manufacturing a guess.
|
|
86
|
+
return [{
|
|
87
|
+
kind,
|
|
88
|
+
...(entry.module ? { module: entry.module } : {}),
|
|
89
|
+
imported: entry.imported,
|
|
90
|
+
...(typeof entry.confidence === 'number' ? { confidence: entry.confidence } : {}),
|
|
91
|
+
}];
|
|
92
|
+
}
|
|
93
|
+
function causeKey(cause) {
|
|
94
|
+
return [
|
|
95
|
+
cause.kind,
|
|
96
|
+
cause.module ?? '',
|
|
97
|
+
cause.imported.join('\u0000'),
|
|
98
|
+
String(cause.confidence ?? ''),
|
|
99
|
+
String(cause.source_line ?? ''),
|
|
100
|
+
cause.target.kind,
|
|
101
|
+
cause.target.path,
|
|
102
|
+
cause.target.node_id ?? '',
|
|
103
|
+
].join('\u0001');
|
|
104
|
+
}
|
|
105
|
+
function compareCause(a, b) {
|
|
106
|
+
return a.kind.localeCompare(b.kind)
|
|
107
|
+
|| a.target.path.localeCompare(b.target.path)
|
|
108
|
+
|| (a.target.node_id ?? '').localeCompare(b.target.node_id ?? '')
|
|
109
|
+
|| (a.module ?? '').localeCompare(b.module ?? '')
|
|
110
|
+
|| (a.source_line ?? -1) - (b.source_line ?? -1)
|
|
111
|
+
|| a.imported.join('\u0000').localeCompare(b.imported.join('\u0000'))
|
|
112
|
+
|| (a.confidence ?? -1) - (b.confidence ?? -1);
|
|
113
|
+
}
|
|
114
|
+
function addRelation(rows, entry, depth, target, kind) {
|
|
115
|
+
const current = rows.get(entry.path) ?? {
|
|
116
|
+
path: entry.path,
|
|
117
|
+
file_id: entry.file_id,
|
|
118
|
+
depth,
|
|
119
|
+
causes: [],
|
|
120
|
+
causeKeys: new Set(),
|
|
121
|
+
};
|
|
122
|
+
current.depth = Math.min(current.depth, depth);
|
|
123
|
+
for (const reason of fallbackReasons(entry, kind)) {
|
|
124
|
+
const cause = {
|
|
125
|
+
kind: reason.kind,
|
|
126
|
+
...(reason.module ? { module: reason.module } : {}),
|
|
127
|
+
imported: [...reason.imported],
|
|
128
|
+
...(typeof reason.confidence === 'number' ? { confidence: reason.confidence } : {}),
|
|
129
|
+
...(reason.source_line !== undefined ? { source_line: reason.source_line } : {}),
|
|
130
|
+
target,
|
|
131
|
+
};
|
|
132
|
+
const key = causeKey(cause);
|
|
133
|
+
if (!current.causeKeys.has(key)) {
|
|
134
|
+
current.causeKeys.add(key);
|
|
135
|
+
current.causes.push(cause);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
current.causes.sort(compareCause);
|
|
139
|
+
rows.set(entry.path, current);
|
|
140
|
+
}
|
|
141
|
+
function publicRelation(row) {
|
|
142
|
+
return {
|
|
143
|
+
path: row.path,
|
|
144
|
+
file_id: row.file_id,
|
|
145
|
+
depth: row.depth,
|
|
146
|
+
causes: row.causes,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function relationConfidence(row) {
|
|
150
|
+
return row.causes.reduce((best, cause) => Math.max(best, cause.confidence ?? 0), 0);
|
|
151
|
+
}
|
|
152
|
+
function testStem(filePath) {
|
|
153
|
+
const base = filePath.replace(/\\/g, '/').split('/').pop() ?? filePath;
|
|
154
|
+
return normalizeIdentifier(base
|
|
155
|
+
.replace(/\.[^.]+$/, '')
|
|
156
|
+
.replace(/(?:[._-](?:test|spec)|(?:test|spec)s?)$/i, '')
|
|
157
|
+
.replace(/^(?:test|spec)[_-]/i, ''));
|
|
158
|
+
}
|
|
159
|
+
function clampDepth(depth) {
|
|
160
|
+
if (depth === undefined || !Number.isFinite(depth))
|
|
161
|
+
return 1;
|
|
162
|
+
return Math.min(Math.max(Math.floor(depth), 1), IMPACT_MAX_DEPTH);
|
|
163
|
+
}
|
|
164
|
+
function clampLimit(limit) {
|
|
165
|
+
if (limit === undefined || !Number.isFinite(limit))
|
|
166
|
+
return IMPACT_DEPENDENT_CAP;
|
|
167
|
+
return Math.min(Math.max(Math.floor(limit), 0), IMPACT_DEPENDENT_CAP);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Read a bounded blast radius from existing resolved imports. A direct relation
|
|
171
|
+
* is distance 1. Supplying depth=2 (or more) opts into transitively importing
|
|
172
|
+
* files; depth is clamped to {@link IMPACT_MAX_DEPTH}.
|
|
173
|
+
*/
|
|
174
|
+
export function impact(target, options, ctx) {
|
|
175
|
+
const symbolsIndex = readSymbolsIndex(ctx.cwd, ctx.preferredDirName);
|
|
176
|
+
const manifest = readManifest(ctx.cwd, ctx.preferredDirName);
|
|
177
|
+
const maxDepth = clampDepth(options?.depth);
|
|
178
|
+
const limit = clampLimit(options?.limit);
|
|
179
|
+
const checker = makeLazyChecker();
|
|
180
|
+
const acc = newAccumulator();
|
|
181
|
+
const empty = (freshness) => ({
|
|
182
|
+
target,
|
|
183
|
+
definition: { match_kind: 'none', entries: [] },
|
|
184
|
+
direct_dependents: [],
|
|
185
|
+
transitive_dependents: [],
|
|
186
|
+
tests_for: [],
|
|
187
|
+
risk: {
|
|
188
|
+
score: 0,
|
|
189
|
+
formula: 'direct_dependents + transitive_dependents',
|
|
190
|
+
counters: {
|
|
191
|
+
definitions: 0,
|
|
192
|
+
direct_dependents: 0,
|
|
193
|
+
transitive_dependents: 0,
|
|
194
|
+
resolved_test_files: 0,
|
|
195
|
+
suggested_test_files: 0,
|
|
196
|
+
max_depth_returned: 0,
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
limits: {
|
|
200
|
+
max_depth: maxDepth,
|
|
201
|
+
max_dependents_per_section: limit,
|
|
202
|
+
direct_truncated: false,
|
|
203
|
+
transitive_truncated: false,
|
|
204
|
+
},
|
|
205
|
+
freshness_badge: freshness,
|
|
206
|
+
});
|
|
207
|
+
if (!symbolsIndex || !manifest) {
|
|
208
|
+
return empty({ status: 'missing_index', coarse: 'missing', details: { hint: 'run refresh' } });
|
|
209
|
+
}
|
|
210
|
+
let matchKind = 'none';
|
|
211
|
+
let rawDefinitions = [];
|
|
212
|
+
if (looksLikePathTarget(target)) {
|
|
213
|
+
const safePath = normalizePathTarget(target, manifest.project_root);
|
|
214
|
+
if (safePath) {
|
|
215
|
+
const symbols = entriesForPath(symbolsIndex, safePath);
|
|
216
|
+
rawDefinitions = symbols.map(asDefinition);
|
|
217
|
+
if (rawDefinitions.length > 0) {
|
|
218
|
+
matchKind = 'path';
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
const shard = readShard(fileId(manifest.project_id, safePath), ctx.cwd, ctx.preferredDirName);
|
|
222
|
+
const fileNode = shard?.nodes.find((node) => node.kind === 'file');
|
|
223
|
+
if (shard && fileNode) {
|
|
224
|
+
rawDefinitions = [{
|
|
225
|
+
node_id: fileNode.id,
|
|
226
|
+
name: fileNode.name,
|
|
227
|
+
kind: 'file',
|
|
228
|
+
subtype: null,
|
|
229
|
+
path: shard.path,
|
|
230
|
+
file_id: shard.file_id,
|
|
231
|
+
span: null,
|
|
232
|
+
confidence: fileNode.confidence,
|
|
233
|
+
}];
|
|
234
|
+
matchKind = 'path';
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
const symbols = entriesForToken(symbolsIndex, target);
|
|
241
|
+
rawDefinitions = symbols.map(asDefinition);
|
|
242
|
+
if (rawDefinitions.length > 0) {
|
|
243
|
+
matchKind = rawDefinitions.every((entry) => normalizeIdentifier(entry.name) === normalizeIdentifier(target)) ? 'exact' : 'fuzzy';
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const definitions = rawDefinitions
|
|
247
|
+
.filter((entry) => validateStoreEntry({ path: entry.path, file_id: entry.file_id }, checker, acc, ctx.cwd, ctx.preferredDirName))
|
|
248
|
+
.map((entry) => {
|
|
249
|
+
// SymbolIndexEntry intentionally stores only a ranking hint. The shard is
|
|
250
|
+
// the authoritative persisted source for the definition span/confidence.
|
|
251
|
+
const node = readShard(entry.file_id, ctx.cwd, ctx.preferredDirName)?.nodes.find((candidate) => candidate.id === entry.node_id);
|
|
252
|
+
return node ? { ...entry, span: node.span ?? null, confidence: node.confidence } : entry;
|
|
253
|
+
});
|
|
254
|
+
const definitionByNodeId = new Map(definitions.filter((entry) => entry.kind === 'symbol').map((entry) => [entry.node_id, entry]));
|
|
255
|
+
const definitionPaths = new Set(definitions.map((entry) => entry.path));
|
|
256
|
+
const resolution = readResolutionIndex(ctx.cwd, ctx.preferredDirName);
|
|
257
|
+
const directRows = new Map();
|
|
258
|
+
if (resolution) {
|
|
259
|
+
for (const definition of definitionByNodeId.values()) {
|
|
260
|
+
for (const dependent of resolution.dependents_by_symbol[definition.node_id] ?? []) {
|
|
261
|
+
addRelation(directRows, dependent, 1, {
|
|
262
|
+
kind: 'symbol', path: definition.path, node_id: definition.node_id, name: definition.name,
|
|
263
|
+
}, 'imports_symbol');
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
for (const definitionPath of definitionPaths) {
|
|
267
|
+
for (const dependent of resolution.dependents_by_file[definitionPath] ?? []) {
|
|
268
|
+
addRelation(directRows, dependent, 1, { kind: 'file', path: definitionPath }, 'resolves_to');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const sortedDirect = [...directRows.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
273
|
+
const directCandidates = sortedDirect.slice(0, limit);
|
|
274
|
+
const direct = directCandidates
|
|
275
|
+
.filter((row) => validateStoreEntry(row, checker, acc, ctx.cwd, ctx.preferredDirName))
|
|
276
|
+
.map(publicRelation);
|
|
277
|
+
const transitive = [];
|
|
278
|
+
let transitiveTruncated = false;
|
|
279
|
+
if (resolution && maxDepth > 1 && limit > 0) {
|
|
280
|
+
const visited = new Set([...definitionPaths, ...direct.map((row) => row.path)]);
|
|
281
|
+
const queue = direct.map((row) => ({ path: row.path, depth: 1 }));
|
|
282
|
+
for (let offset = 0; offset < queue.length; offset++) {
|
|
283
|
+
const current = queue[offset];
|
|
284
|
+
if (current.depth >= maxDepth)
|
|
285
|
+
continue;
|
|
286
|
+
for (const dependent of resolution.dependents_by_file[current.path] ?? []) {
|
|
287
|
+
if (visited.has(dependent.path))
|
|
288
|
+
continue;
|
|
289
|
+
visited.add(dependent.path);
|
|
290
|
+
if (transitive.length >= limit) {
|
|
291
|
+
transitiveTruncated = true;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const rowMap = new Map();
|
|
295
|
+
addRelation(rowMap, dependent, current.depth + 1, { kind: 'file', path: current.path }, 'resolves_to');
|
|
296
|
+
const row = rowMap.get(dependent.path);
|
|
297
|
+
if (!validateStoreEntry(row, checker, acc, ctx.cwd, ctx.preferredDirName))
|
|
298
|
+
continue;
|
|
299
|
+
const output = publicRelation(row);
|
|
300
|
+
transitive.push(output);
|
|
301
|
+
queue.push({ path: output.path, depth: output.depth });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
transitive.sort((a, b) => a.depth - b.depth || a.path.localeCompare(b.path));
|
|
306
|
+
const confirmedTests = [...direct, ...transitive]
|
|
307
|
+
.filter((row) => isTestPath(row.path))
|
|
308
|
+
.map((row) => ({
|
|
309
|
+
path: row.path,
|
|
310
|
+
file_id: row.file_id,
|
|
311
|
+
relation: 'resolved_import',
|
|
312
|
+
confidence: relationConfidence(row),
|
|
313
|
+
depth: row.depth,
|
|
314
|
+
causes: row.causes,
|
|
315
|
+
reason: `resolved import at graph depth ${row.depth}`,
|
|
316
|
+
}));
|
|
317
|
+
const confirmedPaths = new Set(confirmedTests.map((test) => test.path));
|
|
318
|
+
const targetNames = new Set([
|
|
319
|
+
...definitions.map((definition) => normalizeIdentifier(definition.name)),
|
|
320
|
+
...definitions.map((definition) => testStem(definition.path)),
|
|
321
|
+
].filter(Boolean));
|
|
322
|
+
const suggestions = new Map();
|
|
323
|
+
if (targetNames.size > 0) {
|
|
324
|
+
const seenFiles = new Map();
|
|
325
|
+
for (const entries of Object.values(symbolsIndex.entries)) {
|
|
326
|
+
for (const entry of entries)
|
|
327
|
+
if (!seenFiles.has(entry.path))
|
|
328
|
+
seenFiles.set(entry.path, entry.file_id);
|
|
329
|
+
}
|
|
330
|
+
for (const [testPath, testFileId] of seenFiles) {
|
|
331
|
+
if (suggestions.size >= limit || confirmedPaths.has(testPath) || !isTestPath(testPath))
|
|
332
|
+
continue;
|
|
333
|
+
if (!targetNames.has(testStem(testPath)))
|
|
334
|
+
continue;
|
|
335
|
+
if (!validateStoreEntry({ path: testPath, file_id: testFileId }, checker, acc, ctx.cwd, ctx.preferredDirName))
|
|
336
|
+
continue;
|
|
337
|
+
suggestions.set(testPath, {
|
|
338
|
+
path: testPath,
|
|
339
|
+
file_id: testFileId,
|
|
340
|
+
relation: 'naming_convention_suggestion',
|
|
341
|
+
confidence: IMPACT_NAMING_SUGGESTION_CONFIDENCE,
|
|
342
|
+
reason: 'filename convention matches the target; no resolved import proves this relationship',
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const testsFor = [...confirmedTests, ...suggestions.values()].sort((a, b) => a.relation.localeCompare(b.relation) || a.path.localeCompare(b.path));
|
|
347
|
+
const maxDepthReturned = Math.max(0, ...direct.map((row) => row.depth), ...transitive.map((row) => row.depth));
|
|
348
|
+
const risk = {
|
|
349
|
+
score: direct.length + transitive.length,
|
|
350
|
+
formula: 'direct_dependents + transitive_dependents',
|
|
351
|
+
counters: {
|
|
352
|
+
definitions: definitions.length,
|
|
353
|
+
direct_dependents: direct.length,
|
|
354
|
+
transitive_dependents: transitive.length,
|
|
355
|
+
resolved_test_files: confirmedTests.length,
|
|
356
|
+
suggested_test_files: suggestions.size,
|
|
357
|
+
max_depth_returned: maxDepthReturned,
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
const freshnessBadge = deriveBadge(manifest.freshness.status, acc, checker.exhausted, definitions.length > 0 || direct.length > 0 || transitive.length > 0, definitions.length === 0);
|
|
361
|
+
return {
|
|
362
|
+
target,
|
|
363
|
+
definition: { match_kind: definitions.length > 0 ? matchKind : 'none', entries: definitions },
|
|
364
|
+
direct_dependents: direct,
|
|
365
|
+
transitive_dependents: transitive,
|
|
366
|
+
tests_for: testsFor,
|
|
367
|
+
risk,
|
|
368
|
+
limits: {
|
|
369
|
+
max_depth: maxDepth,
|
|
370
|
+
max_dependents_per_section: limit,
|
|
371
|
+
direct_truncated: sortedDirect.length > limit,
|
|
372
|
+
transitive_truncated: transitiveTruncated,
|
|
373
|
+
},
|
|
374
|
+
freshness_badge: freshnessBadge,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
//# sourceMappingURL=impact.js.map
|
|
@@ -130,7 +130,7 @@ export function buildResolutionIndex(projectId, shards) {
|
|
|
130
130
|
// target key -> (importer path -> merged entry)
|
|
131
131
|
const byFile = new Map();
|
|
132
132
|
const bySymbol = new Map();
|
|
133
|
-
const addDependent = (bucket, targetKey, importerPath, importerFileId, module, imported, confidence) => {
|
|
133
|
+
const addDependent = (bucket, targetKey, importerPath, importerFileId, module, imported, confidence, kind, sourceLine) => {
|
|
134
134
|
const perImporter = bucket.get(targetKey) ?? new Map();
|
|
135
135
|
const prev = perImporter.get(importerPath);
|
|
136
136
|
if (!prev) {
|
|
@@ -140,6 +140,7 @@ export function buildResolutionIndex(projectId, shards) {
|
|
|
140
140
|
module,
|
|
141
141
|
imported: [...new Set(imported)].sort(),
|
|
142
142
|
confidence,
|
|
143
|
+
reasons: [],
|
|
143
144
|
});
|
|
144
145
|
}
|
|
145
146
|
else {
|
|
@@ -154,6 +155,29 @@ export function buildResolutionIndex(projectId, shards) {
|
|
|
154
155
|
prev.confidence = typeof prev.confidence === 'number' ? Math.max(prev.confidence, confidence) : confidence;
|
|
155
156
|
}
|
|
156
157
|
}
|
|
158
|
+
const current = perImporter.get(importerPath);
|
|
159
|
+
const reason = {
|
|
160
|
+
kind,
|
|
161
|
+
...(module ? { module } : {}),
|
|
162
|
+
imported: [...new Set(imported)].sort(),
|
|
163
|
+
...(typeof confidence === 'number' ? { confidence } : {}),
|
|
164
|
+
...(sourceLine !== undefined ? { source_line: sourceLine } : {}),
|
|
165
|
+
};
|
|
166
|
+
// Multiple module nodes can resolve to one target from one importer. Keep
|
|
167
|
+
// every concrete cause, deduping an identical extractor edge defensively.
|
|
168
|
+
if (!current.reasons.some((existing) => existing.kind === reason.kind
|
|
169
|
+
&& existing.module === reason.module
|
|
170
|
+
&& existing.confidence === reason.confidence
|
|
171
|
+
&& existing.source_line === reason.source_line
|
|
172
|
+
&& existing.imported.length === reason.imported.length
|
|
173
|
+
&& existing.imported.every((name, index) => name === reason.imported[index]))) {
|
|
174
|
+
current.reasons.push(reason);
|
|
175
|
+
current.reasons.sort((a, b) => a.kind.localeCompare(b.kind)
|
|
176
|
+
|| (a.module ?? '').localeCompare(b.module ?? '')
|
|
177
|
+
|| (a.source_line ?? -1) - (b.source_line ?? -1)
|
|
178
|
+
|| a.imported.join('\0').localeCompare(b.imported.join('\0'))
|
|
179
|
+
|| (a.confidence ?? -1) - (b.confidence ?? -1));
|
|
180
|
+
}
|
|
157
181
|
bucket.set(targetKey, perImporter);
|
|
158
182
|
};
|
|
159
183
|
const ordered = [...shards].sort((a, b) => a.path.localeCompare(b.path));
|
|
@@ -172,10 +196,10 @@ export function buildResolutionIndex(projectId, shards) {
|
|
|
172
196
|
const targetPath = fileNodeIdToPath.get(e.to);
|
|
173
197
|
if (!targetPath)
|
|
174
198
|
continue; // target id not an indexed file (defensive)
|
|
175
|
-
addDependent(byFile, targetPath, shard.path, shard.file_id, mod?.name, mod?.imported ?? [], e.confidence);
|
|
199
|
+
addDependent(byFile, targetPath, shard.path, shard.file_id, mod?.name, mod?.imported ?? [], e.confidence, 'resolves_to', e.source?.line);
|
|
176
200
|
}
|
|
177
201
|
else {
|
|
178
|
-
addDependent(bySymbol, e.to, shard.path, shard.file_id, mod?.name, mod?.imported ?? [], e.confidence);
|
|
202
|
+
addDependent(bySymbol, e.to, shard.path, shard.file_id, mod?.name, mod?.imported ?? [], e.confidence, 'imports_symbol', e.source?.line);
|
|
179
203
|
}
|
|
180
204
|
}
|
|
181
205
|
}
|