knodin 0.7.5 → 0.8.2
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 +18 -3
- package/benchmarks/competitors/SYNTHESIS.md +66 -0
- package/dist/bin/cli.js +371 -66
- package/dist/bin/launcher.js +16 -1
- package/dist/src/agent-integration.js +82 -16
- package/dist/src/artifact-refresh.js +2 -1
- package/dist/src/cli-args.js +19 -1
- package/dist/src/cli-model.js +28 -2
- package/dist/src/codeflow-replay.js +2 -1
- package/dist/src/compare.js +39 -0
- package/dist/src/competitive-constraints.js +2 -1
- package/dist/src/competitive-runner.js +4 -4
- package/dist/src/context-export.js +3 -2
- package/dist/src/context.js +1 -1
- package/dist/src/deterministic-random.js +34 -0
- package/dist/src/diagnostics-write-helper.js +473 -0
- package/dist/src/diagnostics.js +1160 -133
- package/dist/src/doctor.js +3 -1
- package/dist/src/engine/ann-hnsw.js +2 -12
- package/dist/src/engine/file-walker.js +8 -2
- package/dist/src/engine/git-history.js +12 -12
- package/dist/src/engine/index.js +1174 -313
- package/dist/src/engine/sarif-import.js +341 -0
- package/dist/src/engine/scip-import.js +28 -13
- package/dist/src/engine/source-policy.js +16 -0
- package/dist/src/engine/state-paths.js +175 -0
- package/dist/src/execution-profile.js +15 -10
- package/dist/src/failure-diagnosis.js +7 -1
- package/dist/src/graph-layout.js +173 -0
- package/dist/src/index-activity.js +2 -1
- package/dist/src/init.js +86 -45
- package/dist/src/lifecycle-health.js +41 -9
- package/dist/src/mcp-graph-worker.js +69 -0
- package/dist/src/mcp-reliability.js +154 -0
- package/dist/src/mcp-worker-supervisor.js +350 -0
- package/dist/src/mirror.js +290 -0
- package/dist/src/node-runtime.js +157 -0
- package/dist/src/output-compression.js +2 -1
- package/dist/src/output-telemetry.js +16 -11
- package/dist/src/progressive-evidence.js +30 -26
- package/dist/src/pure-compression-cli.js +4 -3
- package/dist/src/relationship-adapters.js +15 -8
- package/dist/src/release-preflight.js +13 -10
- package/dist/src/repair-lease.js +85 -0
- package/dist/src/repository-init-process.js +13 -9
- package/dist/src/repository-management.js +34 -4
- package/dist/src/response-budget.js +8 -6
- package/dist/src/server.js +80 -35
- package/dist/src/structural-fast-path.js +16 -10
- package/dist/src/structural-snapshot.js +6 -2
- package/dist/src/system-config.js +25 -2
- package/dist/src/tools/knodin-tools.js +142 -31
- package/dist/src/update-ceremony.js +9 -5
- package/dist/src/update-trust.js +5 -4
- package/dist/src/visualization.js +372 -19
- package/dist/src/worktree-lifecycle.js +5 -2
- package/docs/BEHAVIORAL-CONTRACT.md +72 -0
- package/docs/CLI.md +20 -1
- package/docs/COMPARISON.md +403 -0
- package/docs/COMPETITIVE-LANDSCAPE-2026-08.md +267 -0
- package/docs/DIAGNOSTICS.md +46 -11
- package/docs/HANDOFF.md +180 -0
- package/docs/INSTALLATION.md +21 -2
- package/docs/MCP.md +59 -8
- package/docs/PT-ACCESS-RECOMMENDATION.md +5 -7
- package/docs/REPOSITORIES-AND-WORKTREES.md +18 -6
- package/docs/SCIP-IMPORT.md +5 -0
- package/docs/TOKEN-OPTIMIZER-SCORECARD.md +79 -0
- package/docs/releases/0.5.1.md +4 -4
- package/docs/releases/0.8.0.md +74 -0
- package/docs/releases/0.8.2.md +34 -0
- package/package.json +17 -4
- package/roadmap/competitive-roadmap.md +3801 -0
- package/schemas/release-attestation-v1.schema.json +1 -1
- package/schemas/support-bundle-v2.schema.json +212 -0
package/dist/src/engine/index.js
CHANGED
|
@@ -18,8 +18,10 @@ import path from "node:path";
|
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import chokidar from "chokidar";
|
|
20
20
|
import Parser from "web-tree-sitter";
|
|
21
|
+
import { compareBytes } from "../compare.js";
|
|
21
22
|
import { readIndexActivity } from "../index-activity.js";
|
|
22
23
|
import { runReadOnlyLspQuery } from "../lsp-readonly.js";
|
|
24
|
+
import { acquireRepairLease } from "../repair-lease.js";
|
|
23
25
|
import { contentFingerprint, writeStructuralSnapshot, } from "../structural-snapshot.js";
|
|
24
26
|
import { KNODIN_VERSION } from "../version.js";
|
|
25
27
|
import * as ann from "./ann-hnsw.js";
|
|
@@ -28,9 +30,11 @@ import { walkRepoFiles } from "./file-walker.js";
|
|
|
28
30
|
import { clearGitHistorySignalCache, collectGitHistorySignals, } from "./git-history.js";
|
|
29
31
|
import { beginPerfPhase, measurePerfPhase, measurePerfPhaseSync } from "./perf.js";
|
|
30
32
|
import { isIndexablePath, makeWatchIgnorePredicate } from "./prune.js";
|
|
33
|
+
import { readSarifLog, SARIF_DEFAULT_LIMITS, } from "./sarif-import.js";
|
|
31
34
|
import { readScipIndex, SCIP_DEFAULT_LIMITS, } from "./scip-import.js";
|
|
32
35
|
import { isIndexableSourcePath } from "./source-policy.js";
|
|
33
36
|
import { Database } from "./sqlite.js";
|
|
37
|
+
import { lookupMirror, mayWriteToRepository, resolveDbPath, resolveStateDir, } from "./state-paths.js";
|
|
34
38
|
import { deleteAllSymbols, deleteSymbolsForFile, deleteSymbolsMatchingPath, ORPHANED_EMBEDDING_PREDICATE, purgeOrphanEmbeddings, } from "./symbol-delete.js";
|
|
35
39
|
// ES Module resolution
|
|
36
40
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -69,7 +73,7 @@ export const REPO_WIDE_QUERY_PATTERNS = [
|
|
|
69
73
|
"api_contract_mismatches",
|
|
70
74
|
];
|
|
71
75
|
const unquote = (value) => {
|
|
72
|
-
const match =
|
|
76
|
+
const match = /^(["'`])([\s\S]*?)\1$/.exec(value.trim());
|
|
73
77
|
return match && !(match[1] === "\x60" && match[2].includes("${")) ? match[2] : undefined;
|
|
74
78
|
};
|
|
75
79
|
function isExecutablePosition(source, target) {
|
|
@@ -106,7 +110,7 @@ function isExecutablePosition(source, target) {
|
|
|
106
110
|
blockComment = true;
|
|
107
111
|
i++;
|
|
108
112
|
}
|
|
109
|
-
else if (char === '"' || char === "'" || char.
|
|
113
|
+
else if (char === '"' || char === "'" || char.codePointAt(0) === 96)
|
|
110
114
|
quote = char;
|
|
111
115
|
}
|
|
112
116
|
return !quote && !lineComment && !blockComment;
|
|
@@ -139,11 +143,94 @@ function splitCallArguments(value) {
|
|
|
139
143
|
result.push(value.slice(start).trim());
|
|
140
144
|
return result;
|
|
141
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Masks block comments, replacing each with `mask(comment)`.
|
|
148
|
+
*
|
|
149
|
+
* This deliberately does not use a regular expression. The pattern it replaces,
|
|
150
|
+
* `/\/\*[\s\S]*?\*\//g`, is quadratic on source containing many unclosed `/*`:
|
|
151
|
+
* a global regex restarts at every position where `/*` could begin, and each
|
|
152
|
+
* attempt lazily expands to end of input hunting a `*/` that never arrives.
|
|
153
|
+
* Measured at ~4x cost per doubling of input, roughly 50s on a 1 MB file.
|
|
154
|
+
*
|
|
155
|
+
* The obvious regex repair — the "unrolled loop" form
|
|
156
|
+
* `/\/\*[^*]*\*+(?:[^/*][^*]*\*+)*\//g` — was measured and is *also* quadratic,
|
|
157
|
+
* and about 12x slower again, because its trailing group backtracks through the
|
|
158
|
+
* whole input before failing. Reasoning about regex-engine behaviour was how
|
|
159
|
+
* this bug survived; indexOf advances monotonically, so linearity here is a
|
|
160
|
+
* property of the code rather than a claim about the engine.
|
|
161
|
+
*
|
|
162
|
+
* Behaviour matches the pattern it replaces, including leaving an unterminated
|
|
163
|
+
* `/*` and everything after it untouched.
|
|
164
|
+
*/
|
|
165
|
+
function maskBlockComments(content, mask) {
|
|
166
|
+
let open = content.indexOf("/*");
|
|
167
|
+
if (open === -1)
|
|
168
|
+
return content;
|
|
169
|
+
let out = "";
|
|
170
|
+
let pos = 0;
|
|
171
|
+
while (open !== -1) {
|
|
172
|
+
const close = content.indexOf("*/", open + 2);
|
|
173
|
+
// Nothing closes after this point, so no later `/*` can match either.
|
|
174
|
+
if (close === -1)
|
|
175
|
+
break;
|
|
176
|
+
out += content.slice(pos, open) + mask(content.slice(open, close + 2));
|
|
177
|
+
pos = close + 2;
|
|
178
|
+
open = content.indexOf("/*", pos);
|
|
179
|
+
}
|
|
180
|
+
return pos === 0 ? content : out + content.slice(pos);
|
|
181
|
+
}
|
|
182
|
+
/** Earliest of two `indexOf` results, or -1 when neither matched. */
|
|
183
|
+
function earliestIndex(a, b) {
|
|
184
|
+
if (a === -1)
|
|
185
|
+
return b;
|
|
186
|
+
if (b === -1)
|
|
187
|
+
return a;
|
|
188
|
+
return Math.min(a, b);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Removes block and line comments in one left-to-right pass.
|
|
192
|
+
*
|
|
193
|
+
* Kept as a single pass rather than two because the alternation this replaces,
|
|
194
|
+
* `/\/\*[\s\S]*?\*\/|\/\/.*$/gm`, prefers a block comment only where one starts
|
|
195
|
+
* at the current position. Stripping block comments first and line comments
|
|
196
|
+
* second is not equivalent: a line comment containing `/*` would let the block
|
|
197
|
+
* pass consume everything up to a later `*/`, deleting real code between them.
|
|
198
|
+
*/
|
|
199
|
+
function stripBlockAndLineComments(content) {
|
|
200
|
+
let out = "";
|
|
201
|
+
let pos = 0;
|
|
202
|
+
let blockPossible = true;
|
|
203
|
+
for (;;) {
|
|
204
|
+
const block = blockPossible ? content.indexOf("/*", pos) : -1;
|
|
205
|
+
const line = content.indexOf("//", pos);
|
|
206
|
+
const next = earliestIndex(block, line);
|
|
207
|
+
if (next === -1)
|
|
208
|
+
break;
|
|
209
|
+
if (next === block) {
|
|
210
|
+
const close = content.indexOf("*/", block + 2);
|
|
211
|
+
if (close === -1) {
|
|
212
|
+
// No block comment closes anywhere after here; keep looking for
|
|
213
|
+
// line comments, which is what the alternation would go on to do.
|
|
214
|
+
blockPossible = false;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
out += content.slice(pos, block);
|
|
218
|
+
pos = close + 2;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
// `$` under /m stops before the newline, so the newline itself survives.
|
|
222
|
+
const eol = content.indexOf("\n", line);
|
|
223
|
+
out += content.slice(pos, line);
|
|
224
|
+
pos = eol === -1 ? content.length : eol;
|
|
225
|
+
}
|
|
226
|
+
return pos === 0 ? content : out + content.slice(pos);
|
|
227
|
+
}
|
|
142
228
|
/** Extract executable TypeScript MCP SDK registrations; comments are masked first. */
|
|
143
229
|
export function extractMcpToolRegistrations(source, file, root) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
230
|
+
// `[^\S\n]` (whitespace except newline) rather than `\s`: `\s` overlaps the
|
|
231
|
+
// `(^|\n)` before it, so every start position in a run of blank lines
|
|
232
|
+
// backtracks through the whole run — super-linear on blank-line-heavy files.
|
|
233
|
+
const clean = maskBlockComments(source, (m) => m.replace(/[^\n]/g, " ")).replace(/(^|\n)[^\S\n]*\/\/[^\n]*/g, (m) => m.replace(/[^\n]/g, " "));
|
|
147
234
|
const rows = [];
|
|
148
235
|
const astCallStarts = new Set();
|
|
149
236
|
const astCalls = [];
|
|
@@ -228,9 +315,9 @@ export function extractMcpToolRegistrations(source, file, root) {
|
|
|
228
315
|
? astCalls.find((call) => /setRequestHandler\s*\(\s*CallToolRequestSchema/.test(call))
|
|
229
316
|
: clean;
|
|
230
317
|
if (callToolHandler && /setRequestHandler\s*\(\s*CallToolRequestSchema/.test(callToolHandler)) {
|
|
231
|
-
const name =
|
|
232
|
-
const handler =
|
|
233
|
-
const factory =
|
|
318
|
+
const name = /\bname\s*!==?\s*(["'`])([^"'`]+)\1/.exec(callToolHandler)?.[2];
|
|
319
|
+
const handler = /await\s+([\w$]*(?:handle|dispatch)[\w$]*)\s*\(\s*args/i.exec(callToolHandler)?.[1];
|
|
320
|
+
const factory = /setRequestHandler\s*\(\s*ListToolsRequestSchema[\s\S]*?tools\s*:\s*(\w*Tools)\s*\(/.exec(source)?.[1];
|
|
234
321
|
if (name && handler)
|
|
235
322
|
rows.push({
|
|
236
323
|
name,
|
|
@@ -243,7 +330,7 @@ export function extractMcpToolRegistrations(source, file, root) {
|
|
|
243
330
|
}
|
|
244
331
|
// Descriptor objects are accepted only inside an explicit Tool[] factory,
|
|
245
332
|
// never from unrelated fixture/config objects in an SDK-importing file.
|
|
246
|
-
const descriptorFunctionMatch =
|
|
333
|
+
const descriptorFunctionMatch = /(?:export\s+)?function\s+(\w*Tools)\s*\([^)]*\)\s*:\s*Tool\[\]\s*\{([\s\S]*?)\n\}/.exec(clean);
|
|
247
334
|
const descriptorFunction = descriptorFunctionMatch?.[2];
|
|
248
335
|
const descriptorScopes = root
|
|
249
336
|
? [
|
|
@@ -254,11 +341,11 @@ export function extractMcpToolRegistrations(source, file, root) {
|
|
|
254
341
|
? [descriptorFunction]
|
|
255
342
|
: [];
|
|
256
343
|
for (const descriptorScope of descriptorScopes) {
|
|
257
|
-
const scopeAssociationKey =
|
|
258
|
-
|
|
344
|
+
const scopeAssociationKey = /function\s+(\w*Tools)\b/.exec(descriptorScope)?.[1] ??
|
|
345
|
+
/tools\s*:\s*(\w*Tools)\s*\(/.exec(descriptorScope)?.[1] ??
|
|
259
346
|
`file:${file}`;
|
|
260
347
|
for (const match of descriptorScope.matchAll(/\bname\s*:\s*(["'`])([^"'`]+)\1\s*,([\s\S]{0,30000}?)\binputSchema\s*:/g)) {
|
|
261
|
-
const description = unquote(
|
|
348
|
+
const description = unquote(/\bdescription\s*:\s*((?:["'`])[^"'`]*(?:["'`]))/.exec(match[3])?.[1] ?? "");
|
|
262
349
|
rows.push({
|
|
263
350
|
name: match[2],
|
|
264
351
|
description,
|
|
@@ -272,6 +359,8 @@ export function extractMcpToolRegistrations(source, file, root) {
|
|
|
272
359
|
}
|
|
273
360
|
return rows;
|
|
274
361
|
}
|
|
362
|
+
/** `meta` key holding the last index's unparsed-file tally, as a JSON object. */
|
|
363
|
+
export const COVERAGE_UNPARSED_META_KEY = "coverageUnparsedByExtension";
|
|
275
364
|
function repairMetadataFamily(filePath) {
|
|
276
365
|
const segments = filePath.replaceAll("\\", "/").split("/");
|
|
277
366
|
const extension = path.extname(filePath).toLowerCase();
|
|
@@ -337,7 +426,7 @@ function symbolIdentity(repoPath, row) {
|
|
|
337
426
|
}
|
|
338
427
|
const SQLITE_SCOPE_CHUNK_SIZE = 400;
|
|
339
428
|
function scopedFilePaths(filePaths) {
|
|
340
|
-
return filePaths === undefined ? undefined : [...new Set(filePaths)].sort();
|
|
429
|
+
return filePaths === undefined ? undefined : [...new Set(filePaths)].sort(compareBytes);
|
|
341
430
|
}
|
|
342
431
|
function symbolsForFiles(db, filePaths) {
|
|
343
432
|
if (filePaths === undefined) {
|
|
@@ -410,8 +499,18 @@ function persistSymbolIdentities(db, repoPath, filePaths) {
|
|
|
410
499
|
.slice(Math.max(0, row.startLine - 1), row.endLine)
|
|
411
500
|
.join("\n")
|
|
412
501
|
.split(/\r?\n/, 1)[0]
|
|
413
|
-
|
|
414
|
-
.
|
|
502
|
+
// `(?<!\s)` pins the match to the start of a whitespace run; without it
|
|
503
|
+
// every position inside the run rescans it, which is quadratic.
|
|
504
|
+
//
|
|
505
|
+
// S8786 reports the brace variant as super-linear anyway. Measured on
|
|
506
|
+
// a pure whitespace run with no brace: 0.02ms at n=4,000 rising to
|
|
507
|
+
// 0.11ms at n=32,000 — ratios 1.29 / 1.94 / 2.17 per doubling, i.e.
|
|
508
|
+
// linear. The lookbehind is what makes it so. The arrow variant on
|
|
509
|
+
// the line below is built identically and is not reported, which is
|
|
510
|
+
// the inconsistency that identifies this as a false positive rather
|
|
511
|
+
// than a rule we are ducking.
|
|
512
|
+
.replace(/(?<!\s)\s*=>.*$/, "")
|
|
513
|
+
.replace(/(?<!\s)\s*\{.*$/, "") // NOSONAR S8786 — measured linear, see above
|
|
415
514
|
.replace(/:\s*(?:#.*)?$/, "")
|
|
416
515
|
.replace(/\s+/g, " ")
|
|
417
516
|
.trim() || `${row.kind}:${row.name}`;
|
|
@@ -637,7 +736,7 @@ const ARCHITECTURE_CACHE_LIMIT = 16;
|
|
|
637
736
|
function getArchitectureMetadataSnapshot(allRepos, resolvedRepoPath) {
|
|
638
737
|
const cacheKey = JSON.stringify([resolvedRepoPath, allRepos.map((repo) => repo.path)]);
|
|
639
738
|
const cached = architectureMetadataCache.get(cacheKey);
|
|
640
|
-
if (cached
|
|
739
|
+
if (cached?.generation === indexGeneration)
|
|
641
740
|
return cached.snapshot;
|
|
642
741
|
const formatPath = makePathFormatter(resolvedRepoPath);
|
|
643
742
|
const communities = detectSymbolCommunities(allRepos, formatPath);
|
|
@@ -705,7 +804,7 @@ function setBoundedCache(cache, key, value, limit) {
|
|
|
705
804
|
}
|
|
706
805
|
}
|
|
707
806
|
function getGraphAnalyticsSnapshot(allRepos, resolvedRepoPath, topN = 15, relationKinds) {
|
|
708
|
-
const normalizedKinds = [...(relationKinds ?? [])].sort();
|
|
807
|
+
const normalizedKinds = [...(relationKinds ?? [])].sort(compareBytes);
|
|
709
808
|
const cacheKey = JSON.stringify([
|
|
710
809
|
resolvedRepoPath,
|
|
711
810
|
allRepos.map((repo) => repo.path),
|
|
@@ -713,7 +812,7 @@ function getGraphAnalyticsSnapshot(allRepos, resolvedRepoPath, topN = 15, relati
|
|
|
713
812
|
normalizedKinds,
|
|
714
813
|
]);
|
|
715
814
|
const cached = graphAnalyticsCache.get(cacheKey);
|
|
716
|
-
if (cached
|
|
815
|
+
if (cached?.generation === indexGeneration)
|
|
717
816
|
return cached.result;
|
|
718
817
|
const result = measurePerfPhase("graph_analytics", async () => {
|
|
719
818
|
const formatPath = makePathFormatter(resolvedRepoPath);
|
|
@@ -755,7 +854,7 @@ function traversalNodeKey(name, file) {
|
|
|
755
854
|
}
|
|
756
855
|
function getTraversalSnapshot(repoPath, db) {
|
|
757
856
|
const cached = traversalSnapshotCache.get(repoPath);
|
|
758
|
-
if (cached
|
|
857
|
+
if (cached?.generation === indexGeneration)
|
|
759
858
|
return cached.snapshot;
|
|
760
859
|
const snapshot = measurePerfPhaseSync("traversal_snapshot", () => {
|
|
761
860
|
const edges = db
|
|
@@ -857,7 +956,7 @@ export function resetSearchCacheStats() {
|
|
|
857
956
|
function getSearchMetadataSnapshot(repoPath, db) {
|
|
858
957
|
const cacheKey = path.resolve(repoPath);
|
|
859
958
|
const cached = searchMetadataCache.get(cacheKey);
|
|
860
|
-
if (cached
|
|
959
|
+
if (cached?.generation === indexGeneration) {
|
|
861
960
|
searchCacheStats.metadataCacheHits++;
|
|
862
961
|
return cached.snapshot;
|
|
863
962
|
}
|
|
@@ -893,7 +992,7 @@ function getSearchMetadataSnapshot(repoPath, db) {
|
|
|
893
992
|
function getSearchCommunityLookup(allRepos, resolvedRepoPath) {
|
|
894
993
|
const cacheKey = JSON.stringify([resolvedRepoPath, allRepos.map((repo) => repo.path)]);
|
|
895
994
|
const cached = searchCommunityLookupCache.get(cacheKey);
|
|
896
|
-
if (cached
|
|
995
|
+
if (cached?.generation === indexGeneration) {
|
|
897
996
|
searchCacheStats.communityLookupCacheHits++;
|
|
898
997
|
return cached.lookup;
|
|
899
998
|
}
|
|
@@ -916,7 +1015,7 @@ function getSearchCommunityLookup(allRepos, resolvedRepoPath) {
|
|
|
916
1015
|
*/
|
|
917
1016
|
function getAnnIndex(repoPath, rows) {
|
|
918
1017
|
const cached = annIndexCache.get(repoPath);
|
|
919
|
-
if (cached
|
|
1018
|
+
if (cached?.generation === indexGeneration)
|
|
920
1019
|
return cached.index;
|
|
921
1020
|
const parsed = [];
|
|
922
1021
|
for (const r of rows) {
|
|
@@ -964,7 +1063,7 @@ function getAnnIndex(repoPath, rows) {
|
|
|
964
1063
|
*/
|
|
965
1064
|
function computeFlows(db, resolvedRepoPath) {
|
|
966
1065
|
const cached = flowsCache.get(resolvedRepoPath);
|
|
967
|
-
if (cached
|
|
1066
|
+
if (cached?.generation === indexGeneration)
|
|
968
1067
|
return cached.result;
|
|
969
1068
|
// Resolved call edges only (the callee resolves to a concrete file).
|
|
970
1069
|
const edgeRows = db
|
|
@@ -1013,8 +1112,8 @@ function computeFlows(db, resolvedRepoPath) {
|
|
|
1013
1112
|
}
|
|
1014
1113
|
if (!src)
|
|
1015
1114
|
return false;
|
|
1016
|
-
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g,
|
|
1017
|
-
return new RegExp(
|
|
1115
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
|
|
1116
|
+
return new RegExp(String.raw `\bexport\s+(?:default\s+)?(?:function|class|const|let|var|type|interface|enum)\s+${escaped}\b`).test(src);
|
|
1018
1117
|
};
|
|
1019
1118
|
// Entry points: endpoints + exported functions with zero callers.
|
|
1020
1119
|
const outDegree = (name) => adj.get(name)?.length ?? 0;
|
|
@@ -1097,7 +1196,7 @@ function computeFlows(db, resolvedRepoPath) {
|
|
|
1097
1196
|
*/
|
|
1098
1197
|
function getFileToFlowLookup(db, resolvedRepoPath) {
|
|
1099
1198
|
const cached = fileToFlowLookupCache.get(resolvedRepoPath);
|
|
1100
|
-
if (cached
|
|
1199
|
+
if (cached?.generation === indexGeneration)
|
|
1101
1200
|
return cached.result;
|
|
1102
1201
|
const byFile = new Map();
|
|
1103
1202
|
for (const flow of computeFlows(db, resolvedRepoPath)) {
|
|
@@ -1325,7 +1424,8 @@ function getPrecedingComment(node) {
|
|
|
1325
1424
|
if (text.startsWith("/*")) {
|
|
1326
1425
|
text = text
|
|
1327
1426
|
.replace(/^\/\*+\s*/, "")
|
|
1328
|
-
|
|
1427
|
+
// `(?<!\*)` pins the match to the start of the star run (quadratic otherwise).
|
|
1428
|
+
.replace(/(?<!\*)\*+\/$/, "")
|
|
1329
1429
|
.split("\n")
|
|
1330
1430
|
.map((line) => line.trim().replace(/^\*\s*/, ""))
|
|
1331
1431
|
.join("\n")
|
|
@@ -1347,9 +1447,9 @@ function getPythonDocstring(node) {
|
|
|
1347
1447
|
const body = node.childForFieldName("body");
|
|
1348
1448
|
if (body && body.childCount > 0) {
|
|
1349
1449
|
const first = body.child(0);
|
|
1350
|
-
if (first
|
|
1450
|
+
if (first?.type === "expression_statement") {
|
|
1351
1451
|
const expr = first.child(0);
|
|
1352
|
-
if (expr
|
|
1452
|
+
if (expr?.type === "string") {
|
|
1353
1453
|
return expr.text.replace(/^["']{3}|["']{3}$/g, "").trim();
|
|
1354
1454
|
}
|
|
1355
1455
|
}
|
|
@@ -1468,8 +1568,8 @@ function balancedEnd(source, openIndex, open = "(", close = ")") {
|
|
|
1468
1568
|
function objectKeys(source) {
|
|
1469
1569
|
const keys = new Set();
|
|
1470
1570
|
for (const entry of splitCallArguments(source)) {
|
|
1471
|
-
const property =
|
|
1472
|
-
const key = property?.[1] ?? property?.[2] ??
|
|
1571
|
+
const property = /^\s*(?:["']([^"']+)["']|([A-Za-z_$][\w$]*))\s*(?::|,|$)/.exec(entry);
|
|
1572
|
+
const key = property?.[1] ?? property?.[2] ?? /^\s*([A-Za-z_$][\w$]*)\s*$/.exec(entry)?.[1];
|
|
1473
1573
|
if (key && key !== "..." && !key.startsWith("..."))
|
|
1474
1574
|
keys.add(key);
|
|
1475
1575
|
}
|
|
@@ -1509,7 +1609,7 @@ function topLevelObjectKeys(source) {
|
|
|
1509
1609
|
}
|
|
1510
1610
|
if (depth !== 0 || !propertyStart || /\s/.test(char))
|
|
1511
1611
|
continue;
|
|
1512
|
-
const match =
|
|
1612
|
+
const match = /^(?:["']([^"']+)["']|([A-Za-z_$][\w$]*))\s*:/.exec(source.slice(i));
|
|
1513
1613
|
if (match) {
|
|
1514
1614
|
keys.add(match[1] ?? match[2]);
|
|
1515
1615
|
propertyStart = false;
|
|
@@ -1534,21 +1634,25 @@ function fieldsFromType(typeExpression, types) {
|
|
|
1534
1634
|
for (const field of types.get(name[0]) ?? [])
|
|
1535
1635
|
fields.add(field);
|
|
1536
1636
|
}
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1637
|
+
// Widest `{ … }` span, scanned rather than matched: the equivalent regex
|
|
1638
|
+
// (`/\{([\s\S]*)\}/`) backtracks quadratically on brace-heavy type expressions.
|
|
1639
|
+
const braceStart = typeExpression.indexOf("{");
|
|
1640
|
+
const braceEnd = typeExpression.lastIndexOf("}");
|
|
1641
|
+
if (braceStart >= 0 && braceEnd > braceStart) {
|
|
1642
|
+
for (const field of objectKeys(typeExpression.slice(braceStart + 1, braceEnd)))
|
|
1540
1643
|
fields.add(field);
|
|
1644
|
+
}
|
|
1541
1645
|
return [...fields];
|
|
1542
1646
|
}
|
|
1543
1647
|
function apiPath(expression, isClient) {
|
|
1544
1648
|
const trimmed = expression.trim();
|
|
1545
1649
|
let value = null;
|
|
1546
1650
|
let unresolved = false;
|
|
1547
|
-
const quoted =
|
|
1651
|
+
const quoted = /^(["'])([\s\S]*?)\1$/.exec(trimmed);
|
|
1548
1652
|
if (quoted)
|
|
1549
1653
|
value = quoted[2];
|
|
1550
1654
|
else {
|
|
1551
|
-
const template =
|
|
1655
|
+
const template = /^`([\s\S]*)`$/.exec(trimmed);
|
|
1552
1656
|
if (template && isClient)
|
|
1553
1657
|
value = template[1].replace(/\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g, "{$1}");
|
|
1554
1658
|
else
|
|
@@ -1608,9 +1712,7 @@ function declaredResponseFields(source, types) {
|
|
|
1608
1712
|
function extractApiContracts(content, definitions, references) {
|
|
1609
1713
|
const facts = [];
|
|
1610
1714
|
const types = typeFieldMap(content);
|
|
1611
|
-
const masked = content
|
|
1612
|
-
.replace(/\/\*[\s\S]*?\*\//g, (match) => match.replace(/[^\n]/g, " "))
|
|
1613
|
-
.replace(/(^|\s)\/\/.*$/gm, (match, prefix) => prefix + " ".repeat(match.length - prefix.length));
|
|
1715
|
+
const masked = maskBlockComments(content, (match) => match.replace(/[^\n]/g, " ")).replace(/(^|\s)\/\/.*$/gm, (match, prefix) => prefix + " ".repeat(match.length - prefix.length));
|
|
1614
1716
|
for (const match of masked.matchAll(/\b([A-Za-z_$][\w$]*)\.(get|post|put|patch|delete)\s*\(/gi)) {
|
|
1615
1717
|
const receiver = match[1].toLowerCase();
|
|
1616
1718
|
if (!new Set(["app", "router", "route", "fastify", "server"]).has(receiver))
|
|
@@ -1661,7 +1763,7 @@ function extractApiContracts(content, definitions, references) {
|
|
|
1661
1763
|
const addClient = (framework, method, index, args) => {
|
|
1662
1764
|
const route = apiPath(args[0] ?? "", true);
|
|
1663
1765
|
if (framework === "fetch") {
|
|
1664
|
-
const methodMatch =
|
|
1766
|
+
const methodMatch = /\bmethod\s*:\s*["'](get|post|put|patch|delete)["']/i.exec(args[1] ?? "");
|
|
1665
1767
|
if (methodMatch)
|
|
1666
1768
|
method = methodMatch[1].toUpperCase();
|
|
1667
1769
|
}
|
|
@@ -1677,12 +1779,12 @@ function extractApiContracts(content, definitions, references) {
|
|
|
1677
1779
|
.match(/(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*await\s*$/)?.[1];
|
|
1678
1780
|
const bodyMatch = response
|
|
1679
1781
|
? framework === "fetch"
|
|
1680
|
-
? new RegExp(`(?:const|let)
|
|
1681
|
-
: new RegExp(`(?:const|let)
|
|
1782
|
+
? new RegExp(String.raw `(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*\(\s*await\s+${response}\.json\(\)\s*\)\s+as\s+([^;\n]+)`).exec(source)
|
|
1783
|
+
: new RegExp(String.raw `(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*${response}\.data\s+as\s+([^;\n]+)`).exec(source)
|
|
1682
1784
|
: null;
|
|
1683
1785
|
const body = bodyMatch?.[1];
|
|
1684
1786
|
const accessed = body
|
|
1685
|
-
? [...source.matchAll(new RegExp(
|
|
1787
|
+
? [...source.matchAll(new RegExp(String.raw `\b${body}\.([A-Za-z_$][\w$]*)`, "g"))].map((entry) => entry[1])
|
|
1686
1788
|
: [];
|
|
1687
1789
|
facts.push({
|
|
1688
1790
|
kind: "client",
|
|
@@ -1751,7 +1853,7 @@ function extractSymbolsAndReferences(rootNode, isPython, isApex = false, filePat
|
|
|
1751
1853
|
const isGo = filePath.endsWith(".go");
|
|
1752
1854
|
const isRust = filePath.endsWith(".rs");
|
|
1753
1855
|
function getEnclosingSymbol() {
|
|
1754
|
-
return symbolStack.
|
|
1856
|
+
return symbolStack.at(-1) ?? null;
|
|
1755
1857
|
}
|
|
1756
1858
|
function traverse(node) {
|
|
1757
1859
|
let isNewSymbol = false;
|
|
@@ -2187,7 +2289,7 @@ function extractSymbolsAndReferences(rootNode, isPython, isApex = false, filePat
|
|
|
2187
2289
|
else if (node.type === "variable_declarator") {
|
|
2188
2290
|
const nameNode = node.childForFieldName("name");
|
|
2189
2291
|
const valueNode = node.childForFieldName("value");
|
|
2190
|
-
if (nameNode
|
|
2292
|
+
if (nameNode?.type === "identifier") {
|
|
2191
2293
|
const name = nameNode.text;
|
|
2192
2294
|
symName = name;
|
|
2193
2295
|
const isFunc = valueNode &&
|
|
@@ -2235,7 +2337,7 @@ function extractSymbolsAndReferences(rootNode, isPython, isApex = false, filePat
|
|
|
2235
2337
|
if (!isPython && !isApex) {
|
|
2236
2338
|
if (node.type === "import_statement" || node.type === "import_declaration") {
|
|
2237
2339
|
const sourceNode = node.childForFieldName("source");
|
|
2238
|
-
if (sourceNode
|
|
2340
|
+
if (sourceNode?.type === "string") {
|
|
2239
2341
|
const srcText = sourceNode.text.replace(/^['"`]|['"`]$/g, "");
|
|
2240
2342
|
fileDependencies.push(srcText);
|
|
2241
2343
|
// Record which local names this module binds, so a later call to
|
|
@@ -2334,14 +2436,14 @@ function extractSymbolsAndReferences(rootNode, isPython, isApex = false, filePat
|
|
|
2334
2436
|
}
|
|
2335
2437
|
else if (node.type === "query_expression") {
|
|
2336
2438
|
const queryText = node.text;
|
|
2337
|
-
const soqlMatches = [...queryText.matchAll(/from\s+(
|
|
2439
|
+
const soqlMatches = [...queryText.matchAll(/from\s+(\w+)/gi)];
|
|
2338
2440
|
for (const match of soqlMatches) {
|
|
2339
2441
|
const tableName = match[1];
|
|
2340
2442
|
if (tableName) {
|
|
2341
2443
|
tableDependencies.push(tableName);
|
|
2342
2444
|
}
|
|
2343
2445
|
}
|
|
2344
|
-
const soslMatches = [...queryText.matchAll(/returning\s+(
|
|
2446
|
+
const soslMatches = [...queryText.matchAll(/returning\s+(\w+)/gi)];
|
|
2345
2447
|
for (const match of soslMatches) {
|
|
2346
2448
|
const tableName = match[1];
|
|
2347
2449
|
if (tableName) {
|
|
@@ -2519,20 +2621,20 @@ function extractSymbolsAndReferences(rootNode, isPython, isApex = false, filePat
|
|
|
2519
2621
|
node.children.find((c) => c.type === "arguments");
|
|
2520
2622
|
if (argsNode && argsNode.childCount > 0) {
|
|
2521
2623
|
let firstArg = argsNode.child(0);
|
|
2522
|
-
if (firstArg
|
|
2624
|
+
if (firstArg?.text === "(") {
|
|
2523
2625
|
firstArg = argsNode.child(1);
|
|
2524
2626
|
}
|
|
2525
2627
|
if (firstArg) {
|
|
2526
2628
|
const tableName = firstArg.text.replace(/['"`]/g, "");
|
|
2527
2629
|
const columns = [];
|
|
2528
2630
|
let secondArg = argsNode.child(2);
|
|
2529
|
-
if (secondArg
|
|
2631
|
+
if (secondArg?.text === ",") {
|
|
2530
2632
|
secondArg = argsNode.child(3);
|
|
2531
2633
|
}
|
|
2532
|
-
if (secondArg
|
|
2634
|
+
if (secondArg?.type === "object") {
|
|
2533
2635
|
for (let i = 0; i < secondArg.childCount; i++) {
|
|
2534
2636
|
const prop = secondArg.child(i);
|
|
2535
|
-
if (prop
|
|
2637
|
+
if (prop?.type === "pair") {
|
|
2536
2638
|
const keyNode = prop.childForFieldName("key") || prop.child(0);
|
|
2537
2639
|
if (keyNode) {
|
|
2538
2640
|
columns.push(keyNode.text.replace(/['"`]/g, ""));
|
|
@@ -2574,7 +2676,7 @@ function extractSymbolsAndReferences(rootNode, isPython, isApex = false, filePat
|
|
|
2574
2676
|
const content = rootNode.text;
|
|
2575
2677
|
const apiContracts = extractApiContracts(content, definitions, references);
|
|
2576
2678
|
// Drizzle ORM fallback regex
|
|
2577
|
-
const drizzleRegex = /(?:pgTable|sqliteTable|mysqlTable)\s*\(\s*['"`](
|
|
2679
|
+
const drizzleRegex = /(?:pgTable|sqliteTable|mysqlTable)\s*\(\s*['"`](\w+)['"`]/g;
|
|
2578
2680
|
let drizzleMatch = drizzleRegex.exec(content);
|
|
2579
2681
|
while (drizzleMatch !== null) {
|
|
2580
2682
|
const tableName = drizzleMatch[1];
|
|
@@ -2596,14 +2698,14 @@ function extractSymbolsAndReferences(rootNode, isPython, isApex = false, filePat
|
|
|
2596
2698
|
}
|
|
2597
2699
|
// Python: SQLAlchemy / Django Models
|
|
2598
2700
|
if (isPython) {
|
|
2599
|
-
const tablenameRegex = /__tablename__\s*=\s*['"`](
|
|
2701
|
+
const tablenameRegex = /__tablename__\s*=\s*['"`](\w+)['"`]/g;
|
|
2600
2702
|
let tablenameMatch = tablenameRegex.exec(content);
|
|
2601
2703
|
while (tablenameMatch !== null) {
|
|
2602
2704
|
const tableName = tablenameMatch[1];
|
|
2603
2705
|
ormDependencies.push({ to: tableName, kind: "orm-relation" });
|
|
2604
2706
|
tablenameMatch = tablenameRegex.exec(content);
|
|
2605
2707
|
}
|
|
2606
|
-
const dbtableRegex = /db_table\s*=\s*['"`](
|
|
2708
|
+
const dbtableRegex = /db_table\s*=\s*['"`](\w+)['"`]/g;
|
|
2607
2709
|
let dbtableMatch = dbtableRegex.exec(content);
|
|
2608
2710
|
while (dbtableMatch !== null) {
|
|
2609
2711
|
const tableName = dbtableMatch[1];
|
|
@@ -2613,7 +2715,7 @@ function extractSymbolsAndReferences(rootNode, isPython, isApex = false, filePat
|
|
|
2613
2715
|
}
|
|
2614
2716
|
// Java: JPA / Hibernate annotations
|
|
2615
2717
|
if (isJava) {
|
|
2616
|
-
const javaTableRegex = /@Table\s*\(\s*name\s*=\s*['"`](
|
|
2718
|
+
const javaTableRegex = /@Table\s*\(\s*name\s*=\s*['"`](\w+)['"`]\s*\)/gi;
|
|
2617
2719
|
let javaTableMatch = javaTableRegex.exec(content);
|
|
2618
2720
|
while (javaTableMatch !== null) {
|
|
2619
2721
|
const tableName = javaTableMatch[1];
|
|
@@ -2623,7 +2725,7 @@ function extractSymbolsAndReferences(rootNode, isPython, isApex = false, filePat
|
|
|
2623
2725
|
}
|
|
2624
2726
|
// C#: EF Core ToTable
|
|
2625
2727
|
if (isCSharp) {
|
|
2626
|
-
const csTableRegex = /\.ToTable\s*\(\s*['"`](
|
|
2728
|
+
const csTableRegex = /\.ToTable\s*\(\s*['"`](\w+)['"`]\s*\)/g;
|
|
2627
2729
|
let csTableMatch = csTableRegex.exec(content);
|
|
2628
2730
|
while (csTableMatch !== null) {
|
|
2629
2731
|
const tableName = csTableMatch[1];
|
|
@@ -2635,7 +2737,7 @@ function extractSymbolsAndReferences(rootNode, isPython, isApex = false, filePat
|
|
|
2635
2737
|
// Mask out comments so we don't detect example endpoints in docstrings.
|
|
2636
2738
|
let maskedContent = rootNode.text;
|
|
2637
2739
|
// Mask block comments preserving newlines
|
|
2638
|
-
maskedContent = maskedContent
|
|
2740
|
+
maskedContent = maskBlockComments(maskedContent, (match) => match.replace(/[^\n]/g, " "));
|
|
2639
2741
|
// Mask line comments (// or #) preserving length
|
|
2640
2742
|
maskedContent = maskedContent.replace(/(^|\s)(\/\/|#).*$/gm, (match, prefix) => {
|
|
2641
2743
|
return prefix + " ".repeat(match.length - prefix.length);
|
|
@@ -2676,9 +2778,16 @@ function extractSymbolsAndReferences(rootNode, isPython, isApex = false, filePat
|
|
|
2676
2778
|
// For decorators, find the decorated function on the next few lines
|
|
2677
2779
|
const remaining = fileContent.substring(pattern.regex.lastIndex);
|
|
2678
2780
|
// Match something like "async function name(" or "def name(" or "class name" or "methodName("
|
|
2679
|
-
|
|
2781
|
+
// The modifier group already has `\s` as an alternative, so neither a
|
|
2782
|
+
// leading `\s*` nor a trailing `\s*` may wrap it: either one splits a
|
|
2783
|
+
// whitespace run with the group and backtracks catastrophically.
|
|
2784
|
+
// Both forms are `^`-anchored and there is no `m` flag, so trying them
|
|
2785
|
+
// in order is exactly the alternation they replace, minus its complexity.
|
|
2786
|
+
const declaredFunc = /^\s*(?:async\s+)?(?:function|def)\s+(\w+)\b/i.exec(remaining);
|
|
2787
|
+
const funcMatch = declaredFunc ??
|
|
2788
|
+
/^(?:@Get|@Post|@Put|@Delete|@Patch|@app|@router|private|public|protected|async|static|\s)*(\w+)\s*\(/i.exec(remaining);
|
|
2680
2789
|
if (funcMatch) {
|
|
2681
|
-
handlerName = funcMatch[1]
|
|
2790
|
+
handlerName = funcMatch[1];
|
|
2682
2791
|
}
|
|
2683
2792
|
}
|
|
2684
2793
|
// If the handler is a method like controller.method, extract the method name too.
|
|
@@ -2812,9 +2921,9 @@ function extractSymbolsAndReferences(rootNode, isPython, isApex = false, filePat
|
|
|
2812
2921
|
// Bare single positional arg (`@GetMapping("/foo")`, optionally followed
|
|
2813
2922
|
// by other attributes): a quoted literal at the very start, with no
|
|
2814
2923
|
// `value =` prefix.
|
|
2815
|
-
const bareMatch =
|
|
2924
|
+
const bareMatch = /^\s*["']([^"']+)["']/.exec(args);
|
|
2816
2925
|
// Named `value = "..."` attribute, anywhere in the argument list.
|
|
2817
|
-
const namedMatch =
|
|
2926
|
+
const namedMatch = /\bvalue\s*=\s*["']([^"']+)["']/.exec(args);
|
|
2818
2927
|
const rawPath = bareMatch?.[1] ?? namedMatch?.[1];
|
|
2819
2928
|
if (!rawPath)
|
|
2820
2929
|
continue;
|
|
@@ -2846,7 +2955,7 @@ function isMinifiedSource(content) {
|
|
|
2846
2955
|
return false;
|
|
2847
2956
|
let newlines = 0;
|
|
2848
2957
|
for (let i = 0; i < content.length; i++) {
|
|
2849
|
-
if (content.
|
|
2958
|
+
if (content.codePointAt(i) === 10)
|
|
2850
2959
|
newlines++;
|
|
2851
2960
|
}
|
|
2852
2961
|
return content.length / (newlines + 1) > 2000;
|
|
@@ -2861,7 +2970,7 @@ function isMinifiedSource(content) {
|
|
|
2861
2970
|
* statements stay intact and the file indexes cleanly.
|
|
2862
2971
|
*/
|
|
2863
2972
|
function sanitizeDollarQuotedSql(content) {
|
|
2864
|
-
return content.replace(/\$(
|
|
2973
|
+
return content.replace(/\$(\w*)\$[\s\S]*?\$\1\$/g, "''");
|
|
2865
2974
|
}
|
|
2866
2975
|
const RESOLVE_EXTS = [".ts", ".tsx", ".js", ".jsx", ".py", ".mts", ".cts", ".mjs", ".cjs"];
|
|
2867
2976
|
// TS ESM writes `.js` specifiers that map to `.ts` source (and .jsx->.tsx, etc.).
|
|
@@ -3038,8 +3147,8 @@ function fileDefinesOrReexports(repoPath, relFile, name, seen) {
|
|
|
3038
3147
|
return true;
|
|
3039
3148
|
try {
|
|
3040
3149
|
const src = fs.readFileSync(path.join(repoPath, relFile), "utf-8");
|
|
3041
|
-
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g,
|
|
3042
|
-
if (new RegExp(
|
|
3150
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
|
|
3151
|
+
if (new RegExp(String.raw `\b(?:function|class|const|let|var|type|interface|enum)\s+${escaped}\b`).test(src)) {
|
|
3043
3152
|
return true;
|
|
3044
3153
|
}
|
|
3045
3154
|
}
|
|
@@ -3096,7 +3205,7 @@ function salesforceExperienceFile(relativePath) {
|
|
|
3096
3205
|
const normalized = relativePath.replaceAll("\\", "/");
|
|
3097
3206
|
if (normalized === "lwr.config.json")
|
|
3098
3207
|
return { type: "lwrConfig" };
|
|
3099
|
-
const match =
|
|
3208
|
+
const match = /^(force-app\/main\/default\/experiences\/[A-Za-z]\w*?)\/(routes|views|themes|assets)\/[^/]+\.(json|svg)$/.exec(normalized);
|
|
3100
3209
|
if (!match)
|
|
3101
3210
|
return null;
|
|
3102
3211
|
const type = match[2];
|
|
@@ -3131,7 +3240,7 @@ function experienceSiteFile(sitePath, category, value, repoPath) {
|
|
|
3131
3240
|
function findExperienceComponentFile(value, repoPath) {
|
|
3132
3241
|
if (typeof value !== "string")
|
|
3133
3242
|
return null;
|
|
3134
|
-
const component =
|
|
3243
|
+
const component = /^c:([A-Za-z]\w*)$/.exec(value)?.[1];
|
|
3135
3244
|
if (!component)
|
|
3136
3245
|
return null;
|
|
3137
3246
|
return (findSalesforceLwcComponentFile(component, repoPath) ??
|
|
@@ -3147,7 +3256,7 @@ function findExperienceComponentFile(value, repoPath) {
|
|
|
3147
3256
|
*/
|
|
3148
3257
|
function lwcBundleFile(relativePath) {
|
|
3149
3258
|
const normalized = relativePath.replaceAll("\\", "/");
|
|
3150
|
-
const match =
|
|
3259
|
+
const match = /^(force-app\/main\/default\/lwc\/)([A-Za-z]\w*?)\/\2\.(js|html|css|js-meta\.xml)$/.exec(normalized);
|
|
3151
3260
|
if (!match)
|
|
3152
3261
|
return null;
|
|
3153
3262
|
return {
|
|
@@ -3163,7 +3272,7 @@ function lwcBundleFile(relativePath) {
|
|
|
3163
3272
|
*/
|
|
3164
3273
|
function auraBundleFile(relativePath) {
|
|
3165
3274
|
const normalized = relativePath.replaceAll("\\", "/");
|
|
3166
|
-
const match =
|
|
3275
|
+
const match = /^(force-app\/main\/default\/aura\/)([A-Za-z]\w*?)\/\2\.(app|cmp|css|design|docs|evt|intf|js|svg)$/.exec(normalized);
|
|
3167
3276
|
if (!match)
|
|
3168
3277
|
return null;
|
|
3169
3278
|
return {
|
|
@@ -3182,14 +3291,11 @@ function findApexMethodFilePath(className, methodName, repoPath) {
|
|
|
3182
3291
|
if (!classFile)
|
|
3183
3292
|
return null;
|
|
3184
3293
|
try {
|
|
3185
|
-
const source = fs
|
|
3186
|
-
|
|
3187
|
-
.replace(/\/\*[\s\S]*?\*\/|\/\/.*$/gm, "")
|
|
3188
|
-
.replace(/'(?:\\.|[^'])*'|"(?:\\.|[^"])*"/g, "");
|
|
3189
|
-
const escaped = methodName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3294
|
+
const source = stripBlockAndLineComments(fs.readFileSync(path.join(repoPath, classFile), "utf-8")).replace(/'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"/g, "");
|
|
3295
|
+
const escaped = methodName.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
|
|
3190
3296
|
// A source-level import is exact only when its declared Apex method exists,
|
|
3191
3297
|
// never merely when the method name appears in a call, comment, or string.
|
|
3192
|
-
const declaration = new RegExp(
|
|
3298
|
+
const declaration = new RegExp(String.raw `\b(?:public|protected|private|global)\s+(?:(?:static|virtual|override|webService)\s+)*[A-Za-z_][\w<>,.?\[\]\s]*?\s${escaped}\s*\(`);
|
|
3193
3299
|
return declaration.test(source) ? classFile : null;
|
|
3194
3300
|
}
|
|
3195
3301
|
catch {
|
|
@@ -3197,7 +3303,7 @@ function findApexMethodFilePath(className, methodName, repoPath) {
|
|
|
3197
3303
|
}
|
|
3198
3304
|
}
|
|
3199
3305
|
function isUnnamespacedSalesforceIdentifier(value) {
|
|
3200
|
-
return !value.includes("__") && /^[A-Za-z]
|
|
3306
|
+
return !value.includes("__") && /^[A-Za-z]\w*(?:\.[A-Za-z]\w*)?$/.test(value);
|
|
3201
3307
|
}
|
|
3202
3308
|
/**
|
|
3203
3309
|
* The C20 matcher deliberately recognizes only Salesforce DX metadata paths.
|
|
@@ -3209,32 +3315,35 @@ function salesforceMetadataFile(relativePath) {
|
|
|
3209
3315
|
const root = "force-app/main/default/";
|
|
3210
3316
|
if (!normalized.startsWith(root))
|
|
3211
3317
|
return null;
|
|
3212
|
-
let match =
|
|
3318
|
+
let match = /^force-app\/main\/default\/objects\/([A-Za-z]\w*)\/\1\.object-meta\.xml$/.exec(normalized);
|
|
3213
3319
|
if (match)
|
|
3214
3320
|
return { type: "object", object: match[1] };
|
|
3215
|
-
match =
|
|
3321
|
+
match =
|
|
3322
|
+
/^force-app\/main\/default\/objects\/([A-Za-z]\w*)\/fields\/([A-Za-z]\w*)\.field-meta\.xml$/.exec(normalized);
|
|
3216
3323
|
if (match)
|
|
3217
3324
|
return { type: "field", object: match[1], field: match[2] };
|
|
3218
|
-
match =
|
|
3325
|
+
match =
|
|
3326
|
+
/^force-app\/main\/default\/objects\/([A-Za-z]\w*)\/recordTypes\/([A-Za-z]\w*)\.recordType-meta\.xml$/.exec(normalized);
|
|
3219
3327
|
if (match)
|
|
3220
3328
|
return { type: "recordType", object: match[1], recordType: match[2] };
|
|
3221
|
-
match =
|
|
3329
|
+
match = /^force-app\/main\/default\/permissionSets\/([A-Za-z]\w*)\.permissionSet-meta\.xml$/.exec(normalized);
|
|
3222
3330
|
if (match)
|
|
3223
3331
|
return { type: "permissionSet", name: match[1] };
|
|
3224
|
-
match =
|
|
3332
|
+
match = /^force-app\/main\/default\/flows\/([A-Za-z]\w*)\.flow-meta\.xml$/.exec(normalized);
|
|
3225
3333
|
if (match)
|
|
3226
3334
|
return { type: "flow", name: match[1] };
|
|
3227
|
-
match =
|
|
3335
|
+
match = /^force-app\/main\/default\/workflows\/([A-Za-z]\w*)\.workflow-meta\.xml$/.exec(normalized);
|
|
3228
3336
|
if (match)
|
|
3229
3337
|
return { type: "workflow", object: match[1] };
|
|
3230
|
-
match =
|
|
3338
|
+
match =
|
|
3339
|
+
/^force-app\/main\/default\/approvalProcesses\/([A-Za-z]\w*)\.([A-Za-z]\w*)\.approvalProcess-meta\.xml$/.exec(normalized);
|
|
3231
3340
|
if (match)
|
|
3232
3341
|
return { type: "approval", object: match[1], name: match[2] };
|
|
3233
3342
|
return null;
|
|
3234
3343
|
}
|
|
3235
3344
|
function staticXmlTagValues(content, tag) {
|
|
3236
3345
|
const uncommented = content.replace(/<!--[\s\S]*?-->/g, "");
|
|
3237
|
-
const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g,
|
|
3346
|
+
const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
|
|
3238
3347
|
const expression = new RegExp(`<${escaped}>([^<]+)</${escaped}>`, "g");
|
|
3239
3348
|
const values = [];
|
|
3240
3349
|
for (const match of uncommented.matchAll(expression)) {
|
|
@@ -3246,7 +3355,7 @@ function staticXmlTagValues(content, tag) {
|
|
|
3246
3355
|
}
|
|
3247
3356
|
function staticSalesforceIdentifier(value) {
|
|
3248
3357
|
return value.split(".").every((part) => {
|
|
3249
|
-
if (!/^[A-Za-z]
|
|
3358
|
+
if (!/^[A-Za-z]\w*$/.test(part))
|
|
3250
3359
|
return false;
|
|
3251
3360
|
const customMarkerCount = (part.match(/__/g) ?? []).length;
|
|
3252
3361
|
// `Thing__c` is an unnamespaced custom object/field. `pkg__Thing__c`
|
|
@@ -3255,14 +3364,14 @@ function staticSalesforceIdentifier(value) {
|
|
|
3255
3364
|
});
|
|
3256
3365
|
}
|
|
3257
3366
|
function findSalesforceLwcComponentFile(componentName, repoPath) {
|
|
3258
|
-
if (!/^[A-Za-z]
|
|
3367
|
+
if (!/^[A-Za-z]\w*$/.test(componentName))
|
|
3259
3368
|
return null;
|
|
3260
3369
|
const candidate = `force-app/main/default/lwc/${componentName}/${componentName}.js`;
|
|
3261
3370
|
return fs.existsSync(path.join(repoPath, candidate)) ? candidate : null;
|
|
3262
3371
|
}
|
|
3263
3372
|
function apexMethodIsDeclared(content, methodName) {
|
|
3264
|
-
const escaped = methodName.replace(/[.*+?^${}()|[\]\\]/g,
|
|
3265
|
-
return (firstExecutableMatch(content, new RegExp(
|
|
3373
|
+
const escaped = methodName.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
|
|
3374
|
+
return (firstExecutableMatch(content, new RegExp(String.raw `\b(?:public|protected|private|global)\s+(?:(?:static|virtual|override|webService)\s+)*[A-Za-z_][\w<>,.?\[\]\s]*?\s${escaped}\s*\(`, "gi")) !== null);
|
|
3266
3375
|
}
|
|
3267
3376
|
function firstExecutableMatch(source, expression) {
|
|
3268
3377
|
for (const match of source.matchAll(expression)) {
|
|
@@ -3293,8 +3402,8 @@ function findClosingCallParen(source, openParen) {
|
|
|
3293
3402
|
return null;
|
|
3294
3403
|
}
|
|
3295
3404
|
function implementsApexInterface(clause, interfaceName) {
|
|
3296
|
-
const escaped = interfaceName.replace(/[.*+?^${}()|[\]\\]/g,
|
|
3297
|
-
return new RegExp(`(?:^|[
|
|
3405
|
+
const escaped = interfaceName.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
|
|
3406
|
+
return new RegExp(String.raw `(?:^|[,\s])${escaped}(?=\s|,|<|$)`, "i").test(clause);
|
|
3298
3407
|
}
|
|
3299
3408
|
/**
|
|
3300
3409
|
* Extract the deliberately narrow source-only part of Apex platform behavior.
|
|
@@ -3307,35 +3416,40 @@ function extractApexPlatformDependencies(content, repoPath) {
|
|
|
3307
3416
|
const add = (to, kind, sourceEvidence) => {
|
|
3308
3417
|
dependencies.push({ to, kind, sourceEvidence });
|
|
3309
3418
|
};
|
|
3310
|
-
const className = firstExecutableMatch(content, /\bclass\s+([
|
|
3419
|
+
const className = firstExecutableMatch(content, /\bclass\s+([a-z]\w*)\b/gi)?.[1];
|
|
3311
3420
|
const restResource = firstExecutableMatch(content, /@RestResource\s*\(\s*urlMapping\s*=\s*(["'])([^"']+)\1\s*\)/gi);
|
|
3312
3421
|
if (restResource) {
|
|
3313
|
-
|
|
3422
|
+
// `\s(?:\s|[^({;]*?(?<!\s))` is the return-type span. Written the obvious way
|
|
3423
|
+
// (`\s+[^({;]+?`) both halves can consume whitespace, so every split point of a
|
|
3424
|
+
// whitespace run is retried and the scan is quadratic. Pinning the span to end
|
|
3425
|
+
// on a non-whitespace character — with a bare `\s` for the one-whitespace-char
|
|
3426
|
+
// case that pin would otherwise drop — matches the same text in linear time.
|
|
3427
|
+
for (const verb of content.matchAll(/@Http(Get|Post|Put|Patch|Delete)\b[\s\S]{0,300}?\b(?:global|public)\s+static\s(?:\s|[^({;]*?(?<!\s))\s+[a-z]\w*\s*\(/gi)) {
|
|
3314
3428
|
if (!isExecutablePosition(content, verb.index ?? 0))
|
|
3315
3429
|
continue;
|
|
3316
|
-
const handlerAnnotation =
|
|
3430
|
+
const handlerAnnotation = /^@Http(?:Get|Post|Put|Patch|Delete)/i.exec(verb[0])?.[0];
|
|
3317
3431
|
if (!handlerAnnotation)
|
|
3318
3432
|
continue;
|
|
3319
3433
|
add(`REST ${verb[1].toUpperCase()} ${restResource[2]}`, "apex_rest_entry", `${restResource[0].trim()}; ${handlerAnnotation}`);
|
|
3320
3434
|
}
|
|
3321
3435
|
}
|
|
3322
3436
|
if (className) {
|
|
3323
|
-
for (const method of content.matchAll(/\b(?:global|public)\s+static\s+webService\s
|
|
3437
|
+
for (const method of content.matchAll(/\b(?:global|public)\s+static\s+webService\s(?:\s|[^({;]*?(?<!\s))\s+([a-z]\w*)\s*\(/gi)) {
|
|
3324
3438
|
if (!isExecutablePosition(content, method.index ?? 0))
|
|
3325
3439
|
continue;
|
|
3326
3440
|
add(`SOAP ${className}.${method[1]}`, "apex_soap_entry", method[0].trim());
|
|
3327
3441
|
}
|
|
3328
|
-
for (const method of content.matchAll(/@InvocableMethod\b[\s\S]{0,300}?\b(?:global|public)\s+static\s
|
|
3442
|
+
for (const method of content.matchAll(/@InvocableMethod\b[\s\S]{0,300}?\b(?:global|public)\s+static\s(?:\s|[^({;]*?(?<!\s))\s+([a-z]\w*)\s*\(/gi)) {
|
|
3329
3443
|
if (!isExecutablePosition(content, method.index ?? 0))
|
|
3330
3444
|
continue;
|
|
3331
3445
|
add(`ApexAction ${className}.${method[1]}`, "apex_invocable_entry", method[0].trim());
|
|
3332
3446
|
}
|
|
3333
|
-
for (const method of content.matchAll(/@future\b[\s\S]{0,300}?\b(?:global|public)\s+static\s
|
|
3447
|
+
for (const method of content.matchAll(/@future\b[\s\S]{0,300}?\b(?:global|public)\s+static\s(?:\s|[^({;]*?(?<!\s))\s+([a-z]\w*)\s*\(/gi)) {
|
|
3334
3448
|
if (!isExecutablePosition(content, method.index ?? 0))
|
|
3335
3449
|
continue;
|
|
3336
3450
|
add(`${className}.${method[1]}`, "apex_async_callback", method[0].trim());
|
|
3337
3451
|
}
|
|
3338
|
-
const implementsClause = firstExecutableMatch(content, new RegExp(
|
|
3452
|
+
const implementsClause = firstExecutableMatch(content, new RegExp(String.raw `\bclass\s+${className}\b[^{}]*\bimplements\b([^{}]+)`, "gis"))?.[1];
|
|
3339
3453
|
if (implementsClause) {
|
|
3340
3454
|
const callbacks = [
|
|
3341
3455
|
{ interfaceName: "Queueable", methods: ["execute"] },
|
|
@@ -3356,11 +3470,11 @@ function extractApexPlatformDependencies(content, repoPath) {
|
|
|
3356
3470
|
}
|
|
3357
3471
|
}
|
|
3358
3472
|
}
|
|
3359
|
-
for (const trigger of content.matchAll(/\btrigger\s+([
|
|
3473
|
+
for (const trigger of content.matchAll(/\btrigger\s+([a-z]\w*)\s+on\s+([a-z]\w*)\s*\(([^)]+)\)/gi)) {
|
|
3360
3474
|
if (!isExecutablePosition(content, trigger.index ?? 0))
|
|
3361
3475
|
continue;
|
|
3362
3476
|
for (const event of trigger[3].split(",").map((value) => value.trim())) {
|
|
3363
|
-
const normalized =
|
|
3477
|
+
const normalized = /^(before|after)\s+(insert|update|delete|undelete)$/i.exec(event);
|
|
3364
3478
|
if (!normalized)
|
|
3365
3479
|
continue;
|
|
3366
3480
|
const target = `${trigger[2]}.${normalized[1].toLowerCase()}${normalized[2][0].toUpperCase()}${normalized[2].slice(1).toLowerCase()}`;
|
|
@@ -3372,8 +3486,8 @@ function extractApexPlatformDependencies(content, repoPath) {
|
|
|
3372
3486
|
}
|
|
3373
3487
|
}
|
|
3374
3488
|
const enqueueCalls = [
|
|
3375
|
-
/\bSystem\.enqueueJob\s*\(\s*new\s+([
|
|
3376
|
-
/\bDatabase\.executeBatch\s*\(\s*new\s+([
|
|
3489
|
+
/\bSystem\.enqueueJob\s*\(\s*new\s+([a-z]\w*)\s*\(/gi,
|
|
3490
|
+
/\bDatabase\.executeBatch\s*\(\s*new\s+([a-z]\w*)\s*\(/gi,
|
|
3377
3491
|
];
|
|
3378
3492
|
for (const enqueue of enqueueCalls) {
|
|
3379
3493
|
for (const match of content.matchAll(enqueue)) {
|
|
@@ -3394,7 +3508,7 @@ function extractApexPlatformDependencies(content, repoPath) {
|
|
|
3394
3508
|
const args = splitCallArguments(content.slice(openParen + 1, closeParen));
|
|
3395
3509
|
if (args.length !== 3)
|
|
3396
3510
|
continue;
|
|
3397
|
-
const job =
|
|
3511
|
+
const job = /^new\s+([A-Za-z]\w*)\s*\(/.exec(args[2]);
|
|
3398
3512
|
if (!job)
|
|
3399
3513
|
continue;
|
|
3400
3514
|
const target = findUniqueApexClassFilePath(job[1], repoPath);
|
|
@@ -3443,7 +3557,7 @@ async function indexSalesforceMetadataFile(content, relativePath, repoPath, db,
|
|
|
3443
3557
|
add(entry.value, "permission_set_record_type", entry.evidence);
|
|
3444
3558
|
}
|
|
3445
3559
|
for (const entry of staticXmlTagValues(content, "apexClass")) {
|
|
3446
|
-
if (!/^[A-Za-z]
|
|
3560
|
+
if (!/^[A-Za-z]\w*$/.test(entry.value))
|
|
3447
3561
|
continue;
|
|
3448
3562
|
const target = findUniqueApexClassFilePath(entry.value, repoPath);
|
|
3449
3563
|
if (target)
|
|
@@ -3469,7 +3583,7 @@ async function indexSalesforceMetadataFile(content, relativePath, repoPath, db,
|
|
|
3469
3583
|
if (!/<actionType>apex<\/actionType>/.test(action[1]))
|
|
3470
3584
|
continue;
|
|
3471
3585
|
for (const entry of staticXmlTagValues(action[1], "actionName")) {
|
|
3472
|
-
const target =
|
|
3586
|
+
const target = /^([A-Za-z]\w*)\.([A-Za-z]\w*)$/.exec(entry.value);
|
|
3473
3587
|
if (!target)
|
|
3474
3588
|
continue;
|
|
3475
3589
|
const classFile = findApexMethodFilePath(target[1], target[2], repoPath);
|
|
@@ -3478,7 +3592,7 @@ async function indexSalesforceMetadataFile(content, relativePath, repoPath, db,
|
|
|
3478
3592
|
}
|
|
3479
3593
|
}
|
|
3480
3594
|
for (const entry of staticXmlTagValues(content, "extensionName")) {
|
|
3481
|
-
const component =
|
|
3595
|
+
const component = /^c:([A-Za-z]\w*)$/.exec(entry.value);
|
|
3482
3596
|
if (!component)
|
|
3483
3597
|
continue;
|
|
3484
3598
|
const target = findSalesforceLwcComponentFile(component[1], repoPath);
|
|
@@ -3548,14 +3662,14 @@ async function indexLwcBundleFile(content, relativePath, repoPath, db, bundle) {
|
|
|
3548
3662
|
continue;
|
|
3549
3663
|
if (source.startsWith("c/")) {
|
|
3550
3664
|
const name = source.slice(2);
|
|
3551
|
-
if (/^[A-Za-z]
|
|
3665
|
+
if (/^[A-Za-z]\w*$/.test(name)) {
|
|
3552
3666
|
const target = findLwcComponentFile(relativePath, name, repoPath);
|
|
3553
3667
|
if (target)
|
|
3554
3668
|
dependencies.push({ to: target, kind: "lwc_component" });
|
|
3555
3669
|
}
|
|
3556
3670
|
continue;
|
|
3557
3671
|
}
|
|
3558
|
-
const apex =
|
|
3672
|
+
const apex = /^@salesforce\/apex\/([A-Za-z]\w*)\.([A-Za-z]\w*)$/.exec(source);
|
|
3559
3673
|
if (apex && !source.includes("__")) {
|
|
3560
3674
|
const target = findApexMethodFilePath(apex[1], apex[2], repoPath);
|
|
3561
3675
|
if (target) {
|
|
@@ -3570,7 +3684,7 @@ async function indexLwcBundleFile(content, relativePath, repoPath, db, bundle) {
|
|
|
3570
3684
|
}
|
|
3571
3685
|
continue;
|
|
3572
3686
|
}
|
|
3573
|
-
const schema =
|
|
3687
|
+
const schema = /^@salesforce\/schema\/([^/]+)$/.exec(source);
|
|
3574
3688
|
if (schema && isUnnamespacedSalesforceIdentifier(schema[1])) {
|
|
3575
3689
|
dependencies.push({
|
|
3576
3690
|
to: schema[1],
|
|
@@ -3579,7 +3693,7 @@ async function indexLwcBundleFile(content, relativePath, repoPath, db, bundle) {
|
|
|
3579
3693
|
continue;
|
|
3580
3694
|
}
|
|
3581
3695
|
if (source.startsWith("lightning/")) {
|
|
3582
|
-
for (const binding of bindings.matchAll(/
|
|
3696
|
+
for (const binding of bindings.matchAll(/[{,]\s*([A-Za-z_$][\w$]*)/g)) {
|
|
3583
3697
|
if (binding[1])
|
|
3584
3698
|
importedNames.set(binding[1], source);
|
|
3585
3699
|
}
|
|
@@ -3685,11 +3799,32 @@ export function parseWqlQuery(query) {
|
|
|
3685
3799
|
fields: [],
|
|
3686
3800
|
parameters: [],
|
|
3687
3801
|
};
|
|
3688
|
-
const fromMatch =
|
|
3802
|
+
const fromMatch = /FROM\s+(\w+)/i.exec(query);
|
|
3689
3803
|
if (fromMatch) {
|
|
3690
3804
|
result.table = fromMatch[1];
|
|
3691
3805
|
}
|
|
3692
|
-
|
|
3806
|
+
// The field-list pattern is lazy and retries at every position where SELECT
|
|
3807
|
+
// could begin, so on a long single-line query with no FROM it rescans to end
|
|
3808
|
+
// of input from each one. Measured at ratio ~4.0 per doubling — 2.7s on a
|
|
3809
|
+
// 288 KB query.
|
|
3810
|
+
//
|
|
3811
|
+
// The pattern cannot match without `\s+FROM` present, so proving that
|
|
3812
|
+
// substring absent is exactly equivalent to running it, and costs one linear
|
|
3813
|
+
// pass with no quantifier to backtrack over. Equivalence checked on 14 cases
|
|
3814
|
+
// including nested SELECT, FROM without SELECT, tabs, newlines and
|
|
3815
|
+
// "SELECT Id FROMAccount", plus 100,000 randomised inputs: zero differences.
|
|
3816
|
+
// Degenerate input at n=32,000 falls from 2698ms to 0.078ms, ratio 1.40.
|
|
3817
|
+
//
|
|
3818
|
+
// S8786 still reports the literal, because the rule reads the pattern and
|
|
3819
|
+
// cannot see the guard in front of it. Unlike the brace pattern above, this
|
|
3820
|
+
// one is genuinely super-linear in isolation — what is bounded is the call,
|
|
3821
|
+
// not the regex. Rewriting it as an index scan would satisfy the rule, but
|
|
3822
|
+
// `.` excluding newline makes the equivalent scan subtle enough that the
|
|
3823
|
+
// measured guard is the safer trade for a release. Recorded here rather than
|
|
3824
|
+
// suppressed silently; worth revisiting with the field-list parser.
|
|
3825
|
+
const selectMatch = /\sFROM/i.test(query)
|
|
3826
|
+
? /SELECT\s+(.+?)\s+FROM/i.exec(query) // NOSONAR S8786 — guarded above, measured linear
|
|
3827
|
+
: null;
|
|
3693
3828
|
if (selectMatch) {
|
|
3694
3829
|
const fieldsPart = selectMatch[1];
|
|
3695
3830
|
let currentField = "";
|
|
@@ -3718,7 +3853,7 @@ export function parseWqlQuery(query) {
|
|
|
3718
3853
|
if (finalF)
|
|
3719
3854
|
result.fields.push(finalF);
|
|
3720
3855
|
}
|
|
3721
|
-
const paramMatches = query.matchAll(/(?:\?(
|
|
3856
|
+
const paramMatches = query.matchAll(/(?:\?(\w+)?|:(\w+)|@\{(\w+)\})/g);
|
|
3722
3857
|
for (const match of paramMatches) {
|
|
3723
3858
|
const param = match[1] || match[2] || match[3] || "?";
|
|
3724
3859
|
if (!result.parameters.includes(param)) {
|
|
@@ -3774,7 +3909,7 @@ async function indexLsifDump(absolutePath, _relativePath, repoPath, db) {
|
|
|
3774
3909
|
}
|
|
3775
3910
|
else if (item.type === "edge") {
|
|
3776
3911
|
const outV = item.outV.toString();
|
|
3777
|
-
const inVs = item.inVs ? item.inVs.map(
|
|
3912
|
+
const inVs = item.inVs ? item.inVs.map(String) : [item.inV.toString()];
|
|
3778
3913
|
if (item.label === "contains") {
|
|
3779
3914
|
for (const rId of inVs) {
|
|
3780
3915
|
const r = ranges.get(rId);
|
|
@@ -3862,7 +3997,7 @@ async function indexLsifDump(absolutePath, _relativePath, repoPath, db) {
|
|
|
3862
3997
|
const lines = summary.split("\n");
|
|
3863
3998
|
if (lines.length > 0) {
|
|
3864
3999
|
const firstLine = lines[0].replace(/```\w*/, "").trim();
|
|
3865
|
-
const fnMatch =
|
|
4000
|
+
const fnMatch = /(?:function|class|interface|const|let|var)\s+(\w+)/.exec(firstLine);
|
|
3866
4001
|
if (fnMatch) {
|
|
3867
4002
|
name = fnMatch[1];
|
|
3868
4003
|
}
|
|
@@ -3903,9 +4038,36 @@ async function indexLsifDump(absolutePath, _relativePath, repoPath, db) {
|
|
|
3903
4038
|
console.error("Error indexing LSIF:", error);
|
|
3904
4039
|
}
|
|
3905
4040
|
}
|
|
4041
|
+
/**
|
|
4042
|
+
* Replace the imported-findings tier for one source, leaving graph facts and any
|
|
4043
|
+
* other tool's findings untouched. Re-importing the same log is idempotent.
|
|
4044
|
+
*/
|
|
4045
|
+
function persistSarifFindings(db, facts) {
|
|
4046
|
+
const insert = db.prepare(`
|
|
4047
|
+
INSERT INTO findings
|
|
4048
|
+
(ruleId, tool, level, message, filePath, startLine, startCol, endLine, endCol, helpUri, source)
|
|
4049
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
4050
|
+
`);
|
|
4051
|
+
db.run("BEGIN TRANSACTION;");
|
|
4052
|
+
try {
|
|
4053
|
+
// Scoped to the tools in this log: importing an ESLint log must not delete
|
|
4054
|
+
// PMD findings imported earlier from a different run.
|
|
4055
|
+
for (const tool of facts.tools)
|
|
4056
|
+
db.run("DELETE FROM findings WHERE tool = ?", [tool]);
|
|
4057
|
+
for (const finding of facts.findings) {
|
|
4058
|
+
insert.run(finding.ruleId, finding.tool, finding.level, finding.message, finding.filePath, finding.startLine, finding.startColumn, finding.endLine, finding.endColumn, finding.helpUri ?? null, facts.inputPath);
|
|
4059
|
+
}
|
|
4060
|
+
db.run("COMMIT;");
|
|
4061
|
+
}
|
|
4062
|
+
catch (error) {
|
|
4063
|
+
db.run("ROLLBACK;");
|
|
4064
|
+
throw error;
|
|
4065
|
+
}
|
|
4066
|
+
return facts.findings.length;
|
|
4067
|
+
}
|
|
3906
4068
|
/** Replace only the optional SCIP tier, preserving native and LSIF facts verbatim. */
|
|
3907
4069
|
function persistScipFacts(db, repoPath, facts) {
|
|
3908
|
-
const affectedFiles = [...new Set(facts.symbols.map((symbol) => symbol.filePath))].sort();
|
|
4070
|
+
const affectedFiles = [...new Set(facts.symbols.map((symbol) => symbol.filePath))].sort(compareBytes);
|
|
3909
4071
|
const insertSymbol = db.prepare(`
|
|
3910
4072
|
INSERT INTO symbols (name, kind, filePath, startLine, endLine, startCol, endCol, summary)
|
|
3911
4073
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
@@ -3972,8 +4134,6 @@ function invalidateScipFacts(db) {
|
|
|
3972
4134
|
}
|
|
3973
4135
|
async function indexTerraformFile(content, relativePath, _repoPath, db) {
|
|
3974
4136
|
const symbols = [];
|
|
3975
|
-
const references = [];
|
|
3976
|
-
const dependencies = [];
|
|
3977
4137
|
const lines = content.split("\n");
|
|
3978
4138
|
const blockRegex = /^\s*(resource|data|module)\s+"([^"]+)"\s+"([^"]+)"\s*\{/i;
|
|
3979
4139
|
const blockRegexAlt = /^\s*(resource|data|module)\s+"([^"]+)"\s*\{/i;
|
|
@@ -4024,23 +4184,7 @@ async function indexTerraformFile(content, relativePath, _repoPath, db) {
|
|
|
4024
4184
|
for (const def of symbols) {
|
|
4025
4185
|
insertSym.run(def.name, def.kind, relativePath, def.startLine, def.endLine, def.startCol, def.endCol, def.summary);
|
|
4026
4186
|
}
|
|
4027
|
-
const insertRef = db.prepare(`
|
|
4028
|
-
INSERT INTO "references" (callerSymbol, callerFile, calleeSymbol, calleeFile, line, column)
|
|
4029
|
-
VALUES (?, ?, ?, NULL, ?, ?)
|
|
4030
|
-
`);
|
|
4031
|
-
for (const ref of references) {
|
|
4032
|
-
insertRef.run(ref.callerSymbol, relativePath, ref.calleeSymbol, ref.line, ref.column);
|
|
4033
|
-
}
|
|
4034
|
-
const insertDep = db.prepare(`
|
|
4035
|
-
INSERT INTO dependencies (fromFile, toFile, kind, confidence)
|
|
4036
|
-
VALUES (?, ?, ?, ?)
|
|
4037
|
-
`);
|
|
4038
|
-
for (const dep of dependencies) {
|
|
4039
|
-
insertDep.run(relativePath, dep.toFile, dep.kind, dep.confidence);
|
|
4040
|
-
}
|
|
4041
4187
|
insertSym.finalize();
|
|
4042
|
-
insertRef.finalize();
|
|
4043
|
-
insertDep.finalize();
|
|
4044
4188
|
db.run("COMMIT;");
|
|
4045
4189
|
}
|
|
4046
4190
|
catch (error) {
|
|
@@ -4102,6 +4246,385 @@ async function indexDockerFile(content, relativePath, _repoPath, db) {
|
|
|
4102
4246
|
throw error;
|
|
4103
4247
|
}
|
|
4104
4248
|
}
|
|
4249
|
+
/**
|
|
4250
|
+
* Removes fenced blocks and inline code spans. A document demonstrating markdown
|
|
4251
|
+
* would otherwise contribute its examples as real edges, which is worse than
|
|
4252
|
+
* missing them: a fabricated dependency is indistinguishable from a true one.
|
|
4253
|
+
*/
|
|
4254
|
+
function stripMarkdownCode(content) {
|
|
4255
|
+
return content
|
|
4256
|
+
.replace(/^```[\s\S]*?^```/gm, "")
|
|
4257
|
+
.replace(/^~~~[\s\S]*?^~~~/gm, "")
|
|
4258
|
+
.replace(/`[^`\n]*`/g, "");
|
|
4259
|
+
}
|
|
4260
|
+
// Linear-time by construction, which took three attempts to actually achieve.
|
|
4261
|
+
// Documentation is untrusted input here — it arrives in a mirror of someone
|
|
4262
|
+
// else's repository — so a quadratic scan is a denial-of-service risk, not a
|
|
4263
|
+
// style problem.
|
|
4264
|
+
//
|
|
4265
|
+
// Two things are required, and the first two attempts each got only one:
|
|
4266
|
+
//
|
|
4267
|
+
// 1. No ambiguity *within* an attempt. The optional `<…>` wrapper and trailing
|
|
4268
|
+
// `"title"` are no longer spelled out here; `markdownLinkTarget` splits them
|
|
4269
|
+
// off in code. Each class below is disjoint from what follows it.
|
|
4270
|
+
// 2. No quadratic blow-up *across* attempts. Excluding `[` from both classes is
|
|
4271
|
+
// what buys this. Without it, input like `[a[a[a…` with no closing bracket
|
|
4272
|
+
// starts an attempt at every `[`, and each one scans to end of input.
|
|
4273
|
+
//
|
|
4274
|
+
// A link target containing a literal `[` is therefore not recorded. That is the
|
|
4275
|
+
// safe direction: a missing edge, never a wrong one.
|
|
4276
|
+
const MARKDOWN_INLINE_LINK = /!?\[[^[\]\n]*\]\(([^[)\n]*)\)/g;
|
|
4277
|
+
// The label class excludes newlines. Without that, `[^\]]+` matches across
|
|
4278
|
+
// lines, so a stray unclosed `[` anywhere in a document swallows everything up
|
|
4279
|
+
// to the next `]:` and records an edge to whatever file happens to follow it —
|
|
4280
|
+
// a *fabricated* edge, which is worse than a missing one, because downstream it
|
|
4281
|
+
// is indistinguishable from a real dependency. A reference definition is a
|
|
4282
|
+
// single-line construct; the pattern now says so.
|
|
4283
|
+
const MARKDOWN_REFERENCE_DEFINITION = /^[ \t]{0,3}\[[^\]\n]+\]:[ \t]*<?([^>\s]+)>?/gm;
|
|
4284
|
+
/**
|
|
4285
|
+
* Extracts the target from a markdown link's parenthesised body, discarding an
|
|
4286
|
+
* optional `<…>` wrapper and a trailing `"title"`. Done in code rather than in
|
|
4287
|
+
* {@link MARKDOWN_INLINE_LINK} so the pattern stays backtrack-free.
|
|
4288
|
+
*/
|
|
4289
|
+
function markdownLinkTarget(inner) {
|
|
4290
|
+
const trimmed = inner.trim();
|
|
4291
|
+
if (trimmed.startsWith("<")) {
|
|
4292
|
+
const close = trimmed.indexOf(">");
|
|
4293
|
+
return close === -1 ? trimmed.slice(1) : trimmed.slice(1, close);
|
|
4294
|
+
}
|
|
4295
|
+
const titleAt = trimmed.search(/[ \t]/);
|
|
4296
|
+
return titleAt === -1 ? trimmed : trimmed.slice(0, titleAt);
|
|
4297
|
+
}
|
|
4298
|
+
/** C0 controls and DEL. Never legitimate in a link target; see below. */
|
|
4299
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point
|
|
4300
|
+
const CONTROL_CHARACTERS = /[\x00-\x1f\x7f]/;
|
|
4301
|
+
/**
|
|
4302
|
+
* Resolves a markdown link target to a repository-relative path, or null when it
|
|
4303
|
+
* does not denote one (external URL, bare anchor, or an escape above the root).
|
|
4304
|
+
*
|
|
4305
|
+
* In a mirror this parses documentation from a repository nobody here has read,
|
|
4306
|
+
* so the target is untrusted. Two rejections exist for that reason:
|
|
4307
|
+
*
|
|
4308
|
+
* - Control characters, NUL above all. A NUL would otherwise be stored as part
|
|
4309
|
+
* of `toFile` and echoed in evidence, and C-based consumers truncate there:
|
|
4310
|
+
* a grep over such a path silently reports no matches, which reads as "this
|
|
4311
|
+
* file has no references" rather than "your query could not match". That
|
|
4312
|
+
* exact confusion cost real debugging time in this repository.
|
|
4313
|
+
* - Backslashes, normalized to `/` BEFORE the traversal check rather than
|
|
4314
|
+
* rejected. `path.posix` treats `..\..\x` as one odd filename, so the `..`
|
|
4315
|
+
* guard below never fires — and `path.join` on Windows would then read those
|
|
4316
|
+
* backslashes as separators and escape the repository. Normalizing first
|
|
4317
|
+
* makes the existing guard see the traversal it was written to catch.
|
|
4318
|
+
*/
|
|
4319
|
+
function resolveDocumentLink(target, relativePath) {
|
|
4320
|
+
if (CONTROL_CHARACTERS.test(target))
|
|
4321
|
+
return null;
|
|
4322
|
+
// Any scheme (http:, https:, mailto:, ftp:) is off-repository.
|
|
4323
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(target))
|
|
4324
|
+
return null;
|
|
4325
|
+
const forwardSlashed = target.replaceAll("\\", "/");
|
|
4326
|
+
if (forwardSlashed.startsWith("//"))
|
|
4327
|
+
return null;
|
|
4328
|
+
const withoutFragment = forwardSlashed.split("#")[0].split("?")[0];
|
|
4329
|
+
if (!withoutFragment)
|
|
4330
|
+
return null;
|
|
4331
|
+
const normalized = relativePath.replaceAll("\\", "/");
|
|
4332
|
+
const joined = withoutFragment.startsWith("/")
|
|
4333
|
+
? path.posix.normalize(withoutFragment.slice(1))
|
|
4334
|
+
: path.posix.normalize(path.posix.join(path.posix.dirname(normalized), withoutFragment));
|
|
4335
|
+
if (joined.startsWith("..") || joined === "." || joined === "")
|
|
4336
|
+
return null;
|
|
4337
|
+
return joined;
|
|
4338
|
+
}
|
|
4339
|
+
/**
|
|
4340
|
+
* Link-only indexer for markdown. Produces no symbols — only `dependencies`
|
|
4341
|
+
* edges — which the schema permits: nothing joins `dependencies` to `symbols`,
|
|
4342
|
+
* and the health audit's orphan predicates key on `fromFile` being in
|
|
4343
|
+
* `index_state`, never on the file having contributed symbols.
|
|
4344
|
+
*
|
|
4345
|
+
* A target that resolves inside the repository but does not exist is recorded as
|
|
4346
|
+
* `doc_link_broken` rather than dropped, which is what makes stale documentation
|
|
4347
|
+
* a queryable fact instead of an absence.
|
|
4348
|
+
*/
|
|
4349
|
+
async function indexMarkdownFile(content, relativePath, repoPath, db) {
|
|
4350
|
+
const edges = new Map();
|
|
4351
|
+
const body = stripMarkdownCode(content);
|
|
4352
|
+
const record = (rawTarget) => {
|
|
4353
|
+
const resolved = resolveDocumentLink(rawTarget, relativePath);
|
|
4354
|
+
if (!resolved || resolved === relativePath)
|
|
4355
|
+
return;
|
|
4356
|
+
const exists = fs.existsSync(path.join(repoPath, resolved));
|
|
4357
|
+
const kind = exists ? "doc_link" : "doc_link_broken";
|
|
4358
|
+
const key = `${kind}:${resolved}`;
|
|
4359
|
+
if (!edges.has(key))
|
|
4360
|
+
edges.set(key, {
|
|
4361
|
+
toFile: resolved,
|
|
4362
|
+
kind,
|
|
4363
|
+
// A broken target is a real observation but a weaker claim about
|
|
4364
|
+
// structure, so it never carries full confidence.
|
|
4365
|
+
confidence: exists ? 1.0 : 0.5,
|
|
4366
|
+
evidence: rawTarget.slice(0, 500),
|
|
4367
|
+
});
|
|
4368
|
+
};
|
|
4369
|
+
for (const match of body.matchAll(MARKDOWN_INLINE_LINK))
|
|
4370
|
+
record(markdownLinkTarget(match[1]));
|
|
4371
|
+
for (const match of body.matchAll(MARKDOWN_REFERENCE_DEFINITION))
|
|
4372
|
+
record(match[1]);
|
|
4373
|
+
persistLinkOnlyEdges(db, relativePath, [...edges.values()]);
|
|
4374
|
+
}
|
|
4375
|
+
/**
|
|
4376
|
+
* Well-known CI keys that name another pipeline file. Deliberately NOT general
|
|
4377
|
+
* YAML understanding: an over-broad extractor here would emit confident,
|
|
4378
|
+
* unfalsifiable edges from arbitrary config, which is worse than no coverage.
|
|
4379
|
+
*
|
|
4380
|
+
* `uses:` is matched only for workspace-relative composite actions (`./...`),
|
|
4381
|
+
* which GitHub resolves from the REPOSITORY ROOT. `template:`/`local:` are
|
|
4382
|
+
* resolved relative to the CONTAINING FILE, per Azure Pipelines and GitLab CI.
|
|
4383
|
+
* Registry references (`actions/checkout@v4`, `file.yml@resource`) are not files
|
|
4384
|
+
* in this repository and are skipped rather than recorded as fake paths.
|
|
4385
|
+
*/
|
|
4386
|
+
const YAML_ROOT_RELATIVE_REF = /^[ \t-]*uses:[ \t]*["']?(\.[^"'\s]+)/gm;
|
|
4387
|
+
const YAML_FILE_RELATIVE_REF = /^[ \t-]*(?:template|local):[ \t]*["']?([^"'\s@]+\.ya?ml)/gm;
|
|
4388
|
+
/**
|
|
4389
|
+
* Link-only indexer for CI YAML. Produces `dependencies` edges between pipeline
|
|
4390
|
+
* files so a template's consumers are answerable; extracts no symbols.
|
|
4391
|
+
*/
|
|
4392
|
+
async function indexYamlFile(content, relativePath, repoPath, db) {
|
|
4393
|
+
const edges = new Map();
|
|
4394
|
+
const record = (rawTarget, base) => {
|
|
4395
|
+
const resolved = base === "repo-root"
|
|
4396
|
+
? resolveDocumentLink(rawTarget.replace(/^\.\//, ""), "placeholder.yml")
|
|
4397
|
+
: resolveDocumentLink(rawTarget, relativePath);
|
|
4398
|
+
if (!resolved || resolved === relativePath)
|
|
4399
|
+
return;
|
|
4400
|
+
const exists = fs.existsSync(path.join(repoPath, resolved));
|
|
4401
|
+
const kind = exists ? "workflow_ref" : "workflow_ref_broken";
|
|
4402
|
+
const key = `${kind}:${resolved}`;
|
|
4403
|
+
if (!edges.has(key))
|
|
4404
|
+
edges.set(key, {
|
|
4405
|
+
toFile: resolved,
|
|
4406
|
+
kind,
|
|
4407
|
+
confidence: exists ? 1.0 : 0.5,
|
|
4408
|
+
evidence: rawTarget.slice(0, 500),
|
|
4409
|
+
});
|
|
4410
|
+
};
|
|
4411
|
+
for (const match of content.matchAll(YAML_ROOT_RELATIVE_REF))
|
|
4412
|
+
record(match[1], "repo-root");
|
|
4413
|
+
for (const match of content.matchAll(YAML_FILE_RELATIVE_REF))
|
|
4414
|
+
record(match[1], "containing-file");
|
|
4415
|
+
persistLinkOnlyEdges(db, relativePath, [...edges.values()]);
|
|
4416
|
+
}
|
|
4417
|
+
/**
|
|
4418
|
+
* MSBuild project-to-project edges. `<ProjectReference>` is the dependency graph
|
|
4419
|
+
* between .NET projects; `<Import>` pulls in shared `.props`/`.targets`.
|
|
4420
|
+
* `<PackageReference>` is deliberately excluded — a NuGet package is not a file
|
|
4421
|
+
* in this repository, and recording one as a path would invent a target.
|
|
4422
|
+
*/
|
|
4423
|
+
// Both classes exclude `<` as well as `>`, and that is what keeps the scan
|
|
4424
|
+
// linear rather than quadratic. Measured before and after on `.csproj` content
|
|
4425
|
+
// whose tags are never closed: with `[^>]*?` alone, 28k/56k/112k chars took
|
|
4426
|
+
// 6.0/22.7/92.5ms — doubling the input roughly quadrupled the time, because the
|
|
4427
|
+
// lazy scan runs to end of input from every `<ProjectReference` and a new
|
|
4428
|
+
// attempt starts at every one. Excluding `<` makes an attempt die at the next
|
|
4429
|
+
// tag instead. This is also the correct reading of XML: a `<` cannot appear
|
|
4430
|
+
// inside a tag or an attribute value, so nothing valid is lost.
|
|
4431
|
+
//
|
|
4432
|
+
// It matters because `.csproj`/`.props`/`.targets` are indexed from mirrors —
|
|
4433
|
+
// content from a repository nobody here has read.
|
|
4434
|
+
const MSBUILD_PROJECT_REFERENCE = /<(?:ProjectReference|Import)\s[^><]*?(?:Include|Project)="([^"<]+)"/g;
|
|
4435
|
+
/** `Project("{TYPE-GUID}") = "Name", "relative\path.csproj", "{PROJECT-GUID}"`. */
|
|
4436
|
+
// Every class excludes newlines, for the same reason as the markdown reference
|
|
4437
|
+
// definition above: a `.sln` project entry occupies one line, and letting the
|
|
4438
|
+
// classes span lines lets a malformed entry reach across the file and record a
|
|
4439
|
+
// path that was never written next to it.
|
|
4440
|
+
const SOLUTION_PROJECT_ENTRY = /^Project\("\{[^}\n]*\}"\)\s*=\s*"[^"\n]*",\s*"([^"\n]+)"/gm;
|
|
4441
|
+
/**
|
|
4442
|
+
* Link-only indexer for the .NET build graph: `.csproj`/`.props`/`.targets` via
|
|
4443
|
+
* MSBuild XML, and `.sln` via its own line format (which is not XML). Produces
|
|
4444
|
+
* `dependencies` edges only.
|
|
4445
|
+
*/
|
|
4446
|
+
async function indexDotnetProjectFile(content, relativePath, repoPath, db) {
|
|
4447
|
+
const edges = new Map();
|
|
4448
|
+
const record = (rawTarget) => {
|
|
4449
|
+
// MSBuild and solution files use Windows separators even on Unix.
|
|
4450
|
+
const resolved = resolveDocumentLink(rawTarget.replaceAll("\\", "/"), relativePath);
|
|
4451
|
+
if (!resolved || resolved === relativePath)
|
|
4452
|
+
return;
|
|
4453
|
+
const exists = fs.existsSync(path.join(repoPath, resolved));
|
|
4454
|
+
const kind = exists ? "project_ref" : "project_ref_broken";
|
|
4455
|
+
const key = `${kind}:${resolved}`;
|
|
4456
|
+
if (!edges.has(key))
|
|
4457
|
+
edges.set(key, {
|
|
4458
|
+
toFile: resolved,
|
|
4459
|
+
kind,
|
|
4460
|
+
confidence: exists ? 1.0 : 0.5,
|
|
4461
|
+
evidence: rawTarget.slice(0, 500),
|
|
4462
|
+
});
|
|
4463
|
+
};
|
|
4464
|
+
const pattern = relativePath.endsWith(".sln")
|
|
4465
|
+
? SOLUTION_PROJECT_ENTRY
|
|
4466
|
+
: MSBUILD_PROJECT_REFERENCE;
|
|
4467
|
+
for (const match of content.matchAll(pattern))
|
|
4468
|
+
record(match[1]);
|
|
4469
|
+
persistLinkOnlyEdges(db, relativePath, [...edges.values()]);
|
|
4470
|
+
}
|
|
4471
|
+
/** Net brace depth change across a line, ignoring braces inside strings. */
|
|
4472
|
+
function netBraceDepth(line) {
|
|
4473
|
+
let depth = 0;
|
|
4474
|
+
let quote = null;
|
|
4475
|
+
for (let i = 0; i < line.length; i++) {
|
|
4476
|
+
const char = line[i];
|
|
4477
|
+
if (quote) {
|
|
4478
|
+
if (char === "\\")
|
|
4479
|
+
i++;
|
|
4480
|
+
else if (char === quote)
|
|
4481
|
+
quote = null;
|
|
4482
|
+
continue;
|
|
4483
|
+
}
|
|
4484
|
+
if (char === '"' || char === "'")
|
|
4485
|
+
quote = char;
|
|
4486
|
+
else if (char === "{")
|
|
4487
|
+
depth++;
|
|
4488
|
+
else if (char === "}")
|
|
4489
|
+
depth--;
|
|
4490
|
+
}
|
|
4491
|
+
return depth;
|
|
4492
|
+
}
|
|
4493
|
+
/**
|
|
4494
|
+
* Projects a Razor file onto a C# buffer with the SAME NUMBER OF LINES, keeping
|
|
4495
|
+
* `@code` / `@functions` member bodies at their original line positions and
|
|
4496
|
+
* blanking everything else.
|
|
4497
|
+
*
|
|
4498
|
+
* Line geometry is preserved deliberately rather than reconstructed: symbols
|
|
4499
|
+
* parsed out of the projection carry line numbers that are already correct for
|
|
4500
|
+
* the original file, so there is no offset arithmetic to get wrong. That matters
|
|
4501
|
+
* more here than coverage — a Razor symbol reported at the wrong line is
|
|
4502
|
+
* confidently misleading evidence, which is worse than not indexing Razor at all.
|
|
4503
|
+
*
|
|
4504
|
+
* Only the opening `@code {` line is rewritten (to `class __RazorCode {`), so
|
|
4505
|
+
* columns on that one line shift; every member line is copied verbatim and keeps
|
|
4506
|
+
* both its line and column. Returns null when the file declares no member block.
|
|
4507
|
+
*/
|
|
4508
|
+
/** Not global: this is tested per line, so `exec` must not carry lastIndex. */
|
|
4509
|
+
const RAZOR_MEMBER_BLOCK = /@(?:code|functions)\s*\{/;
|
|
4510
|
+
function projectRazorToCSharp(content) {
|
|
4511
|
+
const lines = content.split("\n");
|
|
4512
|
+
const projected = new Array(lines.length).fill("");
|
|
4513
|
+
let depth = 0;
|
|
4514
|
+
let found = false;
|
|
4515
|
+
for (let index = 0; index < lines.length; index++) {
|
|
4516
|
+
const line = lines[index];
|
|
4517
|
+
if (depth === 0) {
|
|
4518
|
+
const opener = RAZOR_MEMBER_BLOCK.exec(line);
|
|
4519
|
+
if (!opener)
|
|
4520
|
+
continue;
|
|
4521
|
+
const at = line.indexOf(opener[0]);
|
|
4522
|
+
const tail = line.slice(at + opener[0].length);
|
|
4523
|
+
projected[index] = `${" ".repeat(at)}class __RazorCode {${tail}`;
|
|
4524
|
+
depth = 1 + netBraceDepth(tail);
|
|
4525
|
+
found = true;
|
|
4526
|
+
if (depth <= 0)
|
|
4527
|
+
depth = 0;
|
|
4528
|
+
continue;
|
|
4529
|
+
}
|
|
4530
|
+
projected[index] = line;
|
|
4531
|
+
depth += netBraceDepth(line);
|
|
4532
|
+
if (depth <= 0)
|
|
4533
|
+
depth = 0;
|
|
4534
|
+
}
|
|
4535
|
+
return found ? projected.join("\n") : null;
|
|
4536
|
+
}
|
|
4537
|
+
/**
|
|
4538
|
+
* Indexes the C# members declared in a Razor file's `@code` / `@functions`
|
|
4539
|
+
* block, reusing the existing C# grammar and extractor rather than describing
|
|
4540
|
+
* Razor as its own language. Markup, directives and inline expressions are not
|
|
4541
|
+
* indexed.
|
|
4542
|
+
*/
|
|
4543
|
+
async function indexRazorFile(content, relativePath, _repoPath, db) {
|
|
4544
|
+
const projected = projectRazorToCSharp(content);
|
|
4545
|
+
if (!projected)
|
|
4546
|
+
return;
|
|
4547
|
+
const language = await getLanguageForFile("razor-projection.cs");
|
|
4548
|
+
if (!language)
|
|
4549
|
+
return;
|
|
4550
|
+
const parser = new Parser();
|
|
4551
|
+
parser.setLanguage(language);
|
|
4552
|
+
const tree = parser.parse(projected);
|
|
4553
|
+
if (!tree)
|
|
4554
|
+
return;
|
|
4555
|
+
// `filePath` drives the extractor's language-specific branches, so it must
|
|
4556
|
+
// look like C#; the rows are persisted against the real Razor path below.
|
|
4557
|
+
const extracted = extractSymbolsAndReferences(tree.rootNode, false, false, "razor-projection.cs");
|
|
4558
|
+
tree.delete();
|
|
4559
|
+
parser.delete();
|
|
4560
|
+
// The synthetic wrapper class exists only to make members parseable.
|
|
4561
|
+
const definitions = extracted.definitions.filter((def) => def.name !== "__RazorCode");
|
|
4562
|
+
db.run("BEGIN TRANSACTION;");
|
|
4563
|
+
try {
|
|
4564
|
+
db.run('DELETE FROM "references" WHERE callerFile = ?', [relativePath]);
|
|
4565
|
+
deleteSymbolsForFile(db, relativePath);
|
|
4566
|
+
db.run("DELETE FROM dependencies WHERE fromFile = ?", [relativePath]);
|
|
4567
|
+
// finalize() in a `finally`: a throw mid-loop would otherwise leave the
|
|
4568
|
+
// statement open, and an unfinalized statement can keep SQLite state alive
|
|
4569
|
+
// across the ROLLBACK below — which surfaces later as an unexplained lock
|
|
4570
|
+
// rather than as the error that actually caused it.
|
|
4571
|
+
const insertSym = db.prepare(`
|
|
4572
|
+
INSERT INTO symbols (name, kind, filePath, startLine, endLine, startCol, endCol, summary)
|
|
4573
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
4574
|
+
`);
|
|
4575
|
+
try {
|
|
4576
|
+
for (const def of definitions) {
|
|
4577
|
+
insertSym.run(def.name, def.kind, relativePath, def.startLine, def.endLine, def.startCol, def.endCol, def.summary);
|
|
4578
|
+
}
|
|
4579
|
+
}
|
|
4580
|
+
finally {
|
|
4581
|
+
insertSym.finalize();
|
|
4582
|
+
}
|
|
4583
|
+
const insertRef = db.prepare(`
|
|
4584
|
+
INSERT INTO "references" (callerSymbol, callerFile, calleeSymbol, calleeFile, line, column)
|
|
4585
|
+
VALUES (?, ?, ?, NULL, ?, ?)
|
|
4586
|
+
`);
|
|
4587
|
+
try {
|
|
4588
|
+
for (const ref of extracted.references)
|
|
4589
|
+
insertRef.run(ref.callerSymbol, relativePath, ref.calleeSymbol, ref.line, ref.column);
|
|
4590
|
+
}
|
|
4591
|
+
finally {
|
|
4592
|
+
insertRef.finalize();
|
|
4593
|
+
}
|
|
4594
|
+
db.run("COMMIT;");
|
|
4595
|
+
}
|
|
4596
|
+
catch (error) {
|
|
4597
|
+
db.run("ROLLBACK;");
|
|
4598
|
+
throw error;
|
|
4599
|
+
}
|
|
4600
|
+
}
|
|
4601
|
+
/** Standard delete-then-insert transaction shared by the link-only indexers. */
|
|
4602
|
+
function persistLinkOnlyEdges(db, relativePath, edges) {
|
|
4603
|
+
db.run("BEGIN TRANSACTION;");
|
|
4604
|
+
try {
|
|
4605
|
+
db.run('DELETE FROM "references" WHERE callerFile = ?', [relativePath]);
|
|
4606
|
+
deleteSymbolsForFile(db, relativePath);
|
|
4607
|
+
db.run("DELETE FROM dependencies WHERE fromFile = ?", [relativePath]);
|
|
4608
|
+
// See persistRazorSymbols: finalize in a `finally` so a mid-loop throw
|
|
4609
|
+
// cannot leave the statement open across the ROLLBACK.
|
|
4610
|
+
const insertDep = db.prepare(`
|
|
4611
|
+
INSERT INTO dependencies (fromFile, toFile, kind, confidence, sourceEvidence)
|
|
4612
|
+
VALUES (?, ?, ?, ?, ?)
|
|
4613
|
+
`);
|
|
4614
|
+
try {
|
|
4615
|
+
for (const edge of edges)
|
|
4616
|
+
insertDep.run(relativePath, edge.toFile, edge.kind, edge.confidence, edge.evidence);
|
|
4617
|
+
}
|
|
4618
|
+
finally {
|
|
4619
|
+
insertDep.finalize();
|
|
4620
|
+
}
|
|
4621
|
+
db.run("COMMIT;");
|
|
4622
|
+
}
|
|
4623
|
+
catch (error) {
|
|
4624
|
+
db.run("ROLLBACK;");
|
|
4625
|
+
throw error;
|
|
4626
|
+
}
|
|
4627
|
+
}
|
|
4105
4628
|
async function indexWorkdayFile(content, tree, relativePath, _repoPath, db) {
|
|
4106
4629
|
const symbols = [];
|
|
4107
4630
|
const references = [];
|
|
@@ -4189,7 +4712,7 @@ async function indexWorkdayFile(content, tree, relativePath, _repoPath, db) {
|
|
|
4189
4712
|
if (contentNode) {
|
|
4190
4713
|
for (let i = 0; i < contentNode.childCount; i++) {
|
|
4191
4714
|
const child = contentNode.child(i);
|
|
4192
|
-
if (child
|
|
4715
|
+
if (child?.type === "element") {
|
|
4193
4716
|
const childStagNode = child.children.find((c) => c.type === "STag" || c.type === "EmptyElemTag");
|
|
4194
4717
|
if (childStagNode) {
|
|
4195
4718
|
const childTagNameNode = childStagNode.children.find((c) => c.type === "Name");
|
|
@@ -4249,17 +4772,20 @@ async function indexWorkdayFile(content, tree, relativePath, _repoPath, db) {
|
|
|
4249
4772
|
const op = match[1].trim();
|
|
4250
4773
|
addDependency(op, "wws", 1.0);
|
|
4251
4774
|
}
|
|
4252
|
-
const operationTagRegex = /<(
|
|
4775
|
+
const operationTagRegex = /<(?:\w+:)?operation[^>]*>([^<]+)<\/(?:\w+:)?operation>/gi;
|
|
4253
4776
|
const tagMatches = content.matchAll(operationTagRegex);
|
|
4254
4777
|
for (const match of tagMatches) {
|
|
4255
4778
|
const op = match[1].trim();
|
|
4256
4779
|
addDependency(op, "wws", 1.0);
|
|
4257
4780
|
}
|
|
4258
|
-
|
|
4781
|
+
// The select list is matched but never read, so it stays uncaptured. Pinning it to
|
|
4782
|
+
// end on a non-whitespace character (with a bare `\s` for the single-whitespace
|
|
4783
|
+
// case) keeps `\s+FROM` from re-splitting every whitespace run, which is quadratic.
|
|
4784
|
+
const wqlQueryRegex = /SELECT\s(?:\s|[\s\S]*?(?<!\s))\s+FROM\s+(\w+)/gi;
|
|
4259
4785
|
const wqlMatches = content.matchAll(wqlQueryRegex);
|
|
4260
4786
|
for (const match of wqlMatches) {
|
|
4261
4787
|
const fullQuery = match[0];
|
|
4262
|
-
const table = match[
|
|
4788
|
+
const table = match[1].trim();
|
|
4263
4789
|
const wqlRes = parseWqlQuery(fullQuery);
|
|
4264
4790
|
const targetTable = wqlRes.table || table;
|
|
4265
4791
|
if (targetTable) {
|
|
@@ -4326,8 +4852,8 @@ async function indexAuraBundleFile(content, relativePath, repoPath, db, bundle)
|
|
|
4326
4852
|
// computed strings, and client-side action names deliberately produce no edge.
|
|
4327
4853
|
if (bundle.extension === "cmp") {
|
|
4328
4854
|
const markup = content.replace(/<!--[\s\S]*?-->/g, (comment) => comment.replace(/[^\n]/g, " "));
|
|
4329
|
-
const componentTag =
|
|
4330
|
-
const controller =
|
|
4855
|
+
const componentTag = /<aura:component\b[^>]*>/i.exec(markup)?.[0];
|
|
4856
|
+
const controller = /\bcontroller\s*=\s*["']([a-z]\w*)["']/i.exec(componentTag ?? "")?.[1];
|
|
4331
4857
|
if (controller) {
|
|
4332
4858
|
const target = findUniqueApexClassFilePath(controller, repoPath);
|
|
4333
4859
|
if (target)
|
|
@@ -4339,7 +4865,7 @@ async function indexAuraBundleFile(content, relativePath, repoPath, db, bundle)
|
|
|
4339
4865
|
}
|
|
4340
4866
|
// An Aura markup tag in the local `c` namespace is a declared, static LWC
|
|
4341
4867
|
// target only when the matching Salesforce DX LWC entry point exists.
|
|
4342
|
-
for (const match of markup.matchAll(/<c:([A-Za-z]
|
|
4868
|
+
for (const match of markup.matchAll(/<c:([A-Za-z]\w*)\b/g)) {
|
|
4343
4869
|
const target = findSalesforceLwcComponentFile(match[1], repoPath);
|
|
4344
4870
|
if (target)
|
|
4345
4871
|
dependencies.push({
|
|
@@ -4510,11 +5036,11 @@ async function indexVisualforceFile(content, tree, relativePath, repoPath, db) {
|
|
|
4510
5036
|
const pageTagMatches = content.matchAll(pageTagRegex);
|
|
4511
5037
|
for (const pageTagMatch of pageTagMatches) {
|
|
4512
5038
|
const tagContent = pageTagMatch[0];
|
|
4513
|
-
const controllerMatch =
|
|
5039
|
+
const controllerMatch = /controller\s*=\s*["']([^"']+)["']/i.exec(tagContent);
|
|
4514
5040
|
if (controllerMatch?.[1]) {
|
|
4515
5041
|
controllerNames.add(controllerMatch[1].trim());
|
|
4516
5042
|
}
|
|
4517
|
-
const extensionsMatch =
|
|
5043
|
+
const extensionsMatch = /extensions\s*=\s*["']([^"']+)["']/i.exec(tagContent);
|
|
4518
5044
|
if (extensionsMatch?.[1]) {
|
|
4519
5045
|
const extList = extensionsMatch[1]
|
|
4520
5046
|
.split(",")
|
|
@@ -4531,7 +5057,7 @@ async function indexVisualforceFile(content, tree, relativePath, repoPath, db) {
|
|
|
4531
5057
|
const exprMatches = content.matchAll(exprRegex);
|
|
4532
5058
|
for (const match of exprMatches) {
|
|
4533
5059
|
const expr = match[1];
|
|
4534
|
-
const words = expr.match(/[a-zA-Z_]
|
|
5060
|
+
const words = expr.match(/[a-zA-Z_]\w*/g);
|
|
4535
5061
|
if (words) {
|
|
4536
5062
|
for (const word of words) {
|
|
4537
5063
|
if (["true", "false", "null", "and", "or", "not", "if"].includes(word.toLowerCase())) {
|
|
@@ -4658,7 +5184,7 @@ async function indexSqlFile(content, tree, relativePath, _repoPath, db) {
|
|
|
4658
5184
|
}
|
|
4659
5185
|
if (node.type === "relation" || node.type === "object_reference") {
|
|
4660
5186
|
const refName = node.text.replace(/["`]/g, "").trim();
|
|
4661
|
-
if (refName && /^[a-zA-Z_]
|
|
5187
|
+
if (refName && /^[a-zA-Z_]\w*$/.test(refName)) {
|
|
4662
5188
|
references.push({
|
|
4663
5189
|
callerSymbol: null,
|
|
4664
5190
|
calleeSymbol: refName,
|
|
@@ -4675,7 +5201,7 @@ async function indexSqlFile(content, tree, relativePath, _repoPath, db) {
|
|
|
4675
5201
|
}
|
|
4676
5202
|
}
|
|
4677
5203
|
traverse(tree.rootNode);
|
|
4678
|
-
const procRegex = /CREATE\s+(?:OR\s+REPLACE\s+)?(PROCEDURE|PROC|FUNCTION|TRIGGER)\s+(
|
|
5204
|
+
const procRegex = /CREATE\s+(?:OR\s+REPLACE\s+)?(PROCEDURE|PROC|FUNCTION|TRIGGER)\s+(\w+)/gi;
|
|
4679
5205
|
let procMatch = procRegex.exec(content);
|
|
4680
5206
|
while (procMatch !== null) {
|
|
4681
5207
|
const type = procMatch[1].toLowerCase();
|
|
@@ -4693,7 +5219,7 @@ async function indexSqlFile(content, tree, relativePath, _repoPath, db) {
|
|
|
4693
5219
|
});
|
|
4694
5220
|
procMatch = procRegex.exec(content);
|
|
4695
5221
|
}
|
|
4696
|
-
const lineageRegex = /(?:FROM|JOIN|INTO|UPDATE)\s+(
|
|
5222
|
+
const lineageRegex = /(?:FROM|JOIN|INTO|UPDATE)\s+(\w+)/gi;
|
|
4697
5223
|
let lineageMatch = lineageRegex.exec(content);
|
|
4698
5224
|
while (lineageMatch !== null) {
|
|
4699
5225
|
const tblName = lineageMatch[1];
|
|
@@ -4763,7 +5289,7 @@ async function indexPrismaFile(content, tree, relativePath, _repoPath, db) {
|
|
|
4763
5289
|
if (block) {
|
|
4764
5290
|
for (let i = 0; i < block.childCount; i++) {
|
|
4765
5291
|
const colDecl = block.child(i);
|
|
4766
|
-
if (colDecl
|
|
5292
|
+
if (colDecl?.type === "column_declaration") {
|
|
4767
5293
|
const typeNode = colDecl.childForFieldName("type") ||
|
|
4768
5294
|
colDecl.children.find((c) => c.type === "column_type");
|
|
4769
5295
|
if (typeNode) {
|
|
@@ -4774,8 +5300,7 @@ async function indexPrismaFile(content, tree, relativePath, _repoPath, db) {
|
|
|
4774
5300
|
from: modelName,
|
|
4775
5301
|
to: targetModel,
|
|
4776
5302
|
kind: "orm-relation",
|
|
4777
|
-
}
|
|
4778
|
-
dependencies.push({
|
|
5303
|
+
}, {
|
|
4779
5304
|
from: relativePath,
|
|
4780
5305
|
to: targetModel,
|
|
4781
5306
|
kind: "orm-relation",
|
|
@@ -4795,12 +5320,14 @@ async function indexPrismaFile(content, tree, relativePath, _repoPath, db) {
|
|
|
4795
5320
|
}
|
|
4796
5321
|
}
|
|
4797
5322
|
traverse(tree.rootNode);
|
|
4798
|
-
const modelBlockRegex = /model\s+(
|
|
5323
|
+
const modelBlockRegex = /model\s+(\w+)\s*\{([^}]+)\}/g;
|
|
4799
5324
|
let mBlock = modelBlockRegex.exec(content);
|
|
4800
5325
|
while (mBlock !== null) {
|
|
4801
5326
|
const srcModel = mBlock[1];
|
|
4802
5327
|
const blockContent = mBlock[2];
|
|
4803
|
-
|
|
5328
|
+
// `(?<!\w)` pins the leading field name to a word start; without it the scan
|
|
5329
|
+
// restarts inside every identifier and re-walks it, which is quadratic.
|
|
5330
|
+
const relationRegex = /(?<!\w)\w+\s+(\w+)\s+@relation/g;
|
|
4804
5331
|
let rel = relationRegex.exec(blockContent);
|
|
4805
5332
|
while (rel !== null) {
|
|
4806
5333
|
const targetModel = rel[1];
|
|
@@ -4874,7 +5401,7 @@ export function parseSimpleNameFromUniqueId(uniqueId) {
|
|
|
4874
5401
|
const parts = uniqueId.split(".");
|
|
4875
5402
|
if (parts.length >= 3) {
|
|
4876
5403
|
if (parts[0] === "source") {
|
|
4877
|
-
return `${parts
|
|
5404
|
+
return `${parts.at(-2)}.${parts.at(-1)}`;
|
|
4878
5405
|
}
|
|
4879
5406
|
return parts[parts.length - 1];
|
|
4880
5407
|
}
|
|
@@ -5081,8 +5608,16 @@ export async function indexDbtManifestFile(content, _absolutePath, relativePath,
|
|
|
5081
5608
|
console.error(`Error indexing dbt manifest file ${relativePath}:`, error);
|
|
5082
5609
|
}
|
|
5083
5610
|
}
|
|
5084
|
-
/**
|
|
5085
|
-
|
|
5611
|
+
/**
|
|
5612
|
+
* Index a single file's symbols and references into the SQLite database.
|
|
5613
|
+
*
|
|
5614
|
+
* `unparsed`, when supplied, receives a per-extension tally of files that were
|
|
5615
|
+
* collected as indexable but produced no graph facts — no grammar claimed them,
|
|
5616
|
+
* they were rejected as minified, or indexing threw. These are invisible
|
|
5617
|
+
* otherwise: the function swallows all three cases, so a whole language silently
|
|
5618
|
+
* contributing nothing looks exactly like a language with nothing to say.
|
|
5619
|
+
*/
|
|
5620
|
+
async function indexFile(absolutePath, relativePath, repoPath, db, unparsed) {
|
|
5086
5621
|
try {
|
|
5087
5622
|
invalidateScipFacts(db);
|
|
5088
5623
|
const baseName = path.basename(absolutePath).toLowerCase();
|
|
@@ -5135,16 +5670,45 @@ async function indexFile(absolutePath, relativePath, repoPath, db) {
|
|
|
5135
5670
|
await indexSalesforceMetadataFile(content, relativePath, repoPath, db, salesforceMetadata);
|
|
5136
5671
|
return;
|
|
5137
5672
|
}
|
|
5673
|
+
if (relativePath.endsWith(".md") || relativePath.endsWith(".mdx")) {
|
|
5674
|
+
const content = measurePerfPhaseSync("read", () => fs.readFileSync(absolutePath, "utf-8"));
|
|
5675
|
+
await indexMarkdownFile(content, relativePath, repoPath, db);
|
|
5676
|
+
return;
|
|
5677
|
+
}
|
|
5678
|
+
if (relativePath.endsWith(".yml") || relativePath.endsWith(".yaml")) {
|
|
5679
|
+
const content = measurePerfPhaseSync("read", () => fs.readFileSync(absolutePath, "utf-8"));
|
|
5680
|
+
await indexYamlFile(content, relativePath, repoPath, db);
|
|
5681
|
+
return;
|
|
5682
|
+
}
|
|
5683
|
+
if (relativePath.endsWith(".csproj") ||
|
|
5684
|
+
relativePath.endsWith(".sln") ||
|
|
5685
|
+
relativePath.endsWith(".props") ||
|
|
5686
|
+
relativePath.endsWith(".targets")) {
|
|
5687
|
+
const content = measurePerfPhaseSync("read", () => fs.readFileSync(absolutePath, "utf-8"));
|
|
5688
|
+
await indexDotnetProjectFile(content, relativePath, repoPath, db);
|
|
5689
|
+
return;
|
|
5690
|
+
}
|
|
5691
|
+
if (relativePath.endsWith(".cshtml") || relativePath.endsWith(".razor")) {
|
|
5692
|
+
const content = measurePerfPhaseSync("read", () => fs.readFileSync(absolutePath, "utf-8"));
|
|
5693
|
+
await indexRazorFile(content, relativePath, repoPath, db);
|
|
5694
|
+
return;
|
|
5695
|
+
}
|
|
5138
5696
|
const lang = await measurePerfPhase("parser_wasm_init", () => getLanguageForFile(absolutePath));
|
|
5139
|
-
if (!lang)
|
|
5697
|
+
if (!lang) {
|
|
5698
|
+
if (unparsed)
|
|
5699
|
+
tallyOne(unparsed, extensionBucket(relativePath));
|
|
5140
5700
|
return;
|
|
5701
|
+
}
|
|
5141
5702
|
const content = measurePerfPhaseSync("read", () => fs.readFileSync(absolutePath, "utf-8"));
|
|
5142
5703
|
// Skip minified / generated bundles (e.g. a `*.bundle.js`): they're not
|
|
5143
5704
|
// readable source and would inject thousands of junk "symbols" that pollute
|
|
5144
5705
|
// the graph, communities, and hubs. Heuristic: a very high average line
|
|
5145
5706
|
// length (minified code packs everything onto a few enormous lines).
|
|
5146
|
-
if (isMinifiedSource(content))
|
|
5707
|
+
if (isMinifiedSource(content)) {
|
|
5708
|
+
if (unparsed)
|
|
5709
|
+
tallyOne(unparsed, extensionBucket(relativePath));
|
|
5147
5710
|
return;
|
|
5711
|
+
}
|
|
5148
5712
|
const isSql = absolutePath.endsWith(".sql") ||
|
|
5149
5713
|
absolutePath.endsWith(".pkb") ||
|
|
5150
5714
|
absolutePath.endsWith(".pks");
|
|
@@ -5305,6 +5869,8 @@ async function indexFile(absolutePath, relativePath, repoPath, db) {
|
|
|
5305
5869
|
}
|
|
5306
5870
|
catch (error) {
|
|
5307
5871
|
console.error(`Error indexing file ${relativePath}:`, error);
|
|
5872
|
+
if (unparsed)
|
|
5873
|
+
tallyOne(unparsed, extensionBucket(relativePath));
|
|
5308
5874
|
}
|
|
5309
5875
|
}
|
|
5310
5876
|
// The installed Transformers runtime was slower for every tested multi-item
|
|
@@ -5538,13 +6104,143 @@ function isTestFilePath(relPath) {
|
|
|
5538
6104
|
return /\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)(tests?|__tests__)\//i.test(relPath);
|
|
5539
6105
|
}
|
|
5540
6106
|
/**
|
|
5541
|
-
*
|
|
5542
|
-
* `
|
|
5543
|
-
*
|
|
6107
|
+
* Repo-relative paths git knows about (tracked plus untracked-but-not-ignored),
|
|
6108
|
+
* or null when `repoPath` is not itself the root of a readable git work tree —
|
|
6109
|
+
* in which case the caller falls back to walking the directory.
|
|
5544
6110
|
*/
|
|
6111
|
+
/** Extension bucket for coverage tallies; extensionless files group together. */
|
|
6112
|
+
function extensionBucket(relativePath) {
|
|
6113
|
+
const ext = path.posix.extname(relativePath.replaceAll("\\", "/")).toLowerCase();
|
|
6114
|
+
return ext === "" ? "(no extension)" : ext;
|
|
6115
|
+
}
|
|
6116
|
+
/** Adds one to `tally[bucket]`. */
|
|
6117
|
+
function tallyOne(tally, bucket) {
|
|
6118
|
+
tally.set(bucket, (tally.get(bucket) ?? 0) + 1);
|
|
6119
|
+
}
|
|
5545
6120
|
function collectRepoFiles(repoPath) {
|
|
5546
|
-
|
|
5547
|
-
|
|
6121
|
+
return collectRepoFilesWithCoverage(repoPath).files;
|
|
6122
|
+
}
|
|
6123
|
+
/** Descending-count record, so the largest coverage gap reads first. */
|
|
6124
|
+
function sortedTally(tally) {
|
|
6125
|
+
return Object.fromEntries([...tally].sort((left, right) => right[1] - left[1]));
|
|
6126
|
+
}
|
|
6127
|
+
function sumCounts(counts) {
|
|
6128
|
+
let total = 0;
|
|
6129
|
+
for (const value of Object.values(counts))
|
|
6130
|
+
total += value;
|
|
6131
|
+
return total;
|
|
6132
|
+
}
|
|
6133
|
+
/**
|
|
6134
|
+
* Reads the unparsed tally persisted by the last full index, or null when it
|
|
6135
|
+
* cannot be read at all.
|
|
6136
|
+
*
|
|
6137
|
+
* Null and `{}` mean different things and must not be collapsed. A full index
|
|
6138
|
+
* always writes this key, even when nothing was skipped, so a *present* empty
|
|
6139
|
+
* object is a real "no gaps" answer. Absent, unreadable, or malformed means no
|
|
6140
|
+
* full index has completed under this schema — the gap is unknown, not zero.
|
|
6141
|
+
* Returning `{}` for both is how "I could not measure it" becomes "there is
|
|
6142
|
+
* nothing to measure", which is the failure this whole tally exists to prevent.
|
|
6143
|
+
*
|
|
6144
|
+
* Still never throws: a status call must not fail because a tally is corrupt.
|
|
6145
|
+
*/
|
|
6146
|
+
function readUnparsedTally(db) {
|
|
6147
|
+
if (!db)
|
|
6148
|
+
return null;
|
|
6149
|
+
try {
|
|
6150
|
+
const raw = getMeta(db, COVERAGE_UNPARSED_META_KEY);
|
|
6151
|
+
if (!raw)
|
|
6152
|
+
return null;
|
|
6153
|
+
const parsed = JSON.parse(raw);
|
|
6154
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
6155
|
+
return null;
|
|
6156
|
+
const counts = {};
|
|
6157
|
+
for (const [key, value] of Object.entries(parsed))
|
|
6158
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
6159
|
+
counts[key] = value;
|
|
6160
|
+
return counts;
|
|
6161
|
+
}
|
|
6162
|
+
catch {
|
|
6163
|
+
return null;
|
|
6164
|
+
}
|
|
6165
|
+
}
|
|
6166
|
+
/**
|
|
6167
|
+
* The `mirror` block for a status result, or nothing when `repoPath` is an
|
|
6168
|
+
* ordinary checkout. Spread into the result so the key is absent rather than
|
|
6169
|
+
* undefined for non-mirrors.
|
|
6170
|
+
*/
|
|
6171
|
+
function mirrorSnapshotFor(repoPath) {
|
|
6172
|
+
const record = lookupMirror(repoPath);
|
|
6173
|
+
if (!record)
|
|
6174
|
+
return {};
|
|
6175
|
+
return { mirror: { url: record.url, sha: record.sha, fetchedAt: record.fetchedAt } };
|
|
6176
|
+
}
|
|
6177
|
+
/**
|
|
6178
|
+
* Compares indexed symbols against embedded ones. A repository with no symbols
|
|
6179
|
+
* is `ready`: nothing is missing. Failure to read either count reports `absent`
|
|
6180
|
+
* rather than assuming completeness — the whole point is to avoid claiming
|
|
6181
|
+
* coverage we cannot demonstrate.
|
|
6182
|
+
*/
|
|
6183
|
+
export function semanticReadinessFor(db) {
|
|
6184
|
+
try {
|
|
6185
|
+
const symbols = db.query("SELECT COUNT(*) count FROM symbols").get()?.count ?? 0;
|
|
6186
|
+
if (symbols === 0)
|
|
6187
|
+
return "ready";
|
|
6188
|
+
const embedded = db.query("SELECT COUNT(*) count FROM symbol_embeddings").get()
|
|
6189
|
+
?.count ?? 0;
|
|
6190
|
+
if (embedded === 0)
|
|
6191
|
+
return "absent";
|
|
6192
|
+
return embedded < symbols ? "partial" : "ready";
|
|
6193
|
+
}
|
|
6194
|
+
catch {
|
|
6195
|
+
return "absent";
|
|
6196
|
+
}
|
|
6197
|
+
}
|
|
6198
|
+
/** Worst readiness across federated repositories; any gap degrades the page. */
|
|
6199
|
+
function worstSemanticReadiness(states) {
|
|
6200
|
+
if (states.includes("absent"))
|
|
6201
|
+
return "absent";
|
|
6202
|
+
if (states.includes("partial"))
|
|
6203
|
+
return "partial";
|
|
6204
|
+
return "ready";
|
|
6205
|
+
}
|
|
6206
|
+
/** Combines policy-level skips with the last index's unparsed tally. */
|
|
6207
|
+
function buildCoverageSkips(skippedByExtension, db) {
|
|
6208
|
+
const byExtension = sortedTally(skippedByExtension);
|
|
6209
|
+
const unparsed = readUnparsedTally(db);
|
|
6210
|
+
return {
|
|
6211
|
+
byExtension,
|
|
6212
|
+
// Reported as empty when unknown so consumers reading only this field are
|
|
6213
|
+
// unchanged; `unparsedUnknown` is what tells them the emptiness is not a
|
|
6214
|
+
// measurement. `total` is then a lower bound, not a count.
|
|
6215
|
+
unparsedByExtension: unparsed ?? {},
|
|
6216
|
+
total: sumCounts(byExtension) + sumCounts(unparsed ?? {}),
|
|
6217
|
+
...(unparsed === null ? { unparsedUnknown: true } : {}),
|
|
6218
|
+
};
|
|
6219
|
+
}
|
|
6220
|
+
/**
|
|
6221
|
+
* Whether a `.json` candidate actually holds indexable symbols. Only Salesforce
|
|
6222
|
+
* experience bundles and dbt manifests do; everything else is configuration. An
|
|
6223
|
+
* unreadable or malformed file is not a manifest, so it is skipped like any
|
|
6224
|
+
* other non-source JSON rather than failing collection.
|
|
6225
|
+
*/
|
|
6226
|
+
function isIndexableJsonCandidate(file, repoPath) {
|
|
6227
|
+
if (salesforceExperienceFile(file))
|
|
6228
|
+
return true;
|
|
6229
|
+
if (path.basename(file).toLowerCase() !== "manifest.json")
|
|
6230
|
+
return false;
|
|
6231
|
+
try {
|
|
6232
|
+
return isDbtManifest(fs.readFileSync(path.join(repoPath, file), "utf-8"));
|
|
6233
|
+
}
|
|
6234
|
+
catch {
|
|
6235
|
+
// Deliberate (comment kept from main's equivalent, removed in this merge):
|
|
6236
|
+
// the candidate list is a snapshot, so a `manifest.json` may be unreadable,
|
|
6237
|
+
// deleted, or not valid UTF-8 by the time we read it. None of that is an
|
|
6238
|
+
// indexing failure — the file simply cannot be confirmed as a dbt manifest,
|
|
6239
|
+
// so it is not indexed as one.
|
|
6240
|
+
return false;
|
|
6241
|
+
}
|
|
6242
|
+
}
|
|
6243
|
+
function gitTrackedCandidates(repoPath) {
|
|
5548
6244
|
try {
|
|
5549
6245
|
const gitRoot = child_process
|
|
5550
6246
|
.execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
@@ -5553,48 +6249,63 @@ function collectRepoFiles(repoPath) {
|
|
|
5553
6249
|
stdio: ["ignore", "pipe", "ignore"],
|
|
5554
6250
|
})
|
|
5555
6251
|
.trim();
|
|
5556
|
-
if (gitRoot
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
}
|
|
5570
|
-
}
|
|
6252
|
+
if (!gitRoot || !isSameDir(gitRoot, repoPath))
|
|
6253
|
+
return null;
|
|
6254
|
+
return splitNulPaths(child_process.execFileSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
6255
|
+
cwd: repoPath,
|
|
6256
|
+
encoding: "utf8",
|
|
6257
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
6258
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
6259
|
+
})).filter((file) => {
|
|
6260
|
+
try {
|
|
6261
|
+
return fs.statSync(path.join(repoPath, file)).isFile();
|
|
6262
|
+
}
|
|
6263
|
+
catch {
|
|
6264
|
+
return false;
|
|
6265
|
+
}
|
|
6266
|
+
});
|
|
5571
6267
|
}
|
|
5572
6268
|
catch {
|
|
5573
|
-
|
|
6269
|
+
return null;
|
|
5574
6270
|
}
|
|
5575
|
-
|
|
5576
|
-
|
|
6271
|
+
}
|
|
6272
|
+
/**
|
|
6273
|
+
* Enumerate the repo-relative files knodin indexes: source files plus dbt
|
|
6274
|
+
* `manifest.json` files and Dockerfiles. Shared by the full index and the
|
|
6275
|
+
* cold-start reconcile so both see the identical candidate set.
|
|
6276
|
+
*/
|
|
6277
|
+
function collectRepoFilesWithCoverage(repoPath) {
|
|
6278
|
+
const files = [];
|
|
6279
|
+
const skippedByExtension = new Map();
|
|
6280
|
+
const candidates = gitTrackedCandidates(repoPath);
|
|
6281
|
+
// The walk deliberately does NOT pre-filter with `accept`: both branches must
|
|
6282
|
+
// see the same candidate superset so the skip tally below counts identically
|
|
6283
|
+
// whether or not the repository is a Git work tree. (main passed
|
|
6284
|
+
// `{ accept: isIndexableSourcePath }` here; filtering during the walk would
|
|
6285
|
+
// make the excluded files invisible to the tally, which is the whole point of
|
|
6286
|
+
// this function.)
|
|
6287
|
+
for (const file of candidates ?? walkRepoFiles(repoPath)) {
|
|
6288
|
+
// Pruned infrastructure (node_modules, dist, .git, state dirs) is not a
|
|
6289
|
+
// coverage gap and is never counted as one.
|
|
6290
|
+
if (!isIndexablePath(file))
|
|
6291
|
+
continue;
|
|
6292
|
+
if (!isIndexableSourcePath(file)) {
|
|
6293
|
+
tallyOne(skippedByExtension, extensionBucket(file));
|
|
5577
6294
|
continue;
|
|
5578
|
-
const baseName = path.basename(file).toLowerCase();
|
|
5579
|
-
if (file.endsWith(".json")) {
|
|
5580
|
-
// Only dbt manifest.json files hold indexable symbols; skip all other JSON.
|
|
5581
|
-
if (salesforceExperienceFile(file)) {
|
|
5582
|
-
files.push(file);
|
|
5583
|
-
}
|
|
5584
|
-
else if (baseName === "manifest.json") {
|
|
5585
|
-
try {
|
|
5586
|
-
if (isDbtManifest(fs.readFileSync(path.join(repoPath, file), "utf-8"))) {
|
|
5587
|
-
files.push(file);
|
|
5588
|
-
}
|
|
5589
|
-
}
|
|
5590
|
-
catch (_) { }
|
|
5591
|
-
}
|
|
5592
6295
|
}
|
|
5593
|
-
|
|
6296
|
+
if (!file.endsWith(".json")) {
|
|
5594
6297
|
files.push(file);
|
|
5595
6298
|
}
|
|
6299
|
+
else if (isIndexableJsonCandidate(file, repoPath)) {
|
|
6300
|
+
files.push(file);
|
|
6301
|
+
}
|
|
6302
|
+
else {
|
|
6303
|
+
tallyOne(skippedByExtension, ".json");
|
|
6304
|
+
}
|
|
5596
6305
|
}
|
|
5597
|
-
|
|
6306
|
+
// Sorted on a copy with `compareBytes`, main's shared byte-order comparator,
|
|
6307
|
+
// so both file-discovery paths produce identical machine-independent ordering.
|
|
6308
|
+
return { files: [...files].sort(compareBytes), skippedByExtension };
|
|
5598
6309
|
}
|
|
5599
6310
|
/** Current HEAD sha of a git work tree, or null when not a git repo / no commits. */
|
|
5600
6311
|
function gitHead(repoPath) {
|
|
@@ -5832,7 +6543,7 @@ async function reconcileIndex(repoPath, db, progress) {
|
|
|
5832
6543
|
changed.add(file);
|
|
5833
6544
|
const candidates = [...changed]
|
|
5834
6545
|
.filter((file) => isIndexableSourcePath(file) || indexedPaths.has(file))
|
|
5835
|
-
.sort();
|
|
6546
|
+
.sort(compareBytes);
|
|
5836
6547
|
progress?.("indexing-files", 0, `Reconciling ${candidates.length.toLocaleString()} changed files`, {
|
|
5837
6548
|
phaseTotal: candidates.length,
|
|
5838
6549
|
});
|
|
@@ -5876,6 +6587,16 @@ async function reconcileIndex(repoPath, db, progress) {
|
|
|
5876
6587
|
await indexEmbeddings(db, repoPath, progress);
|
|
5877
6588
|
indexGeneration++;
|
|
5878
6589
|
}
|
|
6590
|
+
else if (semanticReadinessFor(db) !== "ready") {
|
|
6591
|
+
// Unchanged files do NOT imply current embeddings: a deferred
|
|
6592
|
+
// (`skipEmbeddings`) or interrupted pass leaves symbols unembedded while
|
|
6593
|
+
// every file looks reconciled. Without this, the follow-up index a user
|
|
6594
|
+
// is told to run would do nothing and semantic search would stay
|
|
6595
|
+
// silently short forever.
|
|
6596
|
+
progress?.("finalizing", 0, "Completing deferred semantic embeddings");
|
|
6597
|
+
await indexEmbeddings(db, repoPath, progress);
|
|
6598
|
+
indexGeneration++;
|
|
6599
|
+
}
|
|
5879
6600
|
// Advance the stored HEAD so the next cold start diffs forward from here,
|
|
5880
6601
|
// and snapshot the working tree so the per-query guard (R9) can tell
|
|
5881
6602
|
// "dirty, and indexed that way" from "dirty, and moved since".
|
|
@@ -6073,7 +6794,8 @@ function gitWorkTreeProbe(repoPath) {
|
|
|
6073
6794
|
dirty.push(p);
|
|
6074
6795
|
}
|
|
6075
6796
|
}
|
|
6076
|
-
|
|
6797
|
+
dirty.sort(compareBytes);
|
|
6798
|
+
return { head, root, dirty };
|
|
6077
6799
|
}
|
|
6078
6800
|
/** True when two paths name the same directory (symlinks resolved). */
|
|
6079
6801
|
function isSameDir(a, b) {
|
|
@@ -6242,9 +6964,14 @@ function stalenessFor(repoPath) {
|
|
|
6242
6964
|
return freshnessProbes.get(path.resolve(repoPath))?.staleness ?? "unknown";
|
|
6243
6965
|
}
|
|
6244
6966
|
/** Recursively indexes all matching files within the repository. */
|
|
6245
|
-
async function indexRepo(repoPath, db, progress) {
|
|
6967
|
+
async function indexRepo(repoPath, db, progress, skipEmbeddings = false) {
|
|
6246
6968
|
progress?.("collecting-files", 0, "Discovering indexable files");
|
|
6247
|
-
const
|
|
6969
|
+
const collected = measurePerfPhaseSync("file_collection", () => collectRepoFilesWithCoverage(repoPath));
|
|
6970
|
+
const files = collected.files;
|
|
6971
|
+
// Files that reach the indexer but yield nothing can only be observed here,
|
|
6972
|
+
// during the parse. Persisted below so `status` can report the gap without
|
|
6973
|
+
// re-indexing the repository to rediscover it.
|
|
6974
|
+
const unparsedByExtension = new Map();
|
|
6248
6975
|
progress?.("indexing-files", 0, `Indexing ${files.length.toLocaleString()} files`, {
|
|
6249
6976
|
phaseTotal: files.length,
|
|
6250
6977
|
});
|
|
@@ -6258,7 +6985,7 @@ async function indexRepo(repoPath, db, progress) {
|
|
|
6258
6985
|
let lastYieldAt = Date.now();
|
|
6259
6986
|
for (let offset = 0; offset < files.length; offset++) {
|
|
6260
6987
|
const file = files[offset];
|
|
6261
|
-
await indexFile(path.join(repoPath, file), file, repoPath, db);
|
|
6988
|
+
await indexFile(path.join(repoPath, file), file, repoPath, db, unparsedByExtension);
|
|
6262
6989
|
recordIndexState(db, repoPath, file);
|
|
6263
6990
|
progress?.("indexing-files", offset + 1, `Indexing ${files.length.toLocaleString()} files`, {
|
|
6264
6991
|
phaseTotal: files.length,
|
|
@@ -6270,11 +6997,17 @@ async function indexRepo(repoPath, db, progress) {
|
|
|
6270
6997
|
}
|
|
6271
6998
|
progress?.("finalizing", 0, "Persisting symbol identities and semantic index");
|
|
6272
6999
|
persistSymbolIdentities(db, repoPath);
|
|
6273
|
-
|
|
7000
|
+
// Structure is complete at this point; embeddings are the expensive tail. When
|
|
7001
|
+
// deferred, `semanticReadiness` reports the gap until a later pass fills it —
|
|
7002
|
+
// `indexEmbeddings` only ever embeds symbols that lack one, so resuming is
|
|
7003
|
+
// cheap and repeating is free.
|
|
7004
|
+
if (!skipEmbeddings)
|
|
7005
|
+
await indexEmbeddings(db, repoPath, progress);
|
|
6274
7006
|
// Remember the HEAD we indexed at so a later cold start can diff forward.
|
|
6275
7007
|
setMeta(db, "lastIndexedHead", gitHead(repoPath) ?? "");
|
|
6276
7008
|
setMeta(db, "lastSuccessfulReconciliation", new Date().toISOString());
|
|
6277
7009
|
setMeta(db, "mcpBackfillVersion", "17");
|
|
7010
|
+
setMeta(db, COVERAGE_UNPARSED_META_KEY, JSON.stringify(Object.fromEntries(unparsedByExtension)));
|
|
6278
7011
|
recordFreshnessBaseline(repoPath, db);
|
|
6279
7012
|
indexGeneration++;
|
|
6280
7013
|
}
|
|
@@ -6321,7 +7054,7 @@ function startFileWatcher(repoPath, db) {
|
|
|
6321
7054
|
return queue.flushPromise;
|
|
6322
7055
|
queue.flushPromise = (async () => {
|
|
6323
7056
|
do {
|
|
6324
|
-
const paths = [...queue.pending].sort();
|
|
7057
|
+
const paths = [...queue.pending].sort(compareBytes);
|
|
6325
7058
|
queue.pending.clear();
|
|
6326
7059
|
if (paths.length === 0)
|
|
6327
7060
|
break;
|
|
@@ -6416,27 +7149,31 @@ async function getOrInitDb(repoPath, options = {}) {
|
|
|
6416
7149
|
dbPath = ":memory:";
|
|
6417
7150
|
}
|
|
6418
7151
|
else {
|
|
6419
|
-
const
|
|
6420
|
-
await fs.promises.mkdir(
|
|
6421
|
-
// Ensure .knodin is in .gitignore
|
|
6422
|
-
|
|
6423
|
-
|
|
6424
|
-
|
|
6425
|
-
|
|
6426
|
-
|
|
7152
|
+
const stateDir = resolveStateDir(normalizedPath);
|
|
7153
|
+
await fs.promises.mkdir(stateDir, { recursive: true });
|
|
7154
|
+
// Ensure .knodin is in .gitignore. Skipped for mirrors: their state
|
|
7155
|
+
// lives outside the clone, and the clone must stay byte-identical to
|
|
7156
|
+
// the remote so a refetch has nothing of ours to discard.
|
|
7157
|
+
if (mayWriteToRepository(normalizedPath)) {
|
|
7158
|
+
try {
|
|
7159
|
+
const gitignorePath = path.join(normalizedPath, ".gitignore");
|
|
7160
|
+
let gitignoreContent = "";
|
|
7161
|
+
if (fs.existsSync(gitignorePath)) {
|
|
7162
|
+
gitignoreContent = await fs.promises.readFile(gitignorePath, "utf-8");
|
|
7163
|
+
}
|
|
7164
|
+
const lines = gitignoreContent.split("\n").map((l) => l.trim());
|
|
7165
|
+
if (!lines.includes(".knodin") &&
|
|
7166
|
+
!lines.includes(".knodin/") &&
|
|
7167
|
+
!lines.includes("/.knodin")) {
|
|
7168
|
+
const prefix = gitignoreContent.length > 0 && !gitignoreContent.endsWith("\n") ? "\n" : "";
|
|
7169
|
+
await fs.promises.appendFile(gitignorePath, `${prefix}\n# knodin\n.knodin\n`);
|
|
7170
|
+
}
|
|
6427
7171
|
}
|
|
6428
|
-
|
|
6429
|
-
|
|
6430
|
-
!lines.includes(".knodin/") &&
|
|
6431
|
-
!lines.includes("/.knodin")) {
|
|
6432
|
-
const prefix = gitignoreContent.length > 0 && !gitignoreContent.endsWith("\n") ? "\n" : "";
|
|
6433
|
-
await fs.promises.appendFile(gitignorePath, `${prefix}\n# knodin\n.knodin\n`);
|
|
7172
|
+
catch (e) {
|
|
7173
|
+
console.error("Failed to update .gitignore", e);
|
|
6434
7174
|
}
|
|
6435
7175
|
}
|
|
6436
|
-
|
|
6437
|
-
console.error("Failed to update .gitignore", e);
|
|
6438
|
-
}
|
|
6439
|
-
dbPath = path.join(knodinDir, "db.sqlite");
|
|
7176
|
+
dbPath = path.join(stateDir, "db.sqlite");
|
|
6440
7177
|
}
|
|
6441
7178
|
const finishDbOpenMigration = beginPerfPhase("db_open_migration");
|
|
6442
7179
|
const db = new Database(dbPath);
|
|
@@ -6480,12 +7217,14 @@ async function getOrInitDb(repoPath, options = {}) {
|
|
|
6480
7217
|
// be revisited once as well. v20 distinguishes a known empty object from
|
|
6481
7218
|
// an unknown response shape, preserving C9's precision-first rule. v21
|
|
6482
7219
|
// adds source-only LWR/Experience Bundle topology and must revisit its
|
|
6483
|
-
// newly indexable JSON files.
|
|
6484
|
-
|
|
7220
|
+
// newly indexable JSON files. v22 admits markdown as a link-only
|
|
7221
|
+
// participant, so previously skipped documentation must be visited once
|
|
7222
|
+
// to populate its doc_link / doc_link_broken edges.
|
|
7223
|
+
const KNODIN_SCHEMA_VERSION = 22;
|
|
6485
7224
|
// Highest version whose upgrade needs the stored data REBUILT. Versions
|
|
6486
7225
|
// above it migrate in place, so an upgrade costs a DELETE rather than a
|
|
6487
7226
|
// full re-index + re-embed (~40 min of CPU on an 18k-symbol corpus).
|
|
6488
|
-
const LAST_REBUILD_SCHEMA_VERSION =
|
|
7227
|
+
const LAST_REBUILD_SCHEMA_VERSION = 22;
|
|
6489
7228
|
const versionRow = db.query("PRAGMA user_version").get();
|
|
6490
7229
|
const storedVersion = versionRow?.user_version ?? 0;
|
|
6491
7230
|
const needsMcpBackfill = storedVersion < 17;
|
|
@@ -6611,6 +7350,28 @@ async function getOrInitDb(repoPath, options = {}) {
|
|
|
6611
7350
|
value TEXT
|
|
6612
7351
|
)
|
|
6613
7352
|
`);
|
|
7353
|
+
// Imported analyzer findings. Deliberately separate from symbols and
|
|
7354
|
+
// references: those are resolved from source, a finding is another
|
|
7355
|
+
// tool's judgement about a location. Keeping it apart means graph
|
|
7356
|
+
// answers never silently inherit somebody else's opinion.
|
|
7357
|
+
db.run(`
|
|
7358
|
+
CREATE TABLE IF NOT EXISTS findings (
|
|
7359
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
7360
|
+
ruleId TEXT NOT NULL,
|
|
7361
|
+
tool TEXT NOT NULL,
|
|
7362
|
+
level TEXT NOT NULL,
|
|
7363
|
+
message TEXT,
|
|
7364
|
+
filePath TEXT NOT NULL,
|
|
7365
|
+
startLine INTEGER NOT NULL,
|
|
7366
|
+
startCol INTEGER NOT NULL,
|
|
7367
|
+
endLine INTEGER NOT NULL,
|
|
7368
|
+
endCol INTEGER NOT NULL,
|
|
7369
|
+
helpUri TEXT,
|
|
7370
|
+
source TEXT NOT NULL
|
|
7371
|
+
)
|
|
7372
|
+
`);
|
|
7373
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_findings_file ON findings(filePath)");
|
|
7374
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_findings_tool ON findings(tool)");
|
|
6614
7375
|
db.run(`
|
|
6615
7376
|
CREATE TABLE IF NOT EXISTS mcp_tools (
|
|
6616
7377
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -6800,7 +7561,7 @@ async function getFederatedRepos(repoPath) {
|
|
|
6800
7561
|
// signal, knodin federates to exactly the repos listed in `repos` (possibly
|
|
6801
7562
|
// none) and nothing else.
|
|
6802
7563
|
let autoDiscover = process.env.KNODIN_AUTO_FEDERATE === "1" || process.env.KNODIN_AUTO_FEDERATE === "true";
|
|
6803
|
-
const configPath = path.join(resolvedRepoPath, "
|
|
7564
|
+
const configPath = path.join(resolveStateDir(resolvedRepoPath), "federation.json");
|
|
6804
7565
|
if (fs.existsSync(configPath)) {
|
|
6805
7566
|
try {
|
|
6806
7567
|
const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
@@ -6841,7 +7602,7 @@ async function getFederatedRepos(repoPath) {
|
|
|
6841
7602
|
if (siblingPath === resolvedRepoPath)
|
|
6842
7603
|
continue;
|
|
6843
7604
|
const isAlreadyIndexed = dbInstances.has(path.resolve(siblingPath)) ||
|
|
6844
|
-
(!isTest && fs.existsSync(
|
|
7605
|
+
(!isTest && fs.existsSync(resolveDbPath(siblingPath)));
|
|
6845
7606
|
if (isAlreadyIndexed) {
|
|
6846
7607
|
repos.push(siblingPath);
|
|
6847
7608
|
}
|
|
@@ -6976,10 +7737,11 @@ function queryFileSymbols(db, filePath, repoPath) {
|
|
|
6976
7737
|
.map((line) => line.trim().replace(/^\d+:\s*/, ""))
|
|
6977
7738
|
.join(" ")
|
|
6978
7739
|
.replace(/\s+/g, " ")
|
|
6979
|
-
|
|
7740
|
+
// `(?<!\s)` pins the match to the start of a whitespace run (quadratic otherwise).
|
|
7741
|
+
.split(/(?<!\s)\s*(?:\{|=>)\s*/, 1)[0]
|
|
6980
7742
|
.slice(0, 500)
|
|
6981
7743
|
: "";
|
|
6982
|
-
const visibility =
|
|
7744
|
+
const visibility = /\b(public|private|protected|internal)\b/.exec(header)?.[1] ??
|
|
6983
7745
|
(definition.filePath.endsWith(".py") && definition.name.startsWith("_")
|
|
6984
7746
|
? "private"
|
|
6985
7747
|
: "default");
|
|
@@ -7521,10 +8283,10 @@ function parseUnifiedDiff(stdout) {
|
|
|
7521
8283
|
currentFile = null;
|
|
7522
8284
|
}
|
|
7523
8285
|
else if (line.startsWith("@@") && currentFile) {
|
|
7524
|
-
const match =
|
|
8286
|
+
const match = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
7525
8287
|
if (match) {
|
|
7526
|
-
const startLine = parseInt(match[1], 10);
|
|
7527
|
-
const count = match[2] !== undefined ? parseInt(match[2], 10) : 1;
|
|
8288
|
+
const startLine = Number.parseInt(match[1], 10);
|
|
8289
|
+
const count = match[2] !== undefined ? Number.parseInt(match[2], 10) : 1;
|
|
7528
8290
|
if (count > 0) {
|
|
7529
8291
|
let set = modifiedFiles.get(currentFile);
|
|
7530
8292
|
if (!set) {
|
|
@@ -7571,6 +8333,15 @@ export function parseGitDiff(base, repoPath, options = {}) {
|
|
|
7571
8333
|
if (options.files?.length) {
|
|
7572
8334
|
return new Map(options.files.map((file) => [safeReviewFile(file), new Set([Number.MAX_SAFE_INTEGER])]));
|
|
7573
8335
|
}
|
|
8336
|
+
// A mirror is cloned `--depth 1 --single-branch`, so it has no base branch and
|
|
8337
|
+
// no local history to diff against. Git would succeed and return nothing,
|
|
8338
|
+
// producing an empty review that reads as "no changes" — a confident wrong
|
|
8339
|
+
// answer about someone else's code. Fail loudly instead. Explicit `files`
|
|
8340
|
+
// above bypasses git entirely and remains valid on a mirror.
|
|
8341
|
+
const mirror = lookupMirror(repoPath);
|
|
8342
|
+
if (mirror) {
|
|
8343
|
+
throw new Error(`knodin review: ${repoPath} is a read-only mirror of ${mirror.url}, pinned at snapshot ${mirror.sha.slice(0, 12)}. It was cloned without history, so there is nothing to diff against and an empty result would be misleading. Review the source repository directly, or pass explicit files to review.`);
|
|
8344
|
+
}
|
|
7574
8345
|
if ((options.from === undefined) !== (options.to === undefined)) {
|
|
7575
8346
|
throw new Error("review revisions require both `from` and `to`");
|
|
7576
8347
|
}
|
|
@@ -7599,7 +8370,7 @@ export function parseGitDiff(base, repoPath, options = {}) {
|
|
|
7599
8370
|
function reviewChangedFiles(base, repoPath, options, modified) {
|
|
7600
8371
|
const files = new Set(modified.keys());
|
|
7601
8372
|
if (options.files?.length)
|
|
7602
|
-
return [...files].sort();
|
|
8373
|
+
return [...files].sort(compareBytes);
|
|
7603
8374
|
const scope = options.scope ?? "all";
|
|
7604
8375
|
try {
|
|
7605
8376
|
const args = ["diff", "--name-only", "-z"];
|
|
@@ -7638,7 +8409,7 @@ function reviewChangedFiles(base, repoPath, options, modified) {
|
|
|
7638
8409
|
// The diff result remains usable even if untracked discovery fails.
|
|
7639
8410
|
}
|
|
7640
8411
|
}
|
|
7641
|
-
return [...files].sort();
|
|
8412
|
+
return [...files].sort(compareBytes);
|
|
7642
8413
|
}
|
|
7643
8414
|
export function getTestFilePaths(filePath) {
|
|
7644
8415
|
const dir = path.dirname(filePath);
|
|
@@ -7670,7 +8441,7 @@ export function findDefinitionNode(rootNode, symbolName, isPython, isApex = fals
|
|
|
7670
8441
|
if (isPython) {
|
|
7671
8442
|
if (node.type === "function_definition" || node.type === "class_definition") {
|
|
7672
8443
|
const nameNode = node.childForFieldName("name");
|
|
7673
|
-
if (nameNode
|
|
8444
|
+
if (nameNode?.text === symbolName) {
|
|
7674
8445
|
isDef = true;
|
|
7675
8446
|
}
|
|
7676
8447
|
}
|
|
@@ -7680,28 +8451,26 @@ export function findDefinitionNode(rootNode, symbolName, isPython, isApex = fals
|
|
|
7680
8451
|
node.type === "method_declaration" ||
|
|
7681
8452
|
node.type === "trigger_declaration") {
|
|
7682
8453
|
const nameNode = node.childForFieldName("name") || node.children.find((c) => c.type === "identifier");
|
|
7683
|
-
if (nameNode
|
|
8454
|
+
if (nameNode?.text === symbolName) {
|
|
7684
8455
|
isDef = true;
|
|
7685
8456
|
}
|
|
7686
8457
|
}
|
|
7687
8458
|
}
|
|
7688
|
-
else
|
|
7689
|
-
|
|
7690
|
-
|
|
7691
|
-
|
|
7692
|
-
|
|
7693
|
-
|
|
7694
|
-
|
|
7695
|
-
|
|
7696
|
-
|
|
7697
|
-
isDef = true;
|
|
7698
|
-
}
|
|
8459
|
+
else if (node.type === "function_declaration" ||
|
|
8460
|
+
node.type === "generator_function_declaration" ||
|
|
8461
|
+
node.type === "class_declaration" ||
|
|
8462
|
+
node.type === "method_definition" ||
|
|
8463
|
+
node.type === "interface_declaration" ||
|
|
8464
|
+
node.type === "type_alias_declaration") {
|
|
8465
|
+
const nameNode = node.childForFieldName("name");
|
|
8466
|
+
if (nameNode?.text === symbolName) {
|
|
8467
|
+
isDef = true;
|
|
7699
8468
|
}
|
|
7700
|
-
|
|
7701
|
-
|
|
7702
|
-
|
|
7703
|
-
|
|
7704
|
-
|
|
8469
|
+
}
|
|
8470
|
+
else if (node.type === "variable_declarator") {
|
|
8471
|
+
const nameNode = node.childForFieldName("name");
|
|
8472
|
+
if (nameNode?.type === "identifier" && nameNode.text === symbolName) {
|
|
8473
|
+
isDef = true;
|
|
7705
8474
|
}
|
|
7706
8475
|
}
|
|
7707
8476
|
if (isDef) {
|
|
@@ -8238,8 +9007,8 @@ function detectSymbolCommunities(allRepos, formatPath, relationKinds) {
|
|
|
8238
9007
|
const usedNames = new Set();
|
|
8239
9008
|
for (const indices of groups.values()) {
|
|
8240
9009
|
const memberSet = new Set(indices);
|
|
8241
|
-
const symbols = indices.map((i) => nodes[i].name).sort();
|
|
8242
|
-
const files = Array.from(new Set(indices.map((i) => nodes[i].file))).sort();
|
|
9010
|
+
const symbols = indices.map((i) => nodes[i].name).sort(compareBytes);
|
|
9011
|
+
const files = Array.from(new Set(indices.map((i) => nodes[i].file))).sort(compareBytes);
|
|
8243
9012
|
let internal = 0.0;
|
|
8244
9013
|
let total = 0.0;
|
|
8245
9014
|
for (const i of indices) {
|
|
@@ -8299,7 +9068,7 @@ async function detectKnowledgeGaps(allRepos, mapResult, resolvedRepoPath, opts)
|
|
|
8299
9068
|
const out = new Set();
|
|
8300
9069
|
for (const f of files)
|
|
8301
9070
|
out.add(await languageOfFile(f));
|
|
8302
|
-
return [...out].sort();
|
|
9071
|
+
return [...out].sort(compareBytes);
|
|
8303
9072
|
};
|
|
8304
9073
|
const singleFileCommunities = [];
|
|
8305
9074
|
for (const c of mapResult.communities) {
|
|
@@ -8665,11 +9434,7 @@ function renderCommunityPage(community, map) {
|
|
|
8665
9434
|
const symbols = community.symbols ?? [];
|
|
8666
9435
|
const fileSet = new Set(files);
|
|
8667
9436
|
const lines = [];
|
|
8668
|
-
lines.push(`# ${community.name}`, "");
|
|
8669
|
-
lines.push("## Overview", "");
|
|
8670
|
-
lines.push(`- Size: ${community.size} symbol(s)`);
|
|
8671
|
-
lines.push(`- Cohesion: ${community.cohesion}`, "");
|
|
8672
|
-
lines.push("## Files", "");
|
|
9437
|
+
lines.push(`# ${community.name}`, "", "## Overview", "", `- Size: ${community.size} symbol(s)`, `- Cohesion: ${community.cohesion}`, "", "## Files", "");
|
|
8673
9438
|
if (files.length > 0) {
|
|
8674
9439
|
for (const f of files)
|
|
8675
9440
|
lines.push(`- ${f}`);
|
|
@@ -8734,10 +9499,7 @@ function renderCommunityPage(community, map) {
|
|
|
8734
9499
|
/** Renders `.knodin/wiki/index.md`: repo path, community table, one link per page. */
|
|
8735
9500
|
function renderWikiIndex(resolvedRepoPath, map, slugs) {
|
|
8736
9501
|
const lines = [];
|
|
8737
|
-
lines.push("# knodin Wiki", "");
|
|
8738
|
-
lines.push(`Repo: ${resolvedRepoPath}`, "");
|
|
8739
|
-
lines.push("## Communities", "");
|
|
8740
|
-
lines.push("| Name | Size | Cohesion | Top files |", "|---|---|---|---|");
|
|
9502
|
+
lines.push("# knodin Wiki", "", `Repo: ${resolvedRepoPath}`, "", "## Communities", "", "| Name | Size | Cohesion | Top files |", "|---|---|---|---|");
|
|
8741
9503
|
for (const c of map.communities) {
|
|
8742
9504
|
const topFiles = (c.files ?? []).slice(0, 3).join(", ");
|
|
8743
9505
|
lines.push(`| ${c.name} | ${c.size} | ${c.cohesion} | ${topFiles} |`);
|
|
@@ -8776,7 +9538,7 @@ function writeWikiPageIfChanged(dirPath, relFile, content, force, written, skipp
|
|
|
8776
9538
|
async function writeWiki(engine, repoPath, force) {
|
|
8777
9539
|
const resolvedRepoPath = path.resolve(repoPath);
|
|
8778
9540
|
const map = await engine.map(repoPath, "standard");
|
|
8779
|
-
const wikiDir = path.join(resolvedRepoPath, "
|
|
9541
|
+
const wikiDir = path.join(resolveStateDir(resolvedRepoPath), "wiki");
|
|
8780
9542
|
fs.mkdirSync(wikiDir, { recursive: true });
|
|
8781
9543
|
// Slugify community names into unique page filenames (lowercase, hyphenated).
|
|
8782
9544
|
const slugs = new Map();
|
|
@@ -8925,8 +9687,8 @@ function findImportSpecifierLines(absPath, symbol) {
|
|
|
8925
9687
|
catch {
|
|
8926
9688
|
return [];
|
|
8927
9689
|
}
|
|
8928
|
-
const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g,
|
|
8929
|
-
const wordRe = new RegExp(
|
|
9690
|
+
const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
|
|
9691
|
+
const wordRe = new RegExp(String.raw `\b${escaped}\b`);
|
|
8930
9692
|
// `export` only opens a statement worth scanning when it re-exports bindings
|
|
8931
9693
|
// (`export {` / `export *`) — never for `export function foo() {`, whose body
|
|
8932
9694
|
// braces would otherwise swallow the rest of the file.
|
|
@@ -8964,6 +9726,11 @@ function withStaleness(engine) {
|
|
|
8964
9726
|
result.staleness = stalenessFor(repoPath);
|
|
8965
9727
|
return result;
|
|
8966
9728
|
},
|
|
9729
|
+
async dependencyGraph(repoPath) {
|
|
9730
|
+
const result = await engine.dependencyGraph(repoPath);
|
|
9731
|
+
result.staleness = stalenessFor(repoPath);
|
|
9732
|
+
return result;
|
|
9733
|
+
},
|
|
8967
9734
|
async map(repoPath, detailLevel = "minimal", options) {
|
|
8968
9735
|
const raw = await engine.map(repoPath, detailLevel, options);
|
|
8969
9736
|
const result = options
|
|
@@ -9373,6 +10140,39 @@ export function createEngine() {
|
|
|
9373
10140
|
...(truncated ? { truncated: true } : {}),
|
|
9374
10141
|
};
|
|
9375
10142
|
},
|
|
10143
|
+
async dependencyGraph(repoPath) {
|
|
10144
|
+
const db = await getOrInitDb(repoPath);
|
|
10145
|
+
const rows = db
|
|
10146
|
+
.query("SELECT fromFile, toFile, kind, COALESCE(confidence, 1.0) AS confidence, sourceEvidence FROM dependencies")
|
|
10147
|
+
.all();
|
|
10148
|
+
const degrees = {};
|
|
10149
|
+
const edges = [];
|
|
10150
|
+
for (const row of rows) {
|
|
10151
|
+
// A self-edge carries no layout information and would render as a
|
|
10152
|
+
// dot on top of its own node.
|
|
10153
|
+
if (!row.fromFile || !row.toFile || row.fromFile === row.toFile)
|
|
10154
|
+
continue;
|
|
10155
|
+
degrees[row.fromFile] = (degrees[row.fromFile] ?? 0) + 1;
|
|
10156
|
+
degrees[row.toFile] = (degrees[row.toFile] ?? 0) + 1;
|
|
10157
|
+
edges.push({
|
|
10158
|
+
fromFile: row.fromFile,
|
|
10159
|
+
toFile: row.toFile,
|
|
10160
|
+
kind: row.kind,
|
|
10161
|
+
confidence: row.confidence,
|
|
10162
|
+
...(row.sourceEvidence ? { sourceEvidence: row.sourceEvidence } : {}),
|
|
10163
|
+
});
|
|
10164
|
+
}
|
|
10165
|
+
// Stable order so repeated calls over unchanged evidence agree.
|
|
10166
|
+
edges.sort((left, right) => left.fromFile === right.fromFile
|
|
10167
|
+
? left.toFile.localeCompare(right.toFile)
|
|
10168
|
+
: left.fromFile.localeCompare(right.fromFile));
|
|
10169
|
+
return {
|
|
10170
|
+
repoPath: path.resolve(repoPath),
|
|
10171
|
+
edges,
|
|
10172
|
+
fileCount: Object.keys(degrees).length,
|
|
10173
|
+
degrees,
|
|
10174
|
+
};
|
|
10175
|
+
},
|
|
9376
10176
|
async map(repoPath, detailLevel = "minimal", options = {}) {
|
|
9377
10177
|
const topN = options.topN ?? 15;
|
|
9378
10178
|
if (!Number.isInteger(topN) || topN < 1 || topN > 1000)
|
|
@@ -9395,10 +10195,10 @@ export function createEngine() {
|
|
|
9395
10195
|
resolvedRepoPath,
|
|
9396
10196
|
topN,
|
|
9397
10197
|
options.sort ?? "relevance",
|
|
9398
|
-
[...(options.relationKinds ?? [])].sort(),
|
|
10198
|
+
[...(options.relationKinds ?? [])].sort(compareBytes),
|
|
9399
10199
|
]);
|
|
9400
10200
|
const cached = minimalMapCache.get(cacheKey);
|
|
9401
|
-
if (cached
|
|
10201
|
+
if (cached?.generation === indexGeneration)
|
|
9402
10202
|
return cached.result;
|
|
9403
10203
|
const computeMinimalMap = (async () => {
|
|
9404
10204
|
const analytics = await getGraphAnalyticsSnapshot(allRepos, resolvedRepoPath, topN, options.relationKinds);
|
|
@@ -9468,7 +10268,7 @@ export function createEngine() {
|
|
|
9468
10268
|
return computeMinimalMap;
|
|
9469
10269
|
}
|
|
9470
10270
|
const cached = mapCache.get(resolvedRepoPath);
|
|
9471
|
-
if (cached
|
|
10271
|
+
if (cached?.generation === indexGeneration)
|
|
9472
10272
|
return cached.result;
|
|
9473
10273
|
const computeMap = (async () => {
|
|
9474
10274
|
const repos = await getFederatedRepos(repoPath);
|
|
@@ -9506,7 +10306,7 @@ export function createEngine() {
|
|
|
9506
10306
|
for (const rf of refsFiles) {
|
|
9507
10307
|
filesSet.add(rf.callerFile);
|
|
9508
10308
|
}
|
|
9509
|
-
const filesList = Array.from(filesSet).sort();
|
|
10309
|
+
const filesList = Array.from(filesSet).sort(compareBytes);
|
|
9510
10310
|
const V = filesList.length;
|
|
9511
10311
|
if (V === 0) {
|
|
9512
10312
|
return {
|
|
@@ -9559,7 +10359,7 @@ export function createEngine() {
|
|
|
9559
10359
|
let calleeFile = r.calleeFile;
|
|
9560
10360
|
if (!calleeFile) {
|
|
9561
10361
|
const calleeFiles = symbolToFiles.get(r.calleeSymbol);
|
|
9562
|
-
if (calleeFiles
|
|
10362
|
+
if (calleeFiles?.length === 1)
|
|
9563
10363
|
calleeFile = calleeFiles[0];
|
|
9564
10364
|
}
|
|
9565
10365
|
if (!calleeFile || calleeFile === r.callerFile)
|
|
@@ -9598,7 +10398,7 @@ export function createEngine() {
|
|
|
9598
10398
|
filesSet.add(formatPath(repo.path, rf.callerFile));
|
|
9599
10399
|
}
|
|
9600
10400
|
}
|
|
9601
|
-
const filesList = Array.from(filesSet).sort();
|
|
10401
|
+
const filesList = Array.from(filesSet).sort(compareBytes);
|
|
9602
10402
|
const V = filesList.length;
|
|
9603
10403
|
if (V === 0) {
|
|
9604
10404
|
return {
|
|
@@ -9777,13 +10577,15 @@ export function createEngine() {
|
|
|
9777
10577
|
// repair and timestamp drift before reporting it. Reuse an active DB in
|
|
9778
10578
|
// long-lived processes, or open the existing local DB read-only.
|
|
9779
10579
|
const activeDb = dbInstances.get(resolved);
|
|
9780
|
-
const diskDbPath =
|
|
10580
|
+
const diskDbPath = resolveDbPath(resolved);
|
|
9781
10581
|
const db = activeDb ??
|
|
9782
10582
|
(fs.existsSync(diskDbPath) ? new Database(diskDbPath, { readonly: true }) : null);
|
|
9783
10583
|
const verifiedAt = new Date().toISOString();
|
|
9784
10584
|
const activity = options.ignoreActiveOperation ? null : readIndexActivity(resolved);
|
|
9785
10585
|
if (activity) {
|
|
9786
|
-
const
|
|
10586
|
+
const collected = measurePerfPhaseSync("status_audit", () => collectRepoFilesWithCoverage(resolved));
|
|
10587
|
+
const sourceFiles = collected.files;
|
|
10588
|
+
const skipped = buildCoverageSkips(collected.skippedByExtension, db);
|
|
9787
10589
|
let schemaVersion = 0;
|
|
9788
10590
|
let indexedFiles = 0;
|
|
9789
10591
|
let filesWithSymbols = 0;
|
|
@@ -9826,6 +10628,7 @@ export function createEngine() {
|
|
|
9826
10628
|
percent: sourceFiles.length
|
|
9827
10629
|
? Math.round((Math.min(indexedFiles, sourceFiles.length) / sourceFiles.length) * 10000) / 100
|
|
9828
10630
|
: 100,
|
|
10631
|
+
skipped,
|
|
9829
10632
|
},
|
|
9830
10633
|
orphaned: { embeddings: 0, references: 0, dependencies: 0 },
|
|
9831
10634
|
missing: { files: [], records: [] },
|
|
@@ -9838,7 +10641,8 @@ export function createEngine() {
|
|
|
9838
10641
|
};
|
|
9839
10642
|
}
|
|
9840
10643
|
if (!db) {
|
|
9841
|
-
const
|
|
10644
|
+
const collected = measurePerfPhaseSync("status_audit", () => collectRepoFilesWithCoverage(resolved));
|
|
10645
|
+
const sourceFiles = collected.files;
|
|
9842
10646
|
return {
|
|
9843
10647
|
status: "repair-needed",
|
|
9844
10648
|
repo: resolved,
|
|
@@ -9854,6 +10658,7 @@ export function createEngine() {
|
|
|
9854
10658
|
indexedFiles: 0,
|
|
9855
10659
|
filesWithSymbols: 0,
|
|
9856
10660
|
percent: sourceFiles.length ? 0 : 100,
|
|
10661
|
+
skipped: buildCoverageSkips(collected.skippedByExtension, null),
|
|
9857
10662
|
},
|
|
9858
10663
|
orphaned: { embeddings: 0, references: 0, dependencies: 0 },
|
|
9859
10664
|
missing: {
|
|
@@ -9922,7 +10727,9 @@ export function createEngine() {
|
|
|
9922
10727
|
// reports repair steps instead of hiding the database problem.
|
|
9923
10728
|
}
|
|
9924
10729
|
}
|
|
9925
|
-
const
|
|
10730
|
+
const collected = measurePerfPhaseSync("status_audit", () => collectRepoFilesWithCoverage(resolved));
|
|
10731
|
+
const sourceFiles = collected.files;
|
|
10732
|
+
const coverageSkips = buildCoverageSkips(collected.skippedByExtension, db);
|
|
9926
10733
|
const schemaVersion = db.query("PRAGMA user_version").get()?.user_version ?? 0;
|
|
9927
10734
|
const requiredTables = [
|
|
9928
10735
|
"symbols",
|
|
@@ -9968,6 +10775,7 @@ export function createEngine() {
|
|
|
9968
10775
|
indexedFiles: 0,
|
|
9969
10776
|
filesWithSymbols: 0,
|
|
9970
10777
|
percent: sourceFiles.length ? 0 : 100,
|
|
10778
|
+
skipped: coverageSkips,
|
|
9971
10779
|
},
|
|
9972
10780
|
orphaned: { embeddings: 0, references: 0, dependencies: 0 },
|
|
9973
10781
|
missing: { files: sourceFiles, records: schemaProblems },
|
|
@@ -10049,7 +10857,10 @@ export function createEngine() {
|
|
|
10049
10857
|
percent: sourceFiles.length
|
|
10050
10858
|
? Math.round(((sourceFiles.length - missingFiles.length) / sourceFiles.length) * 10000) / 100
|
|
10051
10859
|
: 100,
|
|
10860
|
+
skipped: coverageSkips,
|
|
10052
10861
|
},
|
|
10862
|
+
...mirrorSnapshotFor(resolved),
|
|
10863
|
+
semanticReadiness: semanticReadinessFor(db),
|
|
10053
10864
|
orphaned,
|
|
10054
10865
|
missing: { files: missingFiles, records },
|
|
10055
10866
|
lastSuccessfulReconciliation: getMeta(db, "lastSuccessfulReconciliation") ?? null,
|
|
@@ -10114,6 +10925,7 @@ export function createEngine() {
|
|
|
10114
10925
|
let repairPaths = [];
|
|
10115
10926
|
let completedFiles = 0;
|
|
10116
10927
|
let committedWork = false;
|
|
10928
|
+
let repairLease;
|
|
10117
10929
|
const generationBeforeRepair = indexGeneration;
|
|
10118
10930
|
const activeOperation = {
|
|
10119
10931
|
promise: undefined,
|
|
@@ -10151,6 +10963,7 @@ export function createEngine() {
|
|
|
10151
10963
|
};
|
|
10152
10964
|
const operation = Promise.resolve()
|
|
10153
10965
|
.then(async () => {
|
|
10966
|
+
repairLease = acquireRepairLease(resolved);
|
|
10154
10967
|
const cancelBoundary = () => {
|
|
10155
10968
|
if (activeOperation.controller.signal.aborted)
|
|
10156
10969
|
throw activeOperation.controller.signal;
|
|
@@ -10183,7 +10996,7 @@ export function createEngine() {
|
|
|
10183
10996
|
// Repair partial current-schema databases before normal initialization,
|
|
10184
10997
|
// whose reconciliation queries require these columns. ALTER preserves all
|
|
10185
10998
|
// healthy index_state rows; the reconciler then refreshes their snapshots.
|
|
10186
|
-
const diskDbPath =
|
|
10999
|
+
const diskDbPath = resolveDbPath(resolved);
|
|
10187
11000
|
if (fs.existsSync(diskDbPath)) {
|
|
10188
11001
|
const activeDb = dbInstances.get(resolved);
|
|
10189
11002
|
const schemaDb = activeDb ?? new Database(diskDbPath);
|
|
@@ -10219,7 +11032,7 @@ export function createEngine() {
|
|
|
10219
11032
|
const eligibleRepairPaths = new Set(collectRepoFiles(resolved));
|
|
10220
11033
|
repairPaths = [...repairKinds.keys()]
|
|
10221
11034
|
.filter((file) => repairKinds.get(file) === "stale" || fileDriftedFromIndexState(db, resolved, file))
|
|
10222
|
-
.sort();
|
|
11035
|
+
.sort(compareBytes);
|
|
10223
11036
|
counts.skipped = Math.max(0, repairKinds.size - repairPaths.length);
|
|
10224
11037
|
overallTotal = repairPaths.length + 4;
|
|
10225
11038
|
emitProgress("planning", repairPaths.length, "Repair plan completed", {
|
|
@@ -10427,6 +11240,7 @@ export function createEngine() {
|
|
|
10427
11240
|
return await operation;
|
|
10428
11241
|
}
|
|
10429
11242
|
finally {
|
|
11243
|
+
repairLease?.release();
|
|
10430
11244
|
options?.signal?.removeEventListener("abort", abortShared);
|
|
10431
11245
|
if (options?.onProgress)
|
|
10432
11246
|
activeOperation.subscribers.delete(options.onProgress);
|
|
@@ -10438,6 +11252,7 @@ export function createEngine() {
|
|
|
10438
11252
|
const indexed = [];
|
|
10439
11253
|
const unchanged = [];
|
|
10440
11254
|
let scipReport;
|
|
11255
|
+
let sarifReport;
|
|
10441
11256
|
const progress = createIndexProgressReporter(options?.onProgress);
|
|
10442
11257
|
progress("starting", 0, "Opening local graph database");
|
|
10443
11258
|
const db = await getOrInitDb(repoPath, {
|
|
@@ -10515,12 +11330,17 @@ export function createEngine() {
|
|
|
10515
11330
|
await reconcileIndex(repoPath, db, progress);
|
|
10516
11331
|
}
|
|
10517
11332
|
else {
|
|
10518
|
-
await indexRepo(repoPath, db, progress);
|
|
11333
|
+
await indexRepo(repoPath, db, progress, options?.skipEmbeddings === true);
|
|
10519
11334
|
}
|
|
10520
11335
|
// Reuse the same candidate set as indexing/reconciliation. This keeps
|
|
10521
11336
|
// LWC HTML/CSS bundle members visible in the response without admitting
|
|
10522
11337
|
// arbitrary web assets to the source index.
|
|
10523
|
-
|
|
11338
|
+
// Appended in a loop, not `push(...files)`: spreading an array as
|
|
11339
|
+
// arguments overflows the call stack once a repository is large
|
|
11340
|
+
// enough. Observed on a 40k-file checkout, which is well within the
|
|
11341
|
+
// range knodin is meant to index.
|
|
11342
|
+
for (const file of collectRepoFiles(repoPath))
|
|
11343
|
+
indexed.push(file);
|
|
10524
11344
|
completionMessage = `${indexed.length.toLocaleString()} repository file(s) are current`;
|
|
10525
11345
|
}
|
|
10526
11346
|
if (options?.scip) {
|
|
@@ -10550,6 +11370,29 @@ export function createEngine() {
|
|
|
10550
11370
|
if (!indexed.includes(file))
|
|
10551
11371
|
indexed.push(file);
|
|
10552
11372
|
}
|
|
11373
|
+
if (options?.sarif) {
|
|
11374
|
+
const facts = readSarifLog(repoPath, options.sarif);
|
|
11375
|
+
persistSarifFindings(db, facts);
|
|
11376
|
+
// Findings are not graph facts, so no re-embedding and no generation
|
|
11377
|
+
// bump: the graph is unchanged and existing answers stay valid.
|
|
11378
|
+
const bounds = { ...SARIF_DEFAULT_LIMITS, ...options.sarif };
|
|
11379
|
+
sarifReport = {
|
|
11380
|
+
provenance: "sarif",
|
|
11381
|
+
inputPath: facts.inputPath,
|
|
11382
|
+
bytes: facts.bytes,
|
|
11383
|
+
tools: facts.tools,
|
|
11384
|
+
rules: facts.rules,
|
|
11385
|
+
files: facts.files,
|
|
11386
|
+
findings: facts.findings.length,
|
|
11387
|
+
skippedUnresolved: facts.skippedUnresolved,
|
|
11388
|
+
elapsedMs: facts.elapsedMs,
|
|
11389
|
+
bounds: {
|
|
11390
|
+
maxBytes: bounds.maxBytes,
|
|
11391
|
+
maxFindings: bounds.maxFindings,
|
|
11392
|
+
timeoutMs: bounds.timeoutMs,
|
|
11393
|
+
},
|
|
11394
|
+
};
|
|
11395
|
+
}
|
|
10553
11396
|
progress("verifying", 0, "Verifying local graph health", {
|
|
10554
11397
|
phaseTotal: 1,
|
|
10555
11398
|
});
|
|
@@ -10604,6 +11447,7 @@ export function createEngine() {
|
|
|
10604
11447
|
indexed,
|
|
10605
11448
|
unchanged,
|
|
10606
11449
|
scip: scipReport,
|
|
11450
|
+
sarif: sarifReport,
|
|
10607
11451
|
verification: {
|
|
10608
11452
|
status: health.status,
|
|
10609
11453
|
issueCount,
|
|
@@ -10621,6 +11465,10 @@ export function createEngine() {
|
|
|
10621
11465
|
throw new Error("knodin search: invalid testScope");
|
|
10622
11466
|
const repos = options.federate === false ? [resolvedRepoPath] : await getFederatedRepos(repoPath);
|
|
10623
11467
|
const allRepos = await Promise.all(repos.map(async (r) => ({ path: r, db: await getOrInitDb(r) })));
|
|
11468
|
+
// Computed before any retrieval route runs, so every exit path can report
|
|
11469
|
+
// it: a short result list caused by missing embeddings must not be
|
|
11470
|
+
// indistinguishable from a short list caused by the query.
|
|
11471
|
+
const semanticReadiness = worstSemanticReadiness(allRepos.map((repo) => semanticReadinessFor(repo.db)));
|
|
10624
11472
|
const searchSnapshots = new Map(allRepos.map((repo) => [repo.path, getSearchMetadataSnapshot(repo.path, repo.db)]));
|
|
10625
11473
|
// typeof + Number.isFinite rejects undefined/null/NaN alike, so any non-numeric
|
|
10626
11474
|
// or unset `limit` falls back to the default instead of clamping to 0/1 or
|
|
@@ -10771,6 +11619,7 @@ export function createEngine() {
|
|
|
10771
11619
|
limit: maxResults,
|
|
10772
11620
|
hasMore: offset + maxResults < matches.length,
|
|
10773
11621
|
retrieval: { route: ["exact-name"], embeddingsUsed: false },
|
|
11622
|
+
semanticReadiness,
|
|
10774
11623
|
};
|
|
10775
11624
|
}
|
|
10776
11625
|
if (retrievalMode !== "vector-only") {
|
|
@@ -10815,6 +11664,7 @@ export function createEngine() {
|
|
|
10815
11664
|
: [identityQuery ? "stable-identity" : pathQuery ? "exact-path" : "exact-name"],
|
|
10816
11665
|
embeddingsUsed: false,
|
|
10817
11666
|
},
|
|
11667
|
+
semanticReadiness,
|
|
10818
11668
|
};
|
|
10819
11669
|
}
|
|
10820
11670
|
}
|
|
@@ -10901,6 +11751,7 @@ export function createEngine() {
|
|
|
10901
11751
|
: ["lexical"],
|
|
10902
11752
|
embeddingsUsed: false,
|
|
10903
11753
|
},
|
|
11754
|
+
semanticReadiness,
|
|
10904
11755
|
};
|
|
10905
11756
|
}
|
|
10906
11757
|
}
|
|
@@ -11055,6 +11906,7 @@ export function createEngine() {
|
|
|
11055
11906
|
: ["lexical", "bounded-graph", "embeddings"],
|
|
11056
11907
|
embeddingsUsed: true,
|
|
11057
11908
|
},
|
|
11909
|
+
semanticReadiness,
|
|
11058
11910
|
};
|
|
11059
11911
|
},
|
|
11060
11912
|
async query(pattern, target, repoPath, to, limit, depth, detailLevel, selector = {}, impactOptions = {}, options = {}) {
|
|
@@ -11397,13 +12249,13 @@ export function createEngine() {
|
|
|
11397
12249
|
adjacency.set(edge.fromFile, outgoing);
|
|
11398
12250
|
}
|
|
11399
12251
|
for (const outgoing of adjacency.values())
|
|
11400
|
-
outgoing.sort();
|
|
12252
|
+
outgoing.sort(compareBytes);
|
|
11401
12253
|
const cycles = [];
|
|
11402
12254
|
const stopAfter = cap + 1;
|
|
11403
12255
|
const workLimit = Math.min(Math.max(cap * 1000, 10_000), 1_000_000);
|
|
11404
12256
|
let work = 0;
|
|
11405
12257
|
let workTruncated = false;
|
|
11406
|
-
for (const root of [...nodes].sort()) {
|
|
12258
|
+
for (const root of [...nodes].sort(compareBytes)) {
|
|
11407
12259
|
if (cycles.length >= stopAfter || workTruncated)
|
|
11408
12260
|
break;
|
|
11409
12261
|
const path = [root];
|
|
@@ -11581,7 +12433,7 @@ export function createEngine() {
|
|
|
11581
12433
|
const includeTests = impactOptions.includeTests ?? true;
|
|
11582
12434
|
const relationKinds = new Set(impactOptions.relationKinds ?? []);
|
|
11583
12435
|
const minConfidence = Math.min(Math.max(impactOptions.minConfidence ?? 0, 0), 1);
|
|
11584
|
-
const isTestFile = (file) => /(^|\/)(test|tests|__tests__)(\/|$)
|
|
12436
|
+
const isTestFile = (file) => /((^|\/)(test|tests|__tests__)(\/|$))|(\.(test|spec)\.[^.]+$)/i.test(file);
|
|
11585
12437
|
const allowed = (kind, confidence, file) => (includeTests || !isTestFile(file)) &&
|
|
11586
12438
|
(relationKinds.size === 0 || relationKinds.has(kind)) &&
|
|
11587
12439
|
confidence >= minConfidence;
|
|
@@ -11596,8 +12448,8 @@ export function createEngine() {
|
|
|
11596
12448
|
const text = fs.readFileSync(path.join(resolvedRepoPath, file), "utf8").split("\n")[line - 1];
|
|
11597
12449
|
if (!text)
|
|
11598
12450
|
return undefined;
|
|
11599
|
-
const escaped = callee.replace(/[.*+?^${}()|[\]\\]/g,
|
|
11600
|
-
const match =
|
|
12451
|
+
const escaped = callee.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
|
|
12452
|
+
const match = new RegExp(String.raw `\b${escaped}\s*\(([^)]*)\)`).exec(text);
|
|
11601
12453
|
if (!match)
|
|
11602
12454
|
return undefined;
|
|
11603
12455
|
return {
|
|
@@ -11942,8 +12794,27 @@ export function createEngine() {
|
|
|
11942
12794
|
const src = readSource(relFile);
|
|
11943
12795
|
if (!src)
|
|
11944
12796
|
return false;
|
|
11945
|
-
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g,
|
|
11946
|
-
return (new RegExp(
|
|
12797
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
|
|
12798
|
+
return (new RegExp(String.raw `\bexport\s+(?:default\s+)?(?:function|class|const|let|var|type|interface|enum)\s+${escaped}\b`).test(src) ||
|
|
12799
|
+
new RegExp(String.raw `\bexport\s*\{[^}]*\b${escaped}\b[^}]*\}`).test(src));
|
|
12800
|
+
};
|
|
12801
|
+
// Whether an import/export binding clause may bind `name`. A namespace
|
|
12802
|
+
// binding, or one whose `{ … }` span cannot be read, is conservatively a
|
|
12803
|
+
// yes; a readable named list is a yes only when it names the symbol.
|
|
12804
|
+
const bindingClauseBinds = (bindings, name) => {
|
|
12805
|
+
if (bindings.includes("*"))
|
|
12806
|
+
return true;
|
|
12807
|
+
// First `{ … }` span, scanned rather than matched: the equivalent
|
|
12808
|
+
// regex re-walks the binding list from every `{`, which is quadratic.
|
|
12809
|
+
const open = bindings.indexOf("{");
|
|
12810
|
+
const close = open < 0 ? -1 : bindings.indexOf("}", open + 1);
|
|
12811
|
+
const named = close < 0 ? undefined : bindings.slice(open + 1, close);
|
|
12812
|
+
if (!named)
|
|
12813
|
+
return true;
|
|
12814
|
+
return named.split(",").some((part) => part
|
|
12815
|
+
.trim()
|
|
12816
|
+
.split(/\s+as\s+/)[0]
|
|
12817
|
+
?.trim() === name);
|
|
11947
12818
|
};
|
|
11948
12819
|
const importerMayReachExport = (importer, relFile, name) => {
|
|
11949
12820
|
const src = readSource(importer);
|
|
@@ -11954,20 +12825,8 @@ export function createEngine() {
|
|
|
11954
12825
|
if (resolveModuleToFile(importer, match[2], repoPath) !== relFile)
|
|
11955
12826
|
continue;
|
|
11956
12827
|
matchedTarget = true;
|
|
11957
|
-
|
|
11958
|
-
if (bindings.includes("*"))
|
|
12828
|
+
if (bindingClauseBinds(match[1].trim(), name))
|
|
11959
12829
|
return true;
|
|
11960
|
-
const named = bindings.match(/\{([^}]*)\}/)?.[1];
|
|
11961
|
-
if (!named)
|
|
11962
|
-
return true;
|
|
11963
|
-
for (const part of named.split(",")) {
|
|
11964
|
-
const imported = part
|
|
11965
|
-
.trim()
|
|
11966
|
-
.split(/\s+as\s+/)[0]
|
|
11967
|
-
?.trim();
|
|
11968
|
-
if (imported === name)
|
|
11969
|
-
return true;
|
|
11970
|
-
}
|
|
11971
12830
|
}
|
|
11972
12831
|
// The dependency extractor found an edge but the source pattern was
|
|
11973
12832
|
// not recognized (for example, a dynamic loader). Keep it live.
|
|
@@ -12520,9 +13379,13 @@ export function createEngine() {
|
|
|
12520
13379
|
// Parameters are reaching definitions at function entry. Deliberately
|
|
12521
13380
|
// handles only simple identifiers, not destructuring/default expressions.
|
|
12522
13381
|
const signature = functionLines[0] ?? "";
|
|
12523
|
-
|
|
13382
|
+
// First `( … )` span, scanned rather than matched: the equivalent regex
|
|
13383
|
+
// re-walks the signature from every `(`, which is quadratic.
|
|
13384
|
+
const parenOpen = signature.indexOf("(");
|
|
13385
|
+
const parenClose = parenOpen < 0 ? -1 : signature.indexOf(")", parenOpen + 1);
|
|
13386
|
+
const params = parenClose < 0 ? "" : signature.slice(parenOpen + 1, parenClose);
|
|
12524
13387
|
for (const parameter of params.split(",")) {
|
|
12525
|
-
const name =
|
|
13388
|
+
const name = /^([A-Za-z_$][\w$]*)/.exec(parameter.trim())?.[1];
|
|
12526
13389
|
if (name) {
|
|
12527
13390
|
trackedVariables.add(name);
|
|
12528
13391
|
add("definition", name, 0, signature);
|
|
@@ -12532,7 +13395,7 @@ export function createEngine() {
|
|
|
12532
13395
|
const line = raw.trim();
|
|
12533
13396
|
if (!line || line.startsWith("//"))
|
|
12534
13397
|
continue;
|
|
12535
|
-
const definition =
|
|
13398
|
+
const definition = /^(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/.exec(line);
|
|
12536
13399
|
if (definition) {
|
|
12537
13400
|
trackedVariables.add(definition[1]);
|
|
12538
13401
|
add("definition", definition[1], index, raw);
|
|
@@ -12560,13 +13423,11 @@ export function createEngine() {
|
|
|
12560
13423
|
].includes(variable))
|
|
12561
13424
|
continue;
|
|
12562
13425
|
if (definition?.[1] === variable &&
|
|
12563
|
-
!line
|
|
12564
|
-
.slice((definition.index ?? 0) + definition[0].length)
|
|
12565
|
-
.match(new RegExp(`\\b${variable}\\b`)))
|
|
13426
|
+
!new RegExp(String.raw `\b${variable}\b`).exec(line.slice((definition.index ?? 0) + definition[0].length)))
|
|
12566
13427
|
continue;
|
|
12567
|
-
if (control?.match(new RegExp(
|
|
13428
|
+
if (control?.match(new RegExp(String.raw `\b${variable}\b`)))
|
|
12568
13429
|
add("control", variable, index, raw);
|
|
12569
|
-
else if (
|
|
13430
|
+
else if (definition?.[1] !== variable)
|
|
12570
13431
|
add("use", variable, index, raw);
|
|
12571
13432
|
}
|
|
12572
13433
|
}
|
|
@@ -12748,7 +13609,7 @@ export function createEngine() {
|
|
|
12748
13609
|
selected,
|
|
12749
13610
|
]);
|
|
12750
13611
|
const cachedOverview = architectureOverviewCache.get(overviewCacheKey);
|
|
12751
|
-
if (cachedOverview
|
|
13612
|
+
if (cachedOverview?.generation === indexGeneration) {
|
|
12752
13613
|
finishArchitecture();
|
|
12753
13614
|
return finish([{ architectureOverview: structuredClone(cachedOverview.result) }]);
|
|
12754
13615
|
}
|
|
@@ -12880,7 +13741,7 @@ export function createEngine() {
|
|
|
12880
13741
|
.map(([name, value]) => ({
|
|
12881
13742
|
name,
|
|
12882
13743
|
symbols: value.symbols,
|
|
12883
|
-
files: [...value.files].sort(),
|
|
13744
|
+
files: [...value.files].sort(compareBytes),
|
|
12884
13745
|
}))
|
|
12885
13746
|
.sort((a, b) => b.symbols - a.symbols || a.name.localeCompare(b.name))
|
|
12886
13747
|
.slice(0, cap);
|
|
@@ -13174,8 +14035,8 @@ export function createEngine() {
|
|
|
13174
14035
|
collideStmt.finalize();
|
|
13175
14036
|
// Line-scoped word-boundary replacement plan: only the lines the preview
|
|
13176
14037
|
// recorded are touched (never a whole-file blind replace).
|
|
13177
|
-
const escaped = oldName.replace(/[.*+?^${}()|[\]\\]/g,
|
|
13178
|
-
const wordRe = new RegExp(
|
|
14038
|
+
const escaped = oldName.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
|
|
14039
|
+
const wordRe = new RegExp(String.raw `\b${escaped}\b`, "g");
|
|
13179
14040
|
const linesByFile = new Map();
|
|
13180
14041
|
// Import-specifier lines are rewritten outside string literals, so a module
|
|
13181
14042
|
// specifier that happens to contain the old name (`from "./helper"`) is left
|