feat(indexing): support custom file extensions

This commit is contained in:
marius-kilocode
2026-07-17 11:46:57 +02:00
parent 2c070e6e6f
commit c081f582ab
34 changed files with 497 additions and 18 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@kilocode/cli": minor
"@kilocode/kilo-indexing": minor
"kilo-code": minor
---
Configure a custom file extension allowlist for codebase indexing to limit scans to relevant project files and support additional text formats.
+1
View File
@@ -166,6 +166,7 @@
"name": "@kilocode/kilo-console",
"version": "7.4.11",
"dependencies": {
"@kilocode/kilo-indexing": "workspace:*",
"@kilocode/kilo-web-ui": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@lottiefiles/dotlottie-web": "0.74.0",
+1
View File
@@ -11,6 +11,7 @@
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@kilocode/kilo-indexing": "workspace:*",
"@kilocode/kilo-web-ui": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@lottiefiles/dotlottie-web": "0.74.0",
@@ -6,7 +6,16 @@ import { CustomSelect, type SelectOption } from "../../components/CustomSelect"
import { loadEmbeddingModels } from "../../client"
import { useConfig } from "../../context/config"
import { ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage"
import { clean, clone, merge, providerPatch, removed, shouldSync, validate } from "./state/indexing"
import {
clean,
clone,
merge,
parseFileExtensions,
providerPatch,
removed,
shouldSync,
validate,
} from "./state/indexing"
type Provider = NonNullable<IndexingConfig["provider"]>
type ProviderValue = Provider | ""
@@ -111,6 +120,7 @@ export function IndexingRoute() {
const [draft, setDraft] = createSignal<IndexingConfig>({})
const [source, setSource] = createSignal("")
const [dirty, setDirty] = createSignal(false)
const [extensionText, setExtensionText] = createSignal("")
const scope = () => ctx.query()?.scope ?? "global"
const [selected, setSelected] = createSignal(scope())
const project = () => scope() === "project"
@@ -149,6 +159,8 @@ export function IndexingRoute() {
setSelected(current)
setSource(key)
setDraft(clone(next))
const extensions = project() ? merge(global(), next).fileExtensions : next.fileExtensions
setExtensionText(extensions?.join(", ") ?? "")
setDirty(false)
})
@@ -478,6 +490,28 @@ export function IndexingRoute() {
</div>
</header>
<div class="ui-form agent-builder-form">
<FieldCard
label="File extensions"
description="Comma-separated allowlist. Leave empty to use the built-in defaults."
actions={
<SourceBadge
source={field("fileExtensions")?.source}
inherited={field("fileExtensions")?.inherited}
overridden={field("fileExtensions")?.overridden}
/>
}
>
<input
value={extensionText()}
placeholder=".php, .js, .css"
disabled={Boolean(ctx.saving())}
onInput={(event) => {
const value = event.currentTarget.value
setExtensionText(value)
update({ fileExtensions: parseFileExtensions(value) })
}}
/>
</FieldCard>
<FieldCard
label="Minimum search score"
description="Similarity threshold from 0 to 1. Default is model-specific or 0.4."
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { clean, merge, providerPatch, removed, shouldSync, validate } from "./indexing"
import { clean, merge, parseFileExtensions, providerPatch, removed, shouldSync, validate } from "./indexing"
describe("indexing config state", () => {
test("merges project settings over nested global settings", () => {
@@ -59,4 +59,13 @@ describe("indexing config state", () => {
"Embedding batch size must be a positive integer.",
])
})
test("parses and cleans file extension allowlists", () => {
expect(parseFileExtensions(" PHP, , .JS, js, C++, c++ ")).toEqual([".c++", ".js", ".php"])
expect(parseFileExtensions(" , , ")).toBeUndefined()
expect(clean({ fileExtensions: [] })).toEqual({})
expect(validate({ fileExtensions: ["*.js", ".d.ts", "src/php"] })).toEqual([
"File extensions must contain only a name with an optional leading dot.",
])
})
})
@@ -1,4 +1,7 @@
import type { IndexingConfig } from "@kilocode/sdk/v2/client"
import { isFileExtension } from "@kilocode/kilo-indexing/file-extensions"
export { parseFileExtensions } from "@kilocode/kilo-indexing/file-extensions"
function record(input: unknown): input is Record<string, unknown> {
return typeof input === "object" && input !== null && !Array.isArray(input)
@@ -26,6 +29,7 @@ export function merge(base: IndexingConfig | undefined, patch: IndexingConfig |
function prune(input: unknown): unknown {
if (typeof input === "string") return input.trim() || undefined
if (Array.isArray(input)) return input.length > 0 ? input : undefined
if (!record(input)) return input ?? undefined
const entries = Object.entries(input).flatMap(([key, value]) => {
const next = prune(value)
@@ -64,6 +68,9 @@ export function removed(before: IndexingConfig, after: IndexingConfig): string[]
export function validate(input: IndexingConfig): string[] {
const errors: string[] = []
if (input.fileExtensions?.some((item) => !isFileExtension(item))) {
errors.push("File extensions must contain only a name with an optional leading dot.")
}
if (input.dimension !== undefined && input.dimension !== null) {
if (!Number.isInteger(input.dimension) || input.dimension <= 0)
errors.push("Vector dimension must be a positive integer.")
@@ -51,6 +51,7 @@ You can also edit the `indexing` section in `kilo.jsonc` directly:
"enabled": true,
"provider": "openai",
"model": "text-embedding-3-small",
"fileExtensions": [".php", ".js", ".css"],
"vectorStore": "lancedb",
"openai": { "apiKey": "sk-..." },
"lancedb": {}
@@ -129,6 +130,7 @@ You can also edit the `indexing` section directly. This is the full shape of the
"apiKey": "pa-..."
},
"lancedb": {},
"fileExtensions": [".php", ".js", ".css"],
"searchMinScore": 0.4,
"searchMaxResults": 50,
"embeddingBatchSize": 60,
@@ -227,6 +229,18 @@ The interface shows real-time status:
### Automatic File Filtering
Set `indexing.fileExtensions` to a non-empty array to index only the listed file extensions. Values are case-insensitive and may be written with or without a leading dot. When this setting is omitted, Kilo uses its built-in language list. Configured text formats without a Tree-sitter parser use line-based fallback chunking.
```json
{
"indexing": {
"fileExtensions": [".php", ".js", ".css"]
}
}
```
The configured list replaces the built-in defaults rather than adding to them. Clear the field in the settings UI to restore the defaults, or to inherit the global list from project scope.
The indexer automatically excludes:
- Binary files and images
+1
View File
@@ -19,6 +19,7 @@
"./detect": "./src/detect.ts",
"./embedding-models": "./src/kilo-embedding-models.ts",
"./engine": "./src/indexing/index.ts",
"./file-extensions": "./src/file-extensions.ts",
"./server": "./src/server/routes.ts",
"./status": "./src/status.ts"
},
+17
View File
@@ -3,8 +3,10 @@ import z from "zod"
import type { IndexingConfigInput } from "./indexing/config-manager"
import { DEFAULT_VECTOR_STORE } from "./indexing/constants"
import type { EmbedderProvider } from "./indexing/interfaces/manager"
import { FILE_EXTENSION_PATTERN, normalizeFileExtensions } from "./file-extensions"
export { DEFAULT_VECTOR_STORE } from "./indexing/constants"
export { isFileExtension, normalizeFileExtensions, parseFileExtensions } from "./file-extensions"
const providers = [
"kilo",
@@ -128,6 +130,11 @@ export const IndexingConfig = z
.positive()
.optional()
.describe("Maximum retry attempts for failed embedding batches (default: 3)"),
fileExtensions: z
.array(z.string().trim().regex(FILE_EXTENSION_PATTERN))
.min(1)
.optional()
.describe("File extension allowlist for codebase indexing (uses built-in defaults if omitted)"),
})
.strict()
.meta({ ref: "IndexingConfig" })
@@ -229,6 +236,15 @@ export const IndexingSchema = Schema.Struct({
scannerMaxBatchRetries: Schema.optional(PositiveInt).annotate({
description: "Maximum retry attempts for failed embedding batches (default: 3)",
}),
fileExtensions: Schema.optional(
Schema.mutable(
Schema.Array(Schema.String.check(Schema.isPattern(/^\s*\.?[A-Za-z0-9][A-Za-z0-9_+-]*\s*$/))).check(
Schema.isMinLength(1),
),
),
).annotate({
description: "File extension allowlist for codebase indexing (uses built-in defaults if omitted)",
}),
}).annotate({
identifier: "IndexingConfig",
description: "Codebase indexing configuration",
@@ -250,6 +266,7 @@ export function toIndexingConfigInput(cfg: IndexingConfig | undefined): Indexing
searchMaxResults: cfg?.searchMaxResults,
embeddingBatchSize: cfg?.embeddingBatchSize,
scannerMaxBatchRetries: cfg?.scannerMaxBatchRetries,
fileExtensions: normalizeFileExtensions(cfg?.fileExtensions),
kiloApiKey: cfg?.kilo?.apiKey,
kiloBaseUrl: cfg?.kilo?.baseUrl,
kiloOrganizationId: cfg?.kilo?.organizationId,
@@ -0,0 +1,24 @@
export const FILE_EXTENSION_PATTERN = /^\.?[A-Za-z0-9][A-Za-z0-9_+-]*$/
export function isFileExtension(input: string): boolean {
return FILE_EXTENSION_PATTERN.test(input.trim())
}
export function normalizeFileExtensions(input: readonly string[] | undefined): string[] | undefined {
if (!input) return undefined
const values = new Set<string>()
for (const raw of input) {
const item = raw.trim().toLowerCase()
if (!item) continue
values.add(item.startsWith(".") ? item : `.${item}`)
}
return values.size > 0 ? [...values].sort() : undefined
}
export function parseFileExtensions(input: string): string[] | undefined {
const values = input
.split(",")
.map((item) => item.trim())
.filter(Boolean)
return values.length > 0 ? normalizeFileExtensions(values) : undefined
}
@@ -3,6 +3,7 @@ import type { CodeIndexConfig, PreviousConfigSnapshot } from "./interfaces/confi
import { DEFAULT_SEARCH_MIN_SCORE, DEFAULT_MAX_SEARCH_RESULTS, DEFAULT_VECTOR_STORE } from "./constants"
import { getDefaultModelId, getModelDimension, getModelScoreThreshold } from "./model-registry"
import { isEmbeddingProfileEqual, resolveEmbeddingProfile } from "./embedding-profile"
import { resolveFileExtensions } from "./shared/supported-extensions"
/**
* Raw input fed to CodeIndexConfigManager from the host environment.
@@ -22,6 +23,7 @@ export interface IndexingConfigInput {
searchMaxResults?: number
embeddingBatchSize?: number
scannerMaxBatchRetries?: number
fileExtensions?: string[]
kiloApiKey?: string
kiloBaseUrl?: string
kiloOrganizationId?: string
@@ -70,6 +72,7 @@ export class CodeIndexConfigManager {
private searchMaxResults?: number
private embeddingBatchSize?: number
private scannerMaxBatchRetries?: number
private fileExtensions: string[] = resolveFileExtensions(undefined)
constructor(input: IndexingConfigInput) {
this.applyInput(input)
@@ -96,6 +99,7 @@ export class CodeIndexConfigManager {
this.searchMaxResults = input.searchMaxResults
this.embeddingBatchSize = input.embeddingBatchSize
this.scannerMaxBatchRetries = input.scannerMaxBatchRetries
this.fileExtensions = resolveFileExtensions(input.fileExtensions)
this.modelId = input.modelId
// Validate and set model dimension
@@ -153,6 +157,7 @@ export class CodeIndexConfigManager {
voyageApiKey: this.voyageOptions?.apiKey ?? "",
qdrantUrl: this.qdrantUrl ?? "",
qdrantApiKey: this.qdrantApiKey ?? "",
fileExtensions: [...this.fileExtensions],
}
}
@@ -230,6 +235,8 @@ export class CodeIndexConfigManager {
if ((prev.qdrantUrl ?? "") !== (this.qdrantUrl ?? "") || (prev.qdrantApiKey ?? "") !== (this.qdrantApiKey ?? ""))
return true
if (prev.fileExtensions.join("\0") !== this.fileExtensions.join("\0")) return true
if (this.hasEmbeddingProfileChanged(prevProvider, prev.modelId, prev.modelDimension)) return true
return false
@@ -276,6 +283,7 @@ export class CodeIndexConfigManager {
searchMaxResults: this.currentSearchMaxResults,
embeddingBatchSize: this.currentEmbeddingBatchSize,
scannerMaxBatchRetries: this.currentScannerMaxBatchRetries,
fileExtensions: [...this.fileExtensions],
}
}
@@ -30,6 +30,7 @@ export interface CodeIndexConfig {
searchMaxResults?: number
embeddingBatchSize?: number
scannerMaxBatchRetries?: number
fileExtensions: string[]
}
export type PreviousConfigSnapshot = {
@@ -57,4 +58,5 @@ export type PreviousConfigSnapshot = {
voyageApiKey?: string
qdrantUrl?: string
qdrantApiKey?: string
fileExtensions: string[]
}
@@ -14,6 +14,7 @@ import {
import { scannerExtensions } from "../shared/supported-extensions"
import {
type IFileWatcher,
type ICodeParser,
type FileProcessingResult,
type IEmbedder,
type IVectorStore,
@@ -33,6 +34,7 @@ import { Log } from "../../util/log"
import type { WorktreeOverlay } from "../worktree-overlay"
import { sanitizeErrorMessage } from "../shared/validation-helpers"
import type { IgnoreMatcher } from "../shared/load-ignore"
import { isBinary } from "../shared/is-binary"
const log = Log.create({ service: "file-watcher" })
@@ -56,6 +58,7 @@ export class FileWatcher implements IFileWatcher {
private drainTask?: Promise<void>
private ready?: Promise<void>
private overlay?: WorktreeOverlay
private readonly extensions: ReadonlySet<string>
public readonly onDidStartBatchProcessing = new Emitter<string[]>()
public readonly onBatchProgressUpdate = new Emitter<{
@@ -75,12 +78,15 @@ export class FileWatcher implements IFileWatcher {
maxBatchRetries?: number,
private readonly onTelemetry?: IndexingTelemetryReporter,
private readonly telemetryMeta?: IndexingTelemetryMeta,
extensions: readonly string[] = scannerExtensions,
private readonly parser: ICodeParser = codeParser,
) {
if (ignoreInstance) {
this.ignoreInstance = ignoreInstance
}
this.batchSegmentThreshold = batchSegmentThreshold ?? BATCH_SEGMENT_THRESHOLD
this.maxBatchRetries = maxBatchRetries ?? MAX_BATCH_RETRIES
this.extensions = new Set(extensions)
}
private emitRetry(attempt: number, batchSize: number, err: unknown): void {
@@ -271,7 +277,7 @@ export class FileWatcher implements IFileWatcher {
const ext = path.extname(filePath).toLowerCase()
if (FileIgnore.match(relativeFilePath)) return false
if (this.ignoreInstance?.ignores(relativeFilePath)) return false
return scannerExtensions.includes(ext) || !path.extname(filePath)
return this.extensions.has(ext)
}
/**
@@ -657,6 +663,14 @@ export class FileWatcher implements IFileWatcher {
*/
async processFile(filePath: string): Promise<FileProcessingResult> {
try {
if (!this.extensions.has(path.extname(filePath).toLowerCase())) {
return {
path: filePath,
status: "skipped" as const,
reason: "File extension is not configured for indexing",
}
}
// Check if file is in an ignored directory
const relativeFilePath = generateRelativeIgnorePath(filePath, this.workspacePath)
if (!relativeFilePath) {
@@ -695,7 +709,16 @@ export class FileWatcher implements IFileWatcher {
}
// Read file content
const content = await readFile(filePath, "utf-8")
const bytes = await readFile(filePath)
if (isBinary(bytes)) {
this.cacheManager.deleteHash(filePath)
return {
path: filePath,
status: "skipped" as const,
reason: "File is binary",
}
}
const content = bytes.toString("utf-8")
// Calculate hash
const newHash = createHash("sha256").update(content).digest("hex")
@@ -710,7 +733,7 @@ export class FileWatcher implements IFileWatcher {
}
// Parse file
const blocks = await codeParser.parseFile(filePath, { content, fileHash: newHash })
const blocks = await this.parser.parseFile(filePath, { content, fileHash: newHash })
// Prepare points for batch processing
let pointsToUpsert: PointStruct[] = []
@@ -20,6 +20,11 @@ export class CodeParser implements ICodeParser {
private pendingLoads: Map<string, Promise<LanguageParser>> = new Map()
private failedParsers: Set<string> = new Set()
private parserFallbackNotified: Set<string> = new Set()
private readonly extensions: ReadonlySet<string>
constructor(extensions: readonly string[] = scannerExtensions) {
this.extensions = new Set(extensions)
}
// Markdown files are now supported using the custom markdown parser
// which extracts headers and sections for semantic indexing
@@ -93,7 +98,7 @@ export class CodeParser implements ICodeParser {
* @returns Boolean indicating if the language is supported
*/
private isSupportedLanguage(extension: string): boolean {
return scannerExtensions.includes(extension)
return this.extensions.has(extension)
}
/**
@@ -28,6 +28,7 @@ import { Log } from "../../util/log"
import { sanitizeErrorMessage } from "../shared/validation-helpers"
import type { IndexingTelemetryMeta, IndexingTelemetryMode, IndexingTelemetryReporter } from "../interfaces/telemetry"
import type { IgnoreMatcher } from "../shared/load-ignore"
import { isBinary } from "../shared/is-binary"
const log = Log.create({ service: "indexing-scanner" })
@@ -35,6 +36,7 @@ export class DirectoryScanner implements IDirectoryScanner {
private _cancelled = false
private batchSegmentThreshold: number
private maxBatchRetries: number
private readonly extensions: ReadonlySet<string>
constructor(
private readonly embedder: IEmbedder,
@@ -46,9 +48,11 @@ export class DirectoryScanner implements IDirectoryScanner {
maxBatchRetries?: number,
private readonly onTelemetry?: IndexingTelemetryReporter,
private readonly telemetryMeta?: IndexingTelemetryMeta,
extensions: readonly string[] = scannerExtensions,
) {
this.batchSegmentThreshold = batchSegmentThreshold ?? BATCH_SEGMENT_THRESHOLD
this.maxBatchRetries = maxBatchRetries ?? MAX_BATCH_RETRIES
this.extensions = new Set(extensions)
}
private emitFileCount(mode: IndexingTelemetryMode, discovered: number, candidate: number): void {
@@ -166,7 +170,7 @@ export class DirectoryScanner implements IDirectoryScanner {
return false
}
return scannerExtensions.includes(ext) && !this.ignoreInstance.ignores(relativeFilePath)
return this.extensions.has(ext) && !this.ignoreInstance.ignores(relativeFilePath)
})
log.info("discovered candidate files for indexing", {
workspacePath: scanWorkspace,
@@ -265,7 +269,12 @@ export class DirectoryScanner implements IDirectoryScanner {
}
// Read file content using fs/promises
const content = await readFile(filePath, "utf-8")
const bytes = await readFile(filePath)
if (isBinary(bytes)) {
skippedCount++
return
}
const content = bytes.toString("utf-8")
if (this._cancelled) {
return
@@ -23,6 +23,12 @@ export class CodeIndexSearchService {
private readonly baseline?: BaselineSearch,
) {}
private allowed(result: VectorStoreSearchResult, extensions: ReadonlySet<string>): boolean {
const file = result.payload?.filePath
if (typeof file !== "string") return false
return extensions.has(path.extname(file).toLowerCase())
}
public async searchIndex(query: string, directoryPrefix?: string): Promise<VectorStoreSearchResult[]> {
if (!this.configManager.isFeatureEnabled || !this.configManager.isFeatureConfigured) {
throw new Error("Code index feature is disabled or not configured.")
@@ -44,7 +50,11 @@ export class CodeIndexSearchService {
}
const normalizedPrefix = directoryPrefix ? path.normalize(directoryPrefix) : undefined
if (!this.baseline) return await this.vectorStore.search(vector, normalizedPrefix, minScore, maxResults)
const extensions = new Set(this.configManager.getConfig().fileExtensions)
if (!this.baseline) {
const results = await this.vectorStore.search(vector, normalizedPrefix, minScore, maxResults)
return results.filter((result) => this.allowed(result, extensions))
}
if (!this.baseline.overlay.ready) throw new Error("Worktree index reconciliation is not complete.")
const ceiling = Math.max(maxResults, Math.min(maxResults * 16, 1000))
@@ -77,9 +87,12 @@ export class CodeIndexSearchService {
"\0",
)
for (const result of baseline) merged.set(key(result), result)
for (const result of baseline) {
if (this.allowed(result, extensions)) merged.set(key(result), result)
}
for (const result of current) {
if (this.baseline.overlay.deltaResult(result)) merged.set(key(result), result)
if (this.allowed(result, extensions) && this.baseline.overlay.deltaResult(result))
merged.set(key(result), result)
}
return [...merged.values()].sort((left, right) => right.score - left.score).slice(0, maxResults)
} catch (err) {
@@ -15,7 +15,7 @@ import { OpenRouterEmbedder } from "./embedders/openrouter"
import { VoyageEmbedder } from "./embedders/voyage"
import { QdrantVectorStore } from "./vector-store/qdrant-client"
import { LanceDBVectorStore } from "./vector-store/lancedb-vector-store"
import { codeParser, DirectoryScanner, FileWatcher } from "./processors"
import { CodeParser, DirectoryScanner, FileWatcher } from "./processors"
import type { AvailableEmbedders, ICodeParser, IEmbedder, IFileWatcher, IVectorStore } from "./interfaces"
import type { CodeIndexConfigManager } from "./config-manager"
import type { CacheManager } from "./cache-manager"
@@ -240,6 +240,7 @@ export class CodeIndexServiceFactory {
config.scannerMaxBatchRetries,
this.onTelemetry,
meta,
config.fileExtensions,
)
}
@@ -248,6 +249,7 @@ export class CodeIndexServiceFactory {
vectorStore: IVectorStore,
cacheManager: CacheManager,
ignoreInstance: IgnoreMatcher,
parser: ICodeParser,
): IFileWatcher {
const config = this.configManager.getConfig()
const meta = this.getTelemetryMeta()
@@ -261,6 +263,8 @@ export class CodeIndexServiceFactory {
config.scannerMaxBatchRetries,
this.onTelemetry,
meta,
config.fileExtensions,
parser,
)
}
@@ -289,9 +293,9 @@ export class CodeIndexServiceFactory {
const embedder = this.createEmbedder()
const vectorStore = this.createVectorStore()
const parser = codeParser
const parser = new CodeParser(config.fileExtensions)
const scanner = this.createDirectoryScanner(embedder, vectorStore, parser, ignoreInstance)
const fileWatcher = this.createFileWatcher(embedder, vectorStore, cacheManager, ignoreInstance)
const fileWatcher = this.createFileWatcher(embedder, vectorStore, cacheManager, ignoreInstance, parser)
log.info("indexing services created", {
workspacePath: this.workspacePath,
@@ -0,0 +1,14 @@
const SAMPLE_BYTES = 4096
export function isBinary(input: Uint8Array): boolean {
const length = Math.min(input.length, SAMPLE_BYTES)
if (length === 0) return false
let control = 0
for (let index = 0; index < length; index++) {
const byte = input[index]
if (byte === 0) return true
if (byte < 9 || (byte > 13 && byte < 32)) control++
}
return control / length > 0.3
}
@@ -1,8 +1,13 @@
import { extensions as allExtensions } from "../../tree-sitter"
import { normalizeFileExtensions } from "../../file-extensions"
// Include all extensions including markdown for the scanner
export const scannerExtensions = allExtensions
export function resolveFileExtensions(input: readonly string[] | undefined): string[] {
return normalizeFileExtensions(input) ?? [...scannerExtensions]
}
/**
* Extensions that should always use fallback chunking instead of tree-sitter parsing.
*
@@ -1,5 +1,10 @@
import { describe, expect, test } from "bun:test"
import { toIndexingConfigInput } from "../../../src/config"
import {
IndexingConfig,
normalizeFileExtensions,
parseFileExtensions,
toIndexingConfigInput,
} from "../../../src/config"
import { CodeIndexConfigManager, type IndexingConfigInput } from "../../../src/indexing/config-manager"
function createInput(input: Partial<IndexingConfigInput> = {}): IndexingConfigInput {
@@ -72,6 +77,25 @@ describe("CodeIndexConfigManager", () => {
expect(cfg.getConfig().vectorStoreProvider).toBe("qdrant")
})
test("normalizes configured file extensions", () => {
expect(normalizeFileExtensions([" PHP ", ".JS", "js", "css"])).toEqual([".css", ".js", ".php"])
expect(parseFileExtensions(" PHP, .JS, js, css ")).toEqual([".css", ".js", ".php"])
expect(parseFileExtensions(" ")).toBeUndefined()
expect(toIndexingConfigInput({ fileExtensions: ["PHP", ".JS"] }).fileExtensions).toEqual([".js", ".php"])
expect(normalizeFileExtensions(["", " "])).toBeUndefined()
expect(
normalizeFileExtensions(Array.from({ length: 10_000 }, (_, index) => (index % 2 ? " PHP " : ".JS"))),
).toEqual([".js", ".php"])
})
test("validates file extension tokens", () => {
expect(IndexingConfig.safeParse({ fileExtensions: ["php", " .JS "] }).success).toBe(true)
expect(IndexingConfig.safeParse({ fileExtensions: [] }).success).toBe(false)
expect(IndexingConfig.safeParse({ fileExtensions: ["*.js"] }).success).toBe(false)
expect(IndexingConfig.safeParse({ fileExtensions: ["src/php"] }).success).toBe(false)
expect(IndexingConfig.safeParse({ fileExtensions: [".d.ts"] }).success).toBe(false)
})
test("configures Kilo with hosted auth options and explicit model metadata", () => {
const cfg = new CodeIndexConfigManager(
createInput({
@@ -206,5 +230,13 @@ describe("CodeIndexConfigManager", () => {
expect(result.requiresRestart).toBe(true)
})
test("restarts only when the normalized file extension allowlist changes", () => {
const cfg = new CodeIndexConfigManager(createInput({ fileExtensions: ["php", ".JS"] }))
expect(cfg.getConfig().fileExtensions).toEqual([".js", ".php"])
expect(cfg.loadConfiguration(createInput({ fileExtensions: [".js", ".PHP", "php"] })).requiresRestart).toBe(false)
expect(cfg.loadConfiguration(createInput({ fileExtensions: [".css"] })).requiresRestart).toBe(true)
})
})
})
@@ -14,6 +14,7 @@ import type {
VectorStoreSearchResult,
} from "../../../../src/indexing/interfaces"
import { FileWatcher } from "../../../../src/indexing/processors/file-watcher"
import { CodeParser } from "../../../../src/indexing/processors/parser"
import { loadIgnore } from "../../../../src/indexing/shared/load-ignore"
import { WorktreeOverlay } from "../../../../src/indexing/worktree-overlay"
@@ -318,6 +319,46 @@ describe("FileWatcher", () => {
expect(result.reason).toBe("File is ignored by .gitignore or .kilocodeignore")
})
test("processFile uses the configured extension allowlist", async () => {
const root = await mkdtemp(path.join(tmpdir(), "file-watcher-test-"))
const cacheDir = path.join(root, ".cache")
const custom = path.join(root, "source.custom")
const excluded = path.join(root, "source.ts")
const content = "custom source content ".repeat(20)
await mkdir(cacheDir, { recursive: true })
await writeFile(custom, content)
await writeFile(excluded, content)
const cache = new CacheManager(cacheDir, root)
await cache.initialize()
const watcher = new FileWatcher(
root,
cache,
createEmbedder(),
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
[".custom"],
new CodeParser([".custom"]),
)
const first = await watcher.processFile(custom)
expect(first.status).toBe("processed_for_batching")
if (first.status === "processed_for_batching" && first.newHash) cache.updateHash(custom, first.newHash)
expect(await watcher.processFile(excluded)).toMatchObject({
status: "skipped",
reason: "File extension is not configured for indexing",
})
await writeFile(custom, new Uint8Array([0, 1, 2, 3]))
expect(await watcher.processFile(custom)).toMatchObject({ status: "skipped", reason: "File is binary" })
expect(cache.getHash(custom)).toBeUndefined()
await writeFile(custom, content)
expect((await watcher.processFile(custom)).status).toBe("processed_for_batching")
})
test("processFile skips files matched by nested .gitignore during incremental updates", async () => {
const root = await mkdtemp(path.join(tmpdir(), "file-watcher-test-"))
try {
@@ -71,6 +71,21 @@ describe("CodeParser", () => {
expect(result).toEqual([])
})
test("uses fallback chunking for configured custom extensions", async () => {
const custom = new CodeParser([".custom"])
const result = await custom.parseFile("test.custom", { content: "custom source line ".repeat(20) })
expect(result.length).toBeGreaterThan(0)
expect(result[0]?.type).toBe("fallback_chunk")
})
test("excludes built-in extensions outside a configured allowlist", async () => {
const custom = new CodeParser([".php"])
const result = await custom.parseFile("test.js", { content: "const value = 1;".repeat(20) })
expect(result).toEqual([])
})
test("should use provided content instead of reading file when options.content is provided", async () => {
const content = `/* This is a long test content string that exceeds 100 characters to properly test the parser's behavior with large inputs.
It includes multiple lines and various JavaScript constructs to simulate real-world code.
@@ -309,6 +309,63 @@ describe("DirectoryScanner", () => {
expect(cache.getHash(open)).toBeDefined()
})
test("uses a configured extension allowlist and removes excluded cached files", async () => {
const root = await mkdtemp(join(tmpdir(), "scanner-test-"))
const cacheDir = await mkdtemp(join(tmpdir(), "scanner-cache-"))
const custom = join(root, "main.custom")
const excluded = join(root, "main.ts")
await Bun.write(custom, "custom source content\n")
await Bun.write(excluded, "export const excluded = 1\n")
const cache = new CacheManager(cacheDir, root)
await cache.initialize()
cache.updateHash(excluded, "old-hash")
const scan = new DirectoryScanner(
new Emb(),
new Store(),
new Parser(),
cache,
ignore(),
1,
1,
undefined,
undefined,
[".custom"],
)
const result = await scan.scanDirectory(root)
expect(result.stats.processed).toBe(1)
expect(cache.getHash(custom)).toBeDefined()
expect(cache.getHash(excluded)).toBeUndefined()
})
test("skips binary files admitted by a custom extension", async () => {
const root = await mkdtemp(join(tmpdir(), "scanner-test-"))
const cacheDir = await mkdtemp(join(tmpdir(), "scanner-cache-"))
const file = join(root, "data.custom")
await Bun.write(file, new Uint8Array([0, 1, 2, 3]))
const cache = new CacheManager(cacheDir, root)
await cache.initialize()
const scan = new DirectoryScanner(
new Emb(),
new Store(),
new Parser(),
cache,
ignore(),
1,
1,
undefined,
undefined,
[".custom"],
)
const result = await scan.scanDirectory(root)
expect(result.stats).toEqual({ processed: 0, skipped: 1 })
expect(cache.getHash(file)).toBeUndefined()
})
test("skips files matched by nested .kilocodeignore during full scans", async () => {
const root = await mkdtemp(join(tmpdir(), "scanner-test-"))
const cacheDir = await mkdtemp(join(tmpdir(), "scanner-cache-"))
@@ -36,16 +36,34 @@ const embedder = (calls: string[][]): IEmbedder => ({
},
})
const config = () =>
const config = (fileExtensions?: string[]) =>
new CodeIndexConfigManager({
enabled: true,
embedderProvider: "openai",
openAiKey: "test",
vectorStoreProvider: "lancedb",
searchMaxResults: 2,
fileExtensions,
})
describe("CodeIndexSearchService worktree search", () => {
test("filters stale results using one vector query", async () => {
const limits: number[] = []
const state = new CodeIndexStateManager()
state.setSystemState("Indexed")
const service = new CodeIndexSearchService(
config([".php"]),
state,
embedder([]),
store([result("src/old.ts", 0.99), result("src/first.php", 0.9), result("src/second.php", 0.8)], limits),
)
const results = await service.searchIndex("query")
expect(limits).toEqual([2])
expect(results.map((item) => item.payload?.filePath)).toEqual(["src/first.php"])
})
test("embeds once, hides baseline paths, and merges the current delta", async () => {
const root = await mkdtemp(path.join(tmpdir(), "search-worktree-"))
const main = await mkdtemp(path.join(tmpdir(), "search-main-"))
@@ -126,4 +126,13 @@ describe("indexing tab scope state", () => {
qdrant: { url: "http://project", apiKey: "global-secret" },
})
})
it("replaces inherited file extensions with a project allowlist", () => {
const global = { fileExtensions: [".ts", ".tsx"] }
expect(indexingConfig("project", global, {}).fileExtensions).toEqual([".ts", ".tsx"])
expect(indexingConfig("project", global, { fileExtensions: [".php"] }).fileExtensions).toEqual([".php"])
expect(indexingInheritance("project", global, {}, [["fileExtensions"]])).toBe("inherited")
expect(indexingInheritance("project", global, { fileExtensions: [".php"] }, [["fileExtensions"]])).toBe("none")
})
})
@@ -1,7 +1,7 @@
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { Button } from "@kilocode/kilo-ui/button"
import { Card } from "@kilocode/kilo-ui/card"
import { DEFAULT_VECTOR_STORE } from "@kilocode/kilo-indexing/config"
import { DEFAULT_VECTOR_STORE, isFileExtension, parseFileExtensions } from "@kilocode/kilo-indexing/config"
import { formatKiloEmbeddingModelLabel, getKiloEmbeddingModel } from "@kilocode/kilo-indexing/embedding-models"
import { Select } from "@kilocode/kilo-ui/select"
import { Switch } from "@kilocode/kilo-ui/switch"
@@ -102,6 +102,8 @@ const IndexingTab: Component = () => {
const [providerDrafts, setProviderDrafts] = createSignal<Record<string, string>>({})
const [storeDrafts, setStoreDrafts] = createSignal<Record<string, string>>({})
const [tuningDrafts, setTuningDrafts] = createSignal<Record<string, string>>({})
const [extensionDrafts, setExtensionDrafts] = createSignal<Record<string, string>>({})
const [extensionErrors, setExtensionErrors] = createSignal<Record<string, string>>({})
const [scope, setScope] = createSignal<IndexingScope>("global")
const globalCfg = createMemo<IndexingConfig>(() => globalConfig().indexing ?? {})
@@ -247,6 +249,30 @@ const IndexingTab: Component = () => {
return value === undefined ? "" : String(value)
}
const extensionValue = () => {
const draft = extensionDrafts()[scope()]
if (draft !== undefined) return draft
return cfg().fileExtensions?.join(", ") ?? ""
}
const saveExtensions = (value: string) => {
const values = value
.split(",")
.map((item) => item.trim())
.filter(Boolean)
const invalid = values.find((item) => !isFileExtension(item))
if (invalid) {
setExtensionErrors((prev) => ({
...prev,
[scope()]: language.t("settings.indexing.fileExtensions.invalid", { extension: invalid }),
}))
return
}
updateIndexing({ fileExtensions: parseFileExtensions(value) })
setExtensionDrafts((prev) => Object.fromEntries(Object.entries(prev).filter(([entry]) => entry !== scope())))
setExtensionErrors((prev) => Object.fromEntries(Object.entries(prev).filter(([entry]) => entry !== scope())))
}
const content = (_scope: IndexingScope) => (
<div style={{ display: "flex", "flex-direction": "column", gap: "16px" }}>
<Card>
@@ -517,6 +543,26 @@ const IndexingTab: Component = () => {
</Card>
<Card>
<SettingsRow
title={language.t("settings.indexing.fileExtensions.title")}
description={description(language.t("settings.indexing.fileExtensions.description"), [["fileExtensions"]])}
tag={() => tag(scope(), [["fileExtensions"]])}
>
<TextField
value={extensionValue()}
error={extensionErrors()[scope()]}
validationState={extensionErrors()[scope()] ? "invalid" : "valid"}
placeholder=".php, .js, .css"
onInput={(e: InputEvent) => {
const target = e.currentTarget as HTMLInputElement
setExtensionDrafts((prev) => ({ ...prev, [scope()]: target.value }))
}}
onBlur={(e: FocusEvent) => {
const target = e.currentTarget as HTMLInputElement
saveExtensions(target.value)
}}
/>
</SettingsRow>
<For each={tuning}>
{(item, index) => (
<SettingsRow
@@ -1216,6 +1216,10 @@ export const dict = {
"settings.indexing.qdrantApiKey.title": "Qdrant API key",
"settings.indexing.qdrantApiKey.description": "Optional API key for the Qdrant instance.",
"settings.indexing.qdrantApiKey.placeholder": "Optional API key",
"settings.indexing.fileExtensions.title": "File Extensions",
"settings.indexing.fileExtensions.description":
"Comma-separated allowlist. Leave empty to use the built-in defaults.",
"settings.indexing.fileExtensions.invalid": "Invalid extension: {{extension}}",
"settings.indexing.tuning.description": "Advanced search and batching parameter.",
"settings.experimental.title": "Experimental",
"settings.language.title": "Language",
@@ -97,6 +97,7 @@ export interface IndexingConfig {
searchMaxResults?: number
embeddingBatchSize?: number
scannerMaxBatchRetries?: number
fileExtensions?: string[]
}
export type KiloEmbeddingModel = {
@@ -9,7 +9,7 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { DialogPrompt } from "@tui/ui/dialog-prompt"
import { DEFAULT_VECTOR_STORE } from "@kilocode/kilo-indexing/config"
import { DEFAULT_VECTOR_STORE, isFileExtension, parseFileExtensions } from "@kilocode/kilo-indexing/config"
import { useSync } from "@tui/context/sync"
import { useToast } from "@tui/ui/toast"
import { createEffect, createMemo, createResource, createSignal, Show } from "solid-js"
@@ -579,6 +579,12 @@ export function DialogIndexing(props: DialogIndexingProps) {
category: "Storage",
description: mark(storeLabel, [["vectorStore"]]),
},
{
value: "fileExtensions",
title: "File Extensions",
category: "Advanced",
description: mark(indexing.fileExtensions?.join(", ") ?? "built-in defaults", [["fileExtensions"]]),
},
{
value: "tuning",
title: "Tuning Parameters",
@@ -680,6 +686,33 @@ export function DialogIndexing(props: DialogIndexingProps) {
<VectorStoreSelect useSDK={props.useSDK} scope={scope()} indexing={indexing} raw={raw} />
))
break
case "fileExtensions": {
const result = await DialogPrompt.show(dialog, "File Extensions", {
value: indexing.fileExtensions?.join(", ") ?? "",
placeholder: ".php, .js, .css (empty uses built-in defaults)",
})
if (result !== null) {
const values = result
.split(",")
.map((item) => item.trim())
.filter(Boolean)
const invalid = values.find((item) => !isFileExtension(item))
if (invalid) {
toast.show({ message: `Invalid file extension: "${invalid}"`, variant: "error" })
} else {
await saveScopedIndexing(
sdk,
sync,
scope(),
raw,
{ ...raw, fileExtensions: parseFileExtensions(result) },
toast,
)
}
}
dialog.replace(() => <DialogIndexing useSDK={props.useSDK} scope={scope()} />)
break
}
case "tuning":
dialog.replace(() => (
<TuningMenu useSDK={props.useSDK} scope={scope()} indexing={indexing} raw={raw} global={globalCfg()} />
@@ -115,6 +115,7 @@ export namespace KilocodeConfigOverlay {
["indexing", "searchMaxResults"],
["indexing", "embeddingBatchSize"],
["indexing", "scannerMaxBatchRetries"],
["indexing", "fileExtensions"],
] as const
const collectionPaths = ["provider", "mcp", "permission", "agent", "formatter", "lsp"] as const
@@ -107,6 +107,15 @@ describe("indexing dialog state", () => {
expect(indexingInheritance("project", global, project, [["searchMinScore"]])).toBe("inherited")
})
test("replaces inherited file extensions with a project allowlist", () => {
const global: IndexingConfig = { fileExtensions: [".ts", ".tsx"] }
const project: IndexingConfig = { fileExtensions: [".php"] }
expect(mergeIndexingConfig(global, project).fileExtensions).toEqual([".php"])
expect(indexingInheritance("project", global, {}, [["fileExtensions"]])).toBe("inherited")
expect(indexingInheritance("project", global, project, [["fileExtensions"]])).toBe("none")
})
test("isolates global auth config from project indexing values", () => {
const project: IndexingConfig = { kilo: { apiKey: "project-key", baseUrl: "https://project.test" } }
const inherited: IndexingConfig = { enabled: true }
@@ -208,6 +208,7 @@ describe("config overlay routes", () => {
indexing: {
enabled: true,
provider: "ollama",
fileExtensions: [".php", ".js"],
ollama: { baseUrl: "http://localhost:11434" },
},
})
@@ -216,6 +217,11 @@ describe("config overlay routes", () => {
expect(body.fields["indexing.enabled"]).toMatchObject({ source: "global", inherited: true, value: true })
expect(body.fields["indexing.provider"]).toMatchObject({ source: "global", inherited: true, value: "ollama" })
expect(body.fields["indexing.fileExtensions"]).toMatchObject({
source: "global",
inherited: true,
value: [".php", ".js"],
})
expect(body.fields["indexing.ollama.baseUrl"]).toMatchObject({
source: "global",
inherited: true,
+1
View File
@@ -1267,6 +1267,7 @@ export type IndexingConfig = {
searchMaxResults?: number
embeddingBatchSize?: number
scannerMaxBatchRetries?: number
fileExtensions?: Array<string>
}
export type PermissionActionConfig = "ask" | "allow" | "deny"
+8
View File
@@ -25844,6 +25844,14 @@
"scannerMaxBatchRetries": {
"type": "integer",
"exclusiveMinimum": 0
},
"fileExtensions": {
"type": "array",
"items": {
"type": "string",
"pattern": "^\\s*\\.?[A-Za-z0-9][A-Za-z0-9_+-]*\\s*$"
},
"minItems": 1
}
},
"additionalProperties": false