Repository navigation
fix: issue triage quick wins (chunk cap, map, path search) #154
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -78,6 +78,9 @@ const MAP_EXCLUDED_PATH_PATTERNS = [ | |
| /(?:^|\/)(?:fixtures?|__fixtures__)(?:\/|$)/i, | ||
| /(?:^|\/)(?:generated|__generated__)(?:\/|$)/i, | ||
| /(?:^|\/)[^/]*\.(?:generated|gen|min)\.[^/]+$/i, | ||
| /(?:^|\/)raw(?:\/|$)/i, | ||
| /(?:^|\/)live-from-ai(?:\/|$)/i, | ||
| /[^/]+-[A-Za-z0-9]{6,}\.(?:js|css|mjs|cjs)$/i, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In the default bounded map, this pattern also excludes handwritten files whose final hyphen-separated word has six or more letters. For example, the existing Useful? React with 👍 / 👎.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The bundle pattern also matches maintained source such as Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
| /\.snap$/i | ||
| ] as const; | ||
|
|
||
|
|
@@ -180,6 +183,7 @@ async function buildPinnedCodebaseMap( | |
| const entrypoints: string[] = []; | ||
| for (const [file, imports] of Object.entries(graphImports)) { | ||
| if (imports.length === 0) continue; // no imports — not an entrypoint | ||
| if (!isMapEligiblePath(file, mode)) continue; | ||
| const importers = graphImportedBy[file]; | ||
| if (!importers || importers.length === 0) { | ||
| entrypoints.push(file); | ||
|
|
@@ -193,7 +197,7 @@ async function buildPinnedCodebaseMap( | |
| graphImportedBy | ||
| ).map(([file, importers]) => ({ file, count: importers.length })); | ||
| const hubFiles: string[] = sortByCountThenAlpha( | ||
| importedByCounts, | ||
| importedByCounts.filter(({ file }) => isMapEligiblePath(file, mode)), | ||
| (x) => x.count, | ||
| (x) => x.file | ||
| ) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -141,6 +141,8 @@ export interface IndexerOptions { | |
| interface PersistedIndexingStats { | ||
| indexedFiles: number; | ||
| totalChunks: number; | ||
| searchableChunks?: number; | ||
| chunkLimitApplied?: boolean; | ||
| totalFiles: number; | ||
| generatedAt: string; | ||
| } | ||
|
|
@@ -956,7 +958,9 @@ export class CodebaseIndexer { | |
| let chunksToEmbed = chunksForEmbedding; | ||
| if (chunksForEmbedding.length > maxChunks) { | ||
| console.warn( | ||
| `WARNING: ${chunksForEmbedding.length} chunks exceed limit. Indexing first ${maxChunks} chunks.` | ||
| `WARNING: ${chunksForEmbedding.length} chunks exceed the searchable limit (${maxChunks}). ` + | ||
| `Only the first ${maxChunks} chunks are indexed for search. ` + | ||
| `Raise projects[].parsing.maxChunks in ~/.codebase-context/config.json for this repo root.` | ||
| ); | ||
| chunksToEmbed = chunksForEmbedding.slice(0, maxChunks); | ||
| } | ||
|
|
@@ -1098,8 +1102,10 @@ export class CodebaseIndexer { | |
| // Keyword index always uses ALL chunks (full regen) | ||
| const indexPath = path.join(generationDirectory, KEYWORD_INDEX_FILENAME); | ||
| // Memory safety: cap keyword index too | ||
| const keywordChunks = | ||
| allChunks.length > maxChunks ? allChunks.slice(0, maxChunks) : allChunks; | ||
| const chunkLimitApplied = allChunks.length > maxChunks; | ||
| const keywordChunks = chunkLimitApplied ? allChunks.slice(0, maxChunks) : allChunks; | ||
| stats.searchableChunks = keywordChunks.length; | ||
| stats.chunkLimitApplied = chunkLimitApplied; | ||
| await fs.writeFile( | ||
| indexPath, | ||
| JSON.stringify({ | ||
|
|
@@ -1206,6 +1212,8 @@ export class CodebaseIndexer { | |
| const persistedStats: PersistedIndexingStats = { | ||
| indexedFiles: stats.indexedFiles, | ||
| totalChunks: stats.totalChunks, | ||
| searchableChunks: stats.searchableChunks, | ||
| chunkLimitApplied: stats.chunkLimitApplied, | ||
|
Comment on lines
+1215
to
+1216
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
After indexing a capped repository, restarting without source changes takes the incremental short-circuit, whose persisted-stats reader restores only Useful? React with 👍 / 👎.
Comment on lines
+1215
to
+1216
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new cap fields are saved but not restored when indexing finds no changed files. That branch restores only |
||
| totalFiles: stats.totalFiles, | ||
| generatedAt | ||
| }; | ||
|
|
@@ -1264,7 +1272,7 @@ export class CodebaseIndexer { | |
| ); | ||
| } else { | ||
| console.error(`Indexing complete in ${stats.duration}ms`); | ||
| console.error(`Indexed ${stats.indexedFiles} files, ${stats.totalChunks} chunks`); | ||
| console.error(formatIndexingProgressSummary(stats)); | ||
| } | ||
|
|
||
| return stats; | ||
|
|
@@ -1589,3 +1597,23 @@ export class CodebaseIndexer { | |
| return { ...this.progress }; | ||
| } | ||
| } | ||
|
|
||
| export function formatIndexingProgressSummary(stats: IndexingStats): string { | ||
| const searchable = stats.searchableChunks ?? stats.totalChunks; | ||
| if (stats.chunkLimitApplied && searchable < stats.totalChunks) { | ||
| return ( | ||
| `Indexed ${stats.indexedFiles} files, ${stats.totalChunks} chunks analyzed, ` + | ||
| `${searchable} chunks searchable (limit applied)` | ||
| ); | ||
| } | ||
| return `Indexed ${stats.indexedFiles} files, ${stats.totalChunks} chunks`; | ||
| } | ||
|
|
||
| export function formatIndexingCompleteLine(stats: IndexingStats): string { | ||
| const searchable = stats.searchableChunks ?? stats.totalChunks; | ||
| const durationSec = (stats.duration / 1000).toFixed(2); | ||
| if (stats.chunkLimitApplied && searchable < stats.totalChunks) { | ||
| return `Complete: ${stats.indexedFiles} files, ${searchable}/${stats.totalChunks} chunks searchable in ${durationSec}s`; | ||
| } | ||
| return `Complete: ${stats.indexedFiles} files, ${stats.totalChunks} chunks in ${durationSec}s`; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,6 +39,24 @@ import { | |
| VECTOR_DB_DIRNAME | ||
| } from '../constants/codebase-context.js'; | ||
|
|
||
| /** File/path-like tokens in a natural-language query (e.g. NOW.md, check-agent-instructions.mjs). */ | ||
| export function extractQueryPathTokens(query: string): string[] { | ||
| const tokens = new Set<string>(); | ||
| for (const match of query.matchAll(/\b([A-Za-z0-9][A-Za-z0-9._-]*\.(?:md|mdx|txt))\b/g)) { | ||
| tokens.add(match[1].toLowerCase()); | ||
| } | ||
| for (const match of query.matchAll(/\b([a-z][a-z0-9-]*(?:-[a-z0-9]+)+)\.(?:mjs|cjs|js|ts)\b/gi)) { | ||
| tokens.add(match[0].toLowerCase()); | ||
| tokens.add(match[1].toLowerCase()); | ||
| } | ||
| for (const match of query.matchAll(/\b([a-z][a-z0-9-]*(?:-[a-z0-9]+){2,})\b/gi)) { | ||
| if (match[1].length >= 12) { | ||
| tokens.add(match[1].toLowerCase()); | ||
| } | ||
| } | ||
| return [...tokens]; | ||
| } | ||
|
|
||
| export interface SearchOptions { | ||
| useSemanticSearch?: boolean; | ||
| useKeywordSearch?: boolean; | ||
|
|
@@ -1941,11 +1959,23 @@ export class CodebaseSearcher { | |
| } | ||
|
|
||
| if (fuseResults.length === 0 || (fuseResults[0].score ?? 0) > KEYWORD_FUSE_OPTIONS.threshold!) { | ||
| const rescue = await this.rescueKeywordMatches(query, limit, filters, validation); | ||
| const seen = new Set(rescue.map(({ item }) => item.id)); | ||
| fuseResults = [...rescue, ...fuseResults.filter(({ item }) => !seen.has(item.id))].sort( | ||
| (a, b) => (a.score ?? 0) - (b.score ?? 0) | ||
| const pathRescue: FuseResult<CodeChunk>[] = this.matchChunksByPathTokens(query, filters).map( | ||
| (chunk, refIndex) => ({ | ||
| item: chunk, | ||
| refIndex, | ||
| score: 0 | ||
| }) | ||
|
Comment on lines
+1962
to
+1967
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Every path match gets |
||
| ); | ||
| const rescue = await this.rescueKeywordMatches(query, limit, filters, validation); | ||
| const seen = new Set([ | ||
| ...pathRescue.map(({ item }) => item.id), | ||
| ...rescue.map(({ item }) => item.id) | ||
| ]); | ||
| fuseResults = [ | ||
| ...pathRescue, | ||
| ...rescue, | ||
| ...fuseResults.filter(({ item }) => !seen.has(item.id)) | ||
|
Comment on lines
+1974
to
+1977
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a filename-bearing query triggers rescue and a chunk matches both its path token and the lexical rescue criteria, that chunk appears in both Useful? React with 👍 / 👎. |
||
| ].sort((a, b) => (a.score ?? 0) - (b.score ?? 0)); | ||
|
Comment on lines
+1974
to
+1978
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
| const current = await this.selectCurrentCandidates( | ||
| fuseResults, | ||
|
|
@@ -1984,6 +2014,20 @@ export class CodebaseSearcher { | |
| score = Math.min(1.0, score + 0.1); | ||
| } | ||
|
|
||
| for (const token of extractQueryPathTokens(query)) { | ||
| const base = path.basename(relativePathLower); | ||
| const stem = base.replace(/\.[^.]+$/, ''); | ||
| if ( | ||
| base === token || | ||
| relativePathLower.endsWith(`/${token}`) || | ||
| stem === token.replace(/\.[^.]+$/, '') | ||
| ) { | ||
| score = Math.min(1.0, score + 0.45); | ||
| } else if (stem.includes(token) || relativePathLower.includes(token)) { | ||
| score = Math.min(1.0, score + 0.25); | ||
|
Comment on lines
+2017
to
+2027
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new ranking adds Context Used: AGENTS.md (source) Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
| } | ||
| } | ||
|
|
||
| return { | ||
| chunk, | ||
| score | ||
|
|
@@ -1996,6 +2040,29 @@ export class CodebaseSearcher { | |
| ); | ||
| } | ||
|
|
||
| private matchChunksByPathTokens(query: string, filters?: SearchFilters): CodeChunk[] { | ||
| const tokens = extractQueryPathTokens(query); | ||
| if (tokens.length === 0) { | ||
| return []; | ||
| } | ||
| return this.chunks.filter((item) => { | ||
| if (!this.matchesSearchFilters(item, filters)) { | ||
| return false; | ||
| } | ||
| const relativePathLower = item.relativePath.toLowerCase(); | ||
| const base = path.basename(relativePathLower); | ||
| const stem = base.replace(/\.[^.]+$/, ''); | ||
| return tokens.some( | ||
| (token) => | ||
| base === token || | ||
| relativePathLower.endsWith(`/${token}`) || | ||
| stem === token.replace(/\.[^.]+$/, '') || | ||
| stem.includes(token) || | ||
| relativePathLower.includes(token) | ||
| ); | ||
| }); | ||
| } | ||
|
|
||
| private generateRelevanceReason(chunk: CodeChunk, query: string): string { | ||
| const reasons: string[] = []; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { | ||
| formatIndexingCompleteLine, | ||
| formatIndexingProgressSummary | ||
| } from '../src/core/indexer.js'; | ||
| import type { IndexingStats } from '../src/types/index.js'; | ||
|
|
||
| function baseStats(overrides: Partial<IndexingStats> = {}): IndexingStats { | ||
| return { | ||
| totalFiles: 100, | ||
| indexedFiles: 90, | ||
| skippedFiles: 10, | ||
| totalChunks: 88424, | ||
| searchableChunks: 5000, | ||
| chunkLimitApplied: true, | ||
| totalLines: 1, | ||
| duration: 2500, | ||
| avgChunkSize: 100, | ||
| componentsByType: {}, | ||
| componentsByLayer: { | ||
| core: 0, | ||
| feature: 0, | ||
| shared: 0, | ||
| infrastructure: 0, | ||
| unknown: 0 | ||
| }, | ||
| errors: [], | ||
| startedAt: new Date(), | ||
| ...overrides | ||
| }; | ||
| } | ||
|
|
||
| describe('indexer chunk limit reporting', () => { | ||
| it('formatIndexingProgressSummary distinguishes analyzed vs searchable counts', () => { | ||
| const line = formatIndexingProgressSummary(baseStats()); | ||
| expect(line).toContain('88424 chunks analyzed'); | ||
| expect(line).toContain('5000 chunks searchable'); | ||
| }); | ||
|
|
||
| it('formatIndexingCompleteLine shows searchable fraction when capped', () => { | ||
| const line = formatIndexingCompleteLine(baseStats()); | ||
| expect(line).toBe('Complete: 90 files, 5000/88424 chunks searchable in 2.50s'); | ||
| }); | ||
|
|
||
| it('formatIndexingCompleteLine stays compact when no cap applied', () => { | ||
| const line = formatIndexingCompleteLine( | ||
| baseStats({ totalChunks: 120, searchableChunks: 120, chunkLimitApplied: false }) | ||
| ); | ||
| expect(line).toBe('Complete: 90 files, 120 chunks in 2.50s'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { extractQueryPathTokens } from '../src/core/search.js'; | ||
|
|
||
| describe('extractQueryPathTokens', () => { | ||
| it('extracts markdown filenames from natural-language queries', () => { | ||
| const tokens = extractQueryPathTokens( | ||
| 'NOW.md live root entrypoint current state for routing' | ||
| ); | ||
| expect(tokens).toContain('now.md'); | ||
| }); | ||
|
|
||
| it('extracts script paths and hyphenated stems', () => { | ||
| const tokens = extractQueryPathTokens( | ||
| 'what must I check before editing AGENTS.md protected agent instructions' | ||
| ); | ||
| expect(tokens).toContain('agents.md'); | ||
| }); | ||
|
|
||
| it('extracts explicit script module names', () => { | ||
| const tokens = extractQueryPathTokens('run check-agent-instructions before commit'); | ||
| expect(tokens).toContain('check-agent-instructions'); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removing the unreleased label presents
projects[].parsing.maxChunksas available to npm users. The guide says unmarked features apply to the published package, but thev2.3.0config reader does not readparsing; this option was added later. Keep an unreleased or built-checkout qualifier here and inREADME.md. The repository requires public docs not to claim capabilities that are not shipped, so this must be corrected before merging.Context Used: AGENTS.md (source)