smart-context-mcp 1.16.5 → 1.18.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/README.md +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/client-contract.js +5 -2
- package/src/explain/explainer.js +234 -0
- package/src/graph-paths.js +174 -0
- package/src/index-manager.js +21 -0
- package/src/index.js +127 -7
- package/src/orchestration/adapters/claude-adapter.js +86 -3
- package/src/orchestration/adapters/cursor-adapter.js +86 -3
- package/src/orchestration/policy/event-policy.js +28 -0
- package/src/review/heuristics.js +105 -0
- package/src/server.js +76 -11
- package/src/storage/sqlite.js +151 -1
- package/src/tools/smart-context.js +84 -1
- package/src/tools/smart-read.js +23 -3
- package/src/tools/smart-review.js +194 -0
- package/src/tools/smart-search.js +25 -17
- package/src/tools/smart-shell.js +116 -2
- package/src/tools/smart-summary.js +146 -11
- package/src/tools/smart-test.js +261 -0
- package/src/tools/smart-turn.js +107 -30
- package/src/turn/next-actions.js +136 -0
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ Restart your AI client. Done.
|
|
|
56
56
|
# Check installed version
|
|
57
57
|
npm list -g smart-context-mcp
|
|
58
58
|
|
|
59
|
-
# Should show: smart-context-mcp@1.
|
|
59
|
+
# Should show: smart-context-mcp@1.18.0 (or later)
|
|
60
60
|
|
|
61
61
|
# Update to latest version
|
|
62
62
|
npm update -g smart-context-mcp
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "smart-context-mcp",
|
|
3
3
|
"mcpName": "io.github.Arrayo/smart-context-mcp",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.18.0",
|
|
5
5
|
"description": "MCP server that reduces agent token usage by 90% with intelligent context compression, task checkpoint persistence, and workflow-aware agent guidance.",
|
|
6
6
|
"author": "Francisco Caballero Portero <fcp1978@hotmail.com>",
|
|
7
7
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/Arrayo/smart-context-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.18.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "smart-context-mcp",
|
|
14
|
-
"version": "1.
|
|
14
|
+
"version": "1.18.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
package/src/client-contract.js
CHANGED
|
@@ -52,8 +52,11 @@ export const buildRecommendedPathLines = (
|
|
|
52
52
|
lines.push(`${nextToolsLabel}: ${recommendedPath.nextTools.slice(0, 3).join(' -> ')}`);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
if (includePath
|
|
56
|
-
|
|
55
|
+
if (includePath) {
|
|
56
|
+
const pathInstruction = recommendedPath.steps?.[0]?.instruction ?? recommendedPath.next ?? null;
|
|
57
|
+
if (pathInstruction) {
|
|
58
|
+
lines.push(`${pathLabel}: ${truncate(pathInstruction, maxLength)}`);
|
|
59
|
+
}
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
return lines;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { loadIndex, queryIndex, queryRelated } from '../index.js';
|
|
5
|
+
import { projectRoot } from '../utils/paths.js';
|
|
6
|
+
import { getExplainCache, setExplainCache } from '../storage/sqlite.js';
|
|
7
|
+
import { countTokens } from '../tokenCounter.js';
|
|
8
|
+
|
|
9
|
+
const SIDE_EFFECT_PATTERNS = [
|
|
10
|
+
{ kind: 'io', re: /\b(fs|fsPromises)\.(read|write|append|unlink|mkdir|rm|stat|exists|copy|rename)/ },
|
|
11
|
+
{ kind: 'io', re: /\b(readFileSync|writeFileSync|appendFileSync|unlinkSync|mkdirSync|rmSync|statSync)\b/ },
|
|
12
|
+
{ kind: 'network', re: /\b(fetch|axios|http\.request|https\.request|XMLHttpRequest|WebSocket)\b/ },
|
|
13
|
+
{ kind: 'process', re: /\b(process\.(env|exit|kill|chdir)|child_process|execSync|spawnSync|execFile|spawn)\b/ },
|
|
14
|
+
{ kind: 'logging', re: /\bconsole\.(log|info|warn|error|debug)\b/ },
|
|
15
|
+
{ kind: 'mutation', re: /\b(this\.\w+\s*=|let\s+\w+\s*=|\w+\.push\(|\w+\.splice\(|delete\s+\w+\[)/ },
|
|
16
|
+
{ kind: 'throws', re: /\bthrow\s+(new\s+)?\w+/ },
|
|
17
|
+
{ kind: 'async', re: /\b(await|Promise\.(all|race|any|allSettled)|setTimeout|setInterval|setImmediate)\b/ },
|
|
18
|
+
{ kind: 'db', re: /\b(prepare|execute|query|transaction|raw|knex|pgp)\(/ },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const COMMENT_LINE_RE = /^\s*(?:\/\/|#|\*|\/\*\*|\/\*|"""|''')/;
|
|
22
|
+
const STRIPPED_COMMENT_RE = /^\s*(?:\/\/+|#+|\*+\/?|\/\*\*?|"""|''')\s?/;
|
|
23
|
+
|
|
24
|
+
const sha256 = (text) => createHash('sha256').update(text).digest('hex');
|
|
25
|
+
|
|
26
|
+
const stripCommentMarkers = (line) =>
|
|
27
|
+
line.replace(STRIPPED_COMMENT_RE, '').replace(/\*\/\s*$/, '').trim();
|
|
28
|
+
|
|
29
|
+
export const extractDocstring = (lines, signatureLineIndex) => {
|
|
30
|
+
if (signatureLineIndex <= 0) return '';
|
|
31
|
+
const docLines = [];
|
|
32
|
+
for (let i = signatureLineIndex - 1; i >= 0; i -= 1) {
|
|
33
|
+
const line = lines[i];
|
|
34
|
+
if (line == null) break;
|
|
35
|
+
if (line.trim() === '') {
|
|
36
|
+
if (docLines.length === 0) continue;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
if (!COMMENT_LINE_RE.test(line)) break;
|
|
40
|
+
docLines.unshift(stripCommentMarkers(line));
|
|
41
|
+
}
|
|
42
|
+
return docLines.join(' ').replace(/\s+/g, ' ').trim().slice(0, 280);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const extractFirstBodyLine = (lines, signatureLineIndex) => {
|
|
46
|
+
for (let i = signatureLineIndex + 1; i < lines.length && i < signatureLineIndex + 12; i += 1) {
|
|
47
|
+
const line = lines[i];
|
|
48
|
+
if (line == null) continue;
|
|
49
|
+
const trimmed = line.trim();
|
|
50
|
+
if (trimmed === '' || trimmed === '{' || COMMENT_LINE_RE.test(trimmed)) continue;
|
|
51
|
+
return trimmed.slice(0, 160);
|
|
52
|
+
}
|
|
53
|
+
return '';
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const detectSideEffects = (block) => {
|
|
57
|
+
const found = new Set();
|
|
58
|
+
for (const { kind, re } of SIDE_EFFECT_PATTERNS) {
|
|
59
|
+
if (re.test(block)) found.add(kind);
|
|
60
|
+
}
|
|
61
|
+
return [...found];
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const extractBlock = (lines, startLine, maxLines = 80) => {
|
|
65
|
+
const startIdx = Math.max(0, (startLine ?? 1) - 1);
|
|
66
|
+
const endIdx = Math.min(lines.length, startIdx + maxLines);
|
|
67
|
+
return {
|
|
68
|
+
block: lines.slice(startIdx, endIdx).join('\n'),
|
|
69
|
+
startIdx,
|
|
70
|
+
endIdx,
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const countCallers = (index, relPath, symbol) => {
|
|
75
|
+
if (!index) return 0;
|
|
76
|
+
const hits = queryIndex(index, symbol);
|
|
77
|
+
const related = queryRelated(index, relPath);
|
|
78
|
+
const callerFiles = new Set(related.importedBy);
|
|
79
|
+
const externalHits = hits.filter((h) => h.path !== relPath).length;
|
|
80
|
+
return callerFiles.size + externalHits;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const lookupSymbolMeta = (index, relPath, symbol) => {
|
|
84
|
+
if (!index) return null;
|
|
85
|
+
const hits = queryIndex(index, symbol);
|
|
86
|
+
const local = hits.find((h) => h.path === relPath);
|
|
87
|
+
return local ?? hits[0] ?? null;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const buildStructuralExplanation = ({
|
|
91
|
+
fullPath,
|
|
92
|
+
content,
|
|
93
|
+
symbol,
|
|
94
|
+
root = projectRoot,
|
|
95
|
+
index = null,
|
|
96
|
+
}) => {
|
|
97
|
+
const lines = content.split('\n');
|
|
98
|
+
const relPath = path.relative(root, fullPath).replace(/\\/g, '/');
|
|
99
|
+
const resolvedIndex = index ?? loadIndex(root);
|
|
100
|
+
const meta = lookupSymbolMeta(resolvedIndex, relPath, symbol);
|
|
101
|
+
|
|
102
|
+
if (!meta) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const { block, startIdx } = extractBlock(lines, meta.line, 80);
|
|
107
|
+
const signature = meta.signature ?? lines[startIdx]?.trim() ?? '';
|
|
108
|
+
const docstring = extractDocstring(lines, startIdx);
|
|
109
|
+
const firstBodyLine = extractFirstBodyLine(lines, startIdx);
|
|
110
|
+
const sideEffects = detectSideEffects(block);
|
|
111
|
+
const callers = countCallers(resolvedIndex, relPath, symbol);
|
|
112
|
+
const contentHash = sha256(block);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
symbol,
|
|
116
|
+
file: relPath,
|
|
117
|
+
line: meta.line,
|
|
118
|
+
kind: meta.kind ?? null,
|
|
119
|
+
parent: meta.parent ?? null,
|
|
120
|
+
signature: signature.slice(0, 200),
|
|
121
|
+
docstring,
|
|
122
|
+
firstBodyLine,
|
|
123
|
+
sideEffects,
|
|
124
|
+
callers,
|
|
125
|
+
contentHash,
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const formatExplanationText = (explanation) => {
|
|
130
|
+
const lines = [
|
|
131
|
+
`${explanation.symbol} (${explanation.kind ?? 'symbol'}) — ${explanation.file}:${explanation.line}`,
|
|
132
|
+
`signature: ${explanation.signature || '<unknown>'}`,
|
|
133
|
+
];
|
|
134
|
+
if (explanation.parent) lines.push(`parent: ${explanation.parent}`);
|
|
135
|
+
if (explanation.docstring) lines.push(`docs: ${explanation.docstring}`);
|
|
136
|
+
if (explanation.firstBodyLine) lines.push(`first body: ${explanation.firstBodyLine}`);
|
|
137
|
+
if (explanation.sideEffects.length > 0) lines.push(`side effects: ${explanation.sideEffects.join(', ')}`);
|
|
138
|
+
lines.push(`callers: ${explanation.callers}`);
|
|
139
|
+
return lines.join('\n');
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export const explainSymbol = async ({
|
|
143
|
+
fullPath,
|
|
144
|
+
content,
|
|
145
|
+
symbol,
|
|
146
|
+
root = projectRoot,
|
|
147
|
+
index = null,
|
|
148
|
+
useCache = true,
|
|
149
|
+
}) => {
|
|
150
|
+
const relPath = path.relative(root, fullPath).replace(/\\/g, '/');
|
|
151
|
+
const partial = buildStructuralExplanation({ fullPath, content, symbol, root, index });
|
|
152
|
+
|
|
153
|
+
if (!partial) {
|
|
154
|
+
return {
|
|
155
|
+
symbol,
|
|
156
|
+
file: relPath,
|
|
157
|
+
found: false,
|
|
158
|
+
text: `Symbol not found in index or content: ${symbol}`,
|
|
159
|
+
cached: false,
|
|
160
|
+
provider: 'structural',
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (useCache) {
|
|
165
|
+
try {
|
|
166
|
+
const cached = await getExplainCache({
|
|
167
|
+
relPath,
|
|
168
|
+
symbol,
|
|
169
|
+
contentHash: partial.contentHash,
|
|
170
|
+
});
|
|
171
|
+
if (cached?.explanation) {
|
|
172
|
+
const text = formatExplanationText(cached.explanation);
|
|
173
|
+
return {
|
|
174
|
+
...cached.explanation,
|
|
175
|
+
found: true,
|
|
176
|
+
text,
|
|
177
|
+
cached: true,
|
|
178
|
+
provider: cached.provider,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
// cache unavailable — fall through and recompute
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const text = formatExplanationText(partial);
|
|
187
|
+
const tokens = countTokens(text);
|
|
188
|
+
|
|
189
|
+
if (useCache) {
|
|
190
|
+
try {
|
|
191
|
+
await setExplainCache({
|
|
192
|
+
relPath,
|
|
193
|
+
symbol,
|
|
194
|
+
contentHash: partial.contentHash,
|
|
195
|
+
explanation: partial,
|
|
196
|
+
provider: 'structural',
|
|
197
|
+
tokens,
|
|
198
|
+
});
|
|
199
|
+
} catch {
|
|
200
|
+
// best-effort cache write
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
...partial,
|
|
206
|
+
found: true,
|
|
207
|
+
text,
|
|
208
|
+
cached: false,
|
|
209
|
+
provider: 'structural',
|
|
210
|
+
};
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export const explainSymbols = async ({ fullPath, content, symbols, root = projectRoot, index = null, useCache = true }) => {
|
|
214
|
+
const list = Array.isArray(symbols) ? symbols : [symbols];
|
|
215
|
+
const results = [];
|
|
216
|
+
for (const sym of list) {
|
|
217
|
+
if (!sym) continue;
|
|
218
|
+
results.push(await explainSymbol({ fullPath, content, symbol: sym, root, index, useCache }));
|
|
219
|
+
}
|
|
220
|
+
return results;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export const formatExplanationsAsText = (results) =>
|
|
224
|
+
results.map((r) => r.text).join('\n\n');
|
|
225
|
+
|
|
226
|
+
const fileExists = (p) => {
|
|
227
|
+
try {
|
|
228
|
+
return fs.existsSync(p);
|
|
229
|
+
} catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
export const __internal = { fileExists, formatExplanationText };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { queryIndex, queryRelated } from './index.js';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_MAX_HOPS = 5;
|
|
5
|
+
const NEAREST_LIMIT = 3;
|
|
6
|
+
|
|
7
|
+
const buildAdjacency = (index, { directed = false } = {}) => {
|
|
8
|
+
const adj = new Map();
|
|
9
|
+
const ensure = (key) => {
|
|
10
|
+
if (!adj.has(key)) adj.set(key, new Set());
|
|
11
|
+
return adj.get(key);
|
|
12
|
+
};
|
|
13
|
+
for (const edge of index?.graph?.edges ?? []) {
|
|
14
|
+
if (!edge?.from || !edge?.to) continue;
|
|
15
|
+
if (edge.kind && edge.kind !== 'import' && edge.kind !== 'testOf') continue;
|
|
16
|
+
ensure(edge.from).add(edge.to);
|
|
17
|
+
if (!directed) ensure(edge.to).add(edge.from);
|
|
18
|
+
}
|
|
19
|
+
return adj;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const resolveEntityToFiles = (index, entity) => {
|
|
23
|
+
if (!entity || typeof entity !== 'string') return [];
|
|
24
|
+
const normalized = entity.replace(/\\/g, '/').trim();
|
|
25
|
+
|
|
26
|
+
if (normalized.includes('/') || /\.[a-zA-Z0-9]+$/.test(normalized)) {
|
|
27
|
+
if (index?.files?.[normalized]) return [normalized];
|
|
28
|
+
const filesMap = index?.files ?? {};
|
|
29
|
+
const matches = Object.keys(filesMap).filter((rel) => rel.endsWith(`/${normalized}`) || rel === normalized);
|
|
30
|
+
return matches;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const hits = queryIndex(index, normalized);
|
|
34
|
+
return [...new Set(hits.map((h) => h.path))];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const reconstructPath = (parents, target) => {
|
|
38
|
+
const path = [target];
|
|
39
|
+
let cursor = target;
|
|
40
|
+
while (parents.has(cursor)) {
|
|
41
|
+
const prev = parents.get(cursor);
|
|
42
|
+
if (prev === null) break;
|
|
43
|
+
path.unshift(prev);
|
|
44
|
+
cursor = prev;
|
|
45
|
+
}
|
|
46
|
+
return path;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const findPath = (index, fromFile, toFile, { maxHops = DEFAULT_MAX_HOPS, directed = false } = {}) => {
|
|
50
|
+
if (!index?.graph?.edges) return null;
|
|
51
|
+
if (!fromFile || !toFile) return null;
|
|
52
|
+
if (fromFile === toFile) return { hops: 0, path: [fromFile] };
|
|
53
|
+
|
|
54
|
+
const adj = buildAdjacency(index, { directed });
|
|
55
|
+
if (!adj.has(fromFile) && !adj.has(toFile)) return null;
|
|
56
|
+
|
|
57
|
+
const visited = new Map();
|
|
58
|
+
visited.set(fromFile, 0);
|
|
59
|
+
const parents = new Map([[fromFile, null]]);
|
|
60
|
+
const queue = [fromFile];
|
|
61
|
+
|
|
62
|
+
while (queue.length > 0) {
|
|
63
|
+
const current = queue.shift();
|
|
64
|
+
const depth = visited.get(current);
|
|
65
|
+
if (depth >= maxHops) continue;
|
|
66
|
+
|
|
67
|
+
const neighbors = adj.get(current) ?? new Set();
|
|
68
|
+
for (const next of neighbors) {
|
|
69
|
+
if (visited.has(next)) continue;
|
|
70
|
+
visited.set(next, depth + 1);
|
|
71
|
+
parents.set(next, current);
|
|
72
|
+
if (next === toFile) {
|
|
73
|
+
return { hops: depth + 1, path: reconstructPath(parents, toFile) };
|
|
74
|
+
}
|
|
75
|
+
queue.push(next);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return null;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const collectNearestNeighbors = (index, file, limit = NEAREST_LIMIT) => {
|
|
83
|
+
const result = new Map();
|
|
84
|
+
if (!file || !index) return [];
|
|
85
|
+
|
|
86
|
+
const related = queryRelated(index, file);
|
|
87
|
+
for (const rel of [...related.imports, ...related.importedBy, ...related.tests, ...related.neighbors]) {
|
|
88
|
+
if (rel === file) continue;
|
|
89
|
+
if (!result.has(rel)) result.set(rel, result.size);
|
|
90
|
+
if (result.size >= limit) break;
|
|
91
|
+
}
|
|
92
|
+
return [...result.keys()];
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const findNearest = (index, fromFile, toFile, limit = NEAREST_LIMIT) => ({
|
|
96
|
+
fromNeighbors: collectNearestNeighbors(index, fromFile, limit),
|
|
97
|
+
toNeighbors: collectNearestNeighbors(index, toFile, limit),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const symbolForPathStep = (index, relPath, symbolName) => {
|
|
101
|
+
const filesMap = index?.files ?? {};
|
|
102
|
+
const entry = filesMap[relPath];
|
|
103
|
+
if (!entry?.symbols) return null;
|
|
104
|
+
if (symbolName) {
|
|
105
|
+
return entry.symbols.find((s) => s.name?.toLowerCase() === symbolName.toLowerCase()) ?? null;
|
|
106
|
+
}
|
|
107
|
+
return entry.symbols.find((s) => s.kind === 'function' || s.kind === 'class' || s.kind === 'const')
|
|
108
|
+
?? entry.symbols[0]
|
|
109
|
+
?? null;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const describePath = (index, pathFiles, { hintSymbols = {} } = {}) => {
|
|
113
|
+
if (!Array.isArray(pathFiles) || pathFiles.length === 0) return [];
|
|
114
|
+
return pathFiles.map((rel) => {
|
|
115
|
+
const sym = symbolForPathStep(index, rel, hintSymbols[rel]);
|
|
116
|
+
return {
|
|
117
|
+
file: rel,
|
|
118
|
+
symbol: sym?.name ?? null,
|
|
119
|
+
signature: sym?.signature ?? null,
|
|
120
|
+
line: sym?.line ?? null,
|
|
121
|
+
kind: sym?.kind ?? null,
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const buildPathsResult = (index, from, to, options = {}) => {
|
|
127
|
+
const fromFiles = resolveEntityToFiles(index, from);
|
|
128
|
+
const toFiles = resolveEntityToFiles(index, to);
|
|
129
|
+
|
|
130
|
+
if (fromFiles.length === 0 || toFiles.length === 0) {
|
|
131
|
+
return {
|
|
132
|
+
from,
|
|
133
|
+
to,
|
|
134
|
+
resolved: { from: fromFiles, to: toFiles },
|
|
135
|
+
found: false,
|
|
136
|
+
reason: fromFiles.length === 0 ? 'from-not-found' : 'to-not-found',
|
|
137
|
+
path: [],
|
|
138
|
+
hops: null,
|
|
139
|
+
fallback: null,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const f of fromFiles) {
|
|
144
|
+
for (const t of toFiles) {
|
|
145
|
+
const result = findPath(index, f, t, options);
|
|
146
|
+
if (result) {
|
|
147
|
+
return {
|
|
148
|
+
from,
|
|
149
|
+
to,
|
|
150
|
+
resolved: { from: [f], to: [t] },
|
|
151
|
+
found: true,
|
|
152
|
+
hops: result.hops,
|
|
153
|
+
path: describePath(index, result.path),
|
|
154
|
+
fallback: null,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const seedFrom = fromFiles[0];
|
|
161
|
+
const seedTo = toFiles[0];
|
|
162
|
+
const fallback = findNearest(index, seedFrom, seedTo, options.nearestLimit ?? NEAREST_LIMIT);
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
from,
|
|
166
|
+
to,
|
|
167
|
+
resolved: { from: [seedFrom], to: [seedTo] },
|
|
168
|
+
found: false,
|
|
169
|
+
reason: 'no-path',
|
|
170
|
+
path: [],
|
|
171
|
+
hops: null,
|
|
172
|
+
fallback,
|
|
173
|
+
};
|
|
174
|
+
};
|
package/src/index-manager.js
CHANGED
|
@@ -128,3 +128,24 @@ export const getIndexStatus = (root = projectRoot) => {
|
|
|
128
128
|
age: meta?.builtAt ? Date.now() - meta.builtAt : null
|
|
129
129
|
};
|
|
130
130
|
};
|
|
131
|
+
|
|
132
|
+
let backgroundBuildPromise = null;
|
|
133
|
+
|
|
134
|
+
export const triggerBackgroundIndexBuild = ({ root = projectRoot, timeoutMs = INDEX_BUILD_TIMEOUT_MS } = {}) => {
|
|
135
|
+
if (backgroundBuildPromise) {
|
|
136
|
+
return backgroundBuildPromise;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const status = getIndexStatus(root);
|
|
140
|
+
if (status.available && status.fresh) {
|
|
141
|
+
return Promise.resolve({ status: 'fresh', cached: true });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
backgroundBuildPromise = ensureIndexReady({ root, timeoutMs })
|
|
145
|
+
.catch((error) => ({ status: 'error', error: error?.message ?? String(error) }))
|
|
146
|
+
.finally(() => {
|
|
147
|
+
backgroundBuildPromise = null;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return backgroundBuildPromise;
|
|
151
|
+
};
|
package/src/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import ts from 'typescript';
|
|
|
5
5
|
import { isBinaryBuffer } from './utils/fs.js';
|
|
6
6
|
import { IGNORED_DIRS } from './config/ignored-paths.js';
|
|
7
7
|
|
|
8
|
-
const INDEX_VERSION =
|
|
8
|
+
const INDEX_VERSION = 6;
|
|
9
9
|
|
|
10
10
|
const MAX_SIGNATURE_LEN = 200;
|
|
11
11
|
const MAX_SNIPPET_LEN = 280;
|
|
@@ -60,8 +60,15 @@ const indexableExtensions = new Set([
|
|
|
60
60
|
'.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
|
|
61
61
|
'.py', '.go', '.rs', '.java',
|
|
62
62
|
'.cs', '.kt', '.php', '.swift',
|
|
63
|
+
'.md', '.markdown',
|
|
63
64
|
]);
|
|
64
65
|
|
|
66
|
+
const isIndexableMarkdownFile = (fullPath) => {
|
|
67
|
+
const ext = path.extname(fullPath).toLowerCase();
|
|
68
|
+
if (ext !== '.md' && ext !== '.markdown') return false;
|
|
69
|
+
return isAdrPath(fullPath);
|
|
70
|
+
};
|
|
71
|
+
|
|
65
72
|
const ignoredDirs = new Set(IGNORED_DIRS);
|
|
66
73
|
|
|
67
74
|
const scriptKindByExtension = {
|
|
@@ -564,6 +571,105 @@ const extractSwiftImports = (content) => {
|
|
|
564
571
|
return { imports, exports: [] };
|
|
565
572
|
};
|
|
566
573
|
|
|
574
|
+
// ---------------------------------------------------------------------------
|
|
575
|
+
// ADR / Spec / Architecture markdown parser
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
|
|
578
|
+
const ADR_PATH_RE = /(?:^|\/)(?:adrs?|decisions|architecture|design-docs)\//i;
|
|
579
|
+
const ADR_FILENAME_RE = /^(?:adr[-_]?\d{0,4}.*|\d{3,4}[-_].*|SPEC|ARCHITECTURE|DESIGN|RFC[-_]?\d*.*)\.(?:md|markdown)$/i;
|
|
580
|
+
const ADR_STATUS_RE = /^\s*[*_>-]{0,3}\s*status\s*[*_]{0,2}\s*[:=]?\s*[*_]{0,2}\s*([A-Za-z][A-Za-z -]+)/i;
|
|
581
|
+
const MARKDOWN_HEADING_RE = /^(#{1,6})\s+(.+?)\s*$/;
|
|
582
|
+
const ADR_VALID_STATUS = new Set([
|
|
583
|
+
'proposed', 'draft', 'rejected', 'accepted', 'deprecated',
|
|
584
|
+
'superseded', 'amended', 'approved', 'in review', 'in-review',
|
|
585
|
+
]);
|
|
586
|
+
|
|
587
|
+
export const isAdrPath = (relPath) => {
|
|
588
|
+
if (!relPath) return false;
|
|
589
|
+
const norm = relPath.replace(/\\/g, '/');
|
|
590
|
+
if (ADR_PATH_RE.test(norm)) return true;
|
|
591
|
+
const base = path.basename(norm);
|
|
592
|
+
return ADR_FILENAME_RE.test(base);
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
const slugify = (text) => text.trim()
|
|
596
|
+
.toLowerCase()
|
|
597
|
+
.replace(/[^\w\s-]/g, '')
|
|
598
|
+
.replace(/\s+/g, '-')
|
|
599
|
+
.replace(/-+/g, '-')
|
|
600
|
+
.replace(/^-|-$/g, '')
|
|
601
|
+
.slice(0, 60);
|
|
602
|
+
|
|
603
|
+
const extractAdrStatus = (lines, headingLine) => {
|
|
604
|
+
const start = Math.max(0, headingLine - 1);
|
|
605
|
+
const end = Math.min(lines.length, start + 20);
|
|
606
|
+
for (let i = start; i < end; i += 1) {
|
|
607
|
+
const m = ADR_STATUS_RE.exec(lines[i]);
|
|
608
|
+
if (!m) continue;
|
|
609
|
+
const value = m[1].trim().toLowerCase().replace(/\s+/g, '-');
|
|
610
|
+
if (ADR_VALID_STATUS.has(value) || ADR_VALID_STATUS.has(value.replace('-', ' '))) {
|
|
611
|
+
return value;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return null;
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
export const extractAdrSymbols = (content, fullPath) => {
|
|
618
|
+
const symbols = [];
|
|
619
|
+
const lines = content.split('\n');
|
|
620
|
+
let titleSet = false;
|
|
621
|
+
|
|
622
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
623
|
+
const headingMatch = MARKDOWN_HEADING_RE.exec(lines[i]);
|
|
624
|
+
if (!headingMatch) continue;
|
|
625
|
+
|
|
626
|
+
const level = headingMatch[1].length;
|
|
627
|
+
const titleText = headingMatch[2].trim();
|
|
628
|
+
if (!titleText) continue;
|
|
629
|
+
|
|
630
|
+
if (!titleSet && level === 1) {
|
|
631
|
+
const name = slugify(titleText) || `adr-${path.basename(fullPath).toLowerCase()}`;
|
|
632
|
+
const status = extractAdrStatus(lines, i + 1);
|
|
633
|
+
const signature = `# ${titleText}${status ? ` (status: ${status})` : ''}`;
|
|
634
|
+
symbols.push({
|
|
635
|
+
name,
|
|
636
|
+
kind: 'adr',
|
|
637
|
+
line: i + 1,
|
|
638
|
+
signature: trimSignature(signature),
|
|
639
|
+
snippet: trimSnippet(titleText),
|
|
640
|
+
...(status ? { status } : {}),
|
|
641
|
+
title: titleText,
|
|
642
|
+
});
|
|
643
|
+
titleSet = true;
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (level === 2 || level === 3) {
|
|
648
|
+
const sectionSlug = slugify(titleText);
|
|
649
|
+
if (!sectionSlug) continue;
|
|
650
|
+
symbols.push({
|
|
651
|
+
name: sectionSlug,
|
|
652
|
+
kind: 'adr-section',
|
|
653
|
+
line: i + 1,
|
|
654
|
+
signature: trimSignature(`${headingMatch[1]} ${titleText}`),
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (!titleSet) {
|
|
660
|
+
const base = path.basename(fullPath).replace(/\.[^.]+$/, '');
|
|
661
|
+
symbols.unshift({
|
|
662
|
+
name: slugify(base) || 'adr',
|
|
663
|
+
kind: 'adr',
|
|
664
|
+
line: 1,
|
|
665
|
+
signature: trimSignature(`# ${base}`),
|
|
666
|
+
title: base,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return symbols;
|
|
671
|
+
};
|
|
672
|
+
|
|
567
673
|
// ---------------------------------------------------------------------------
|
|
568
674
|
// Unified file info extraction
|
|
569
675
|
// ---------------------------------------------------------------------------
|
|
@@ -591,6 +697,7 @@ const extractFileInfo = (fullPath, content) => {
|
|
|
591
697
|
else if (ext === '.kt') info = { symbols: extractKotlinSymbols(content), ...extractKotlinImports(content) };
|
|
592
698
|
else if (ext === '.php') info = { symbols: extractPhpSymbols(content), ...extractPhpImports(content) };
|
|
593
699
|
else if (ext === '.swift') info = { symbols: extractSwiftSymbols(content), ...extractSwiftImports(content) };
|
|
700
|
+
else if ((ext === '.md' || ext === '.markdown') && isAdrPath(fullPath)) info = { symbols: extractAdrSymbols(content, fullPath), imports: [], exports: [] };
|
|
594
701
|
else info = { symbols: [], imports: [], exports: [] };
|
|
595
702
|
|
|
596
703
|
return {
|
|
@@ -672,7 +779,10 @@ const walkForIndex = (dir, files = []) => {
|
|
|
672
779
|
|
|
673
780
|
if (entry.isDirectory()) {
|
|
674
781
|
walkForIndex(fullPath, files);
|
|
675
|
-
} else
|
|
782
|
+
} else {
|
|
783
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
784
|
+
if (!indexableExtensions.has(ext)) continue;
|
|
785
|
+
if ((ext === '.md' || ext === '.markdown') && !isIndexableMarkdownFile(fullPath)) continue;
|
|
676
786
|
files.push(fullPath);
|
|
677
787
|
}
|
|
678
788
|
}
|
|
@@ -722,8 +832,6 @@ export const buildIndex = (root, progress = null) => {
|
|
|
722
832
|
if (!invertedIndex[key]) invertedIndex[key] = [];
|
|
723
833
|
const entry = { path: relPath, line: sym.line, kind: sym.kind };
|
|
724
834
|
if (sym.parent) entry.parent = sym.parent;
|
|
725
|
-
if (sym.signature) entry.signature = sym.signature;
|
|
726
|
-
if (sym.snippet) entry.snippet = sym.snippet;
|
|
727
835
|
invertedIndex[key].push(entry);
|
|
728
836
|
}
|
|
729
837
|
} catch { /* unreadable */ }
|
|
@@ -783,10 +891,24 @@ export const buildIndex = (root, progress = null) => {
|
|
|
783
891
|
// Query helpers
|
|
784
892
|
// ---------------------------------------------------------------------------
|
|
785
893
|
|
|
894
|
+
const findSymbolForHit = (index, key, hit) => {
|
|
895
|
+
const symbols = index.files?.[hit.path]?.symbols;
|
|
896
|
+
if (!Array.isArray(symbols)) return null;
|
|
897
|
+
return symbols.find((sym) => sym.name?.toLowerCase() === key && sym.line === hit.line) ?? null;
|
|
898
|
+
};
|
|
899
|
+
|
|
786
900
|
export const queryIndex = (index, symbolName) => {
|
|
787
901
|
if (!index?.invertedIndex) return [];
|
|
788
902
|
const key = symbolName.toLowerCase();
|
|
789
|
-
|
|
903
|
+
const hits = index.invertedIndex[key] ?? [];
|
|
904
|
+
return hits.map((hit) => {
|
|
905
|
+
const sym = findSymbolForHit(index, key, hit);
|
|
906
|
+
if (!sym) return hit;
|
|
907
|
+
const enriched = { ...hit };
|
|
908
|
+
if (sym.signature && !enriched.signature) enriched.signature = sym.signature;
|
|
909
|
+
if (sym.snippet && !enriched.snippet) enriched.snippet = sym.snippet;
|
|
910
|
+
return enriched;
|
|
911
|
+
});
|
|
790
912
|
};
|
|
791
913
|
|
|
792
914
|
export const queryRelated = (index, relPath) => {
|
|
@@ -873,8 +995,6 @@ export const reindexFile = (index, root, relPath) => {
|
|
|
873
995
|
if (!index.invertedIndex[key]) index.invertedIndex[key] = [];
|
|
874
996
|
const invEntry = { path: relPath, line: sym.line, kind: sym.kind };
|
|
875
997
|
if (sym.parent) invEntry.parent = sym.parent;
|
|
876
|
-
if (sym.signature) invEntry.signature = sym.signature;
|
|
877
|
-
if (sym.snippet) invEntry.snippet = sym.snippet;
|
|
878
998
|
index.invertedIndex[key].push(invEntry);
|
|
879
999
|
}
|
|
880
1000
|
|