diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestIndex.ts b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestIndex.ts index acc320a46f4..c26559f46bc 100644 --- a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestIndex.ts +++ b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestIndex.ts @@ -1042,6 +1042,10 @@ export class ExternalIngestIndex extends Disposable { if (stat.type !== 1) { // FileType.File = 1 return undefined; } + // File system providers may violate FileStat's numeric contract at runtime. + if (!Number.isFinite(stat.size) || !Number.isFinite(stat.mtime)) { + return undefined; + } return { size: stat.size, mtime: stat.mtime }; } catch { return undefined; @@ -1098,5 +1102,3 @@ export class ExternalIngestIndex extends Disposable { return { fileCount: files.length, files }; } } - - diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/test/node/externalIngest.spec.ts b/extensions/copilot/src/platform/workspaceChunkSearch/test/node/externalIngest.spec.ts index c403c7596b5..a80ca51c3d5 100644 --- a/extensions/copilot/src/platform/workspaceChunkSearch/test/node/externalIngest.spec.ts +++ b/extensions/copilot/src/platform/workspaceChunkSearch/test/node/externalIngest.spec.ts @@ -84,6 +84,10 @@ interface MockFileEntry { readonly content: Uint8Array; readonly size: number; readonly mtime: number; + readonly statOverride?: { + readonly field: 'size' | 'mtime'; + readonly value?: number | string; + }; } function createFileFromString(content: string, mtime = Date.now()): MockFileEntry { @@ -126,13 +130,18 @@ class MockFileSystem extends mock() impleme if (!entry) { throw new Error(`File not found: ${uri.toString()}`); } - return { + const stat = { type: FileType.File, ctime: 0, mtime: entry.mtime, size: entry.size, permissions: undefined, }; + if (entry.statOverride) { + // Simulate a provider violating FileStat's runtime contract. + Object.defineProperty(stat, entry.statOverride.field, { value: entry.statOverride.value }); + } + return stat; } override async readFile(uri: URI) { @@ -359,6 +368,41 @@ suite('ExternalIngestIndex', () => { assert.strictEqual(await index.shouldTrackFile(file2, CancellationToken.None), true); }); + test('initialize skips malformed file stats and continues indexing', async () => { + const workspaceRoot = URI.file('/workspace'); + const malformedStats: readonly { + readonly name: string; + readonly field: 'size' | 'mtime'; + readonly value?: number | string; + }[] = [ + { name: 'missing-size.ts', field: 'size' }, + { name: 'string-mtime.ts', field: 'mtime', value: 'invalid' }, + { name: 'nan-size.ts', field: 'size', value: Number.NaN }, + { name: 'infinite-mtime.ts', field: 'mtime', value: Number.POSITIVE_INFINITY }, + ]; + const files = new ResourceMap(); + for (const { name, field, value } of malformedStats) { + files.set(URI.joinPath(workspaceRoot, name), { + ...createFileFromString('const invalid = true;'), + statOverride: { field, value }, + }); + } + const validFile = URI.joinPath(workspaceRoot, 'valid.ts'); + files.set(validFile, createFileFromString('const valid = true;')); + + const { mockClient, index } = setupTestContext(workspaceRoot, files); + await index.initialize(); + const ingestResult = await index.doIngest(testTelemetryInfo, emptyProgressCb, CancellationToken.None); + + assert.deepStrictEqual({ + ingestSucceeded: ingestResult.isOk(), + ingestedFiles: mockClient.ingestedFiles.map(file => file.uri.toString()), + }, { + ingestSucceeded: true, + ingestedFiles: [validFile.toString()], + }); + }); + test('files that fail canIngestPathAndSize are tracked but not ingested', async () => { const workspaceRoot = URI.file('/workspace'); const file1 = URI.joinPath(workspaceRoot, 'small.ts');