Skip to content

Commit ae52a6c

Browse files
committed
Fix type mismatch, docs examples, and hoist regex compilation in aggregate()
1 parent 19b6b6c commit ae52a6c

4 files changed

Lines changed: 20 additions & 13 deletions

File tree

docs/.vitepress/theme/UseCaseTabs.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ const USE_CASES: UseCase[] = [
5656
headline: "Which repos are pinned to a vulnerable minor version?",
5757
description:
5858
"Use regex syntax to target a precise version range — something a plain keyword search cannot do. Find every repo still locked to axios 1.x, react 17.x, or any other outdated pin, then export the list to a migration issue.",
59-
command: `github-code-search query '/"axios": "1./' --org my-org`,
59+
command: `github-code-search query '/"axios": "1\\./' --org my-org`,
6060
},
6161
];
6262

docs/usage/search-syntax.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ Because the GitHub Code Search API does not natively support regex, the CLI auto
8888
github-code-search "/from.*['\"\`]axios/" --org fulll
8989

9090
# Axios dependency in package.json (any semver prefix)
91-
github-code-search '"axios": "[~^]?[0-9]" filename:package.json' --org fulll
91+
github-code-search '/"axios": "[~^]?[0-9]"/ filename:package.json' --org fulll
9292

9393
# Old library require() calls
9494
github-code-search "/require\\(['\"](old-lib)['\"]\\)/" --org fulll
@@ -107,8 +107,7 @@ an `A OR B OR C` query to the GitHub API so that **all branches are covered**
107107
If the extracted term is very short (fewer than 3 characters), the CLI will exit with a warning and ask you to provide a manual hint:
108108

109109
```text
110-
⚠ Regex mode — could not extract a term longer than 2 chars from /[~^]?[0-9]/
111-
Provide a manual hint with --regex-hint <term>.
110+
⚠ Regex mode — No meaningful search term could be extracted from the regex pattern. Use --regex-hint <term> to specify the term to send to the GitHub API.
112111
```
113112

114113
Use `--regex-hint` to override the API search term while still applying the full regex filter locally:

github-code-search.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ async function searchAction(
379379
outputType,
380380
includeArchived,
381381
opts.groupByTeamPrefix,
382-
opts.regexHint,
382+
opts.regexHint ?? "",
383383
);
384384
}
385385
}

src/aggregate.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,13 @@ export function extractRef(repoFullName: string, path: string, matchIndex: numbe
4141
*/
4242
function recomputeSegments(
4343
fragment: string,
44-
regex: RegExp,
44+
re: RegExp,
4545
fragmentStartLine: number,
4646
): TextMatchSegment[] {
47-
// Force global flag so exec() advances; strip g and y first to avoid double-g
48-
// and to prevent sticky mode from anchoring every match at lastIndex.
49-
const re = new RegExp(regex.source, regex.flags.replace(/[gy]/g, "") + "g");
47+
// Reset lastIndex so exec() always searches from the start of the fragment.
48+
// The caller is responsible for providing a global (g) regex constructed once
49+
// per aggregate() call — not recompiled per fragment.
50+
re.lastIndex = 0;
5051
// Precompute newline positions once — O(n) — so per-match line/col lookup
5152
// is O(log n) via binary search instead of O(n) per match (O(n²) overall).
5253
const newlines: number[] = [];
@@ -85,6 +86,13 @@ export function aggregate(
8586
includeArchived = false,
8687
regexFilter?: RegExp | null,
8788
): RepoGroup[] {
89+
// Compile the global regex once per aggregate() call rather than once per
90+
// fragment inside recomputeSegments — avoids repeated RegExp construction
91+
// on large result sets. Strip g and y first to prevent double-flag and
92+
// sticky-mode issues; recomputeSegments resets lastIndex per call.
93+
const globalRe = regexFilter
94+
? new RegExp(regexFilter.source, regexFilter.flags.replace(/[gy]/g, "") + "g")
95+
: null;
8896
const map = new Map<string, CodeMatch[]>();
8997
for (const m of matches) {
9098
if (excludedRepos.has(m.repoFullName)) continue;
@@ -93,10 +101,10 @@ export function aggregate(
93101
// segments (which point at the literal search term) with segments derived
94102
// from the actual regex match positions — see issue #111 / fix highlight bug
95103
let matchToAdd: CodeMatch = m;
96-
if (regexFilter != null) {
104+
if (globalRe != null) {
97105
// Preserve the caller's lastIndex: aggregate() must not have observable
98106
// side-effects on the passed-in RegExp instance.
99-
const savedLastIndex = regexFilter.lastIndex;
107+
const savedLastIndex = regexFilter!.lastIndex;
100108
const updatedTextMatches: TextMatch[] = m.textMatches
101109
.map((tm) => {
102110
// Derive the absolute start line of this fragment from the first API
@@ -110,13 +118,13 @@ export function aggregate(
110118
const fragLine = (before.match(/\n/g)?.length ?? 0) + 1;
111119
fragmentStartLine = firstApiSeg.line - fragLine + 1;
112120
}
113-
const segs = recomputeSegments(tm.fragment, regexFilter, fragmentStartLine);
121+
const segs = recomputeSegments(tm.fragment, globalRe, fragmentStartLine);
114122
return segs.length > 0 ? { fragment: tm.fragment, matches: segs } : null;
115123
})
116124
.filter((tm): tm is TextMatch => tm !== null);
117125
// Restore the caller's original lastIndex (rather than hard-coding 0),
118126
// so aggregate() doesn't have observable side effects on its inputs.
119-
regexFilter.lastIndex = savedLastIndex;
127+
regexFilter!.lastIndex = savedLastIndex;
120128
if (updatedTextMatches.length === 0) continue;
121129
matchToAdd = { ...m, textMatches: updatedTextMatches };
122130
}

0 commit comments

Comments
 (0)