diff --git a/CHANGELOG.md b/CHANGELOG.md index c9b2970..fe2fbb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -163,6 +163,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Files opted in with `includeIgnored` now stay indexed on Git older than 2.36, and embedded repositories remain visible to the watcher (thanks @maxmilian and @newshowardz777; #1549). +- `codegraph init` and `codegraph index` now list unsupported file extensions and explain that CodeGraph is inactive when no supported source files are found (#1502). + #### Screens, links and navigation - **Where the app goes after login is a fork, not two always-es.** A navigation whose destination comes back from a helper — `router.replace(await resolvePostLoginRoute())` over `return (await hasSeenWelcome(…)) ? '/home/' : '/welcome/'` — drew both screens with no condition, reading as if the welcome screen always shows. The two arms share a line, and only a column can tell them apart; each synthesized edge now carries its literal's own position, so the guard reader says which arm it is: `WHEN await hasSeenWelcome(…)` → home, and its negation → welcome. And the scan starts at the helper's body, so a literal-union return type — `Promise<'/welcome/' | '/home/'>`, whose routes are string literals too, written first — no longer stands in for the navigation itself. Re-index after upgrading to pick the positions up. diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 615d9c7..16e0fc2 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -8,8 +8,9 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { execFileSync } from 'child_process'; import { CodeGraph } from '../src'; -import { extractFromSource, scanDirectory, buildDefaultIgnore, discoverEmbeddedRepoRoots, buildScopeIgnore } from '../src/extraction'; +import { extractFromSource, scanDirectory, scanDirectoryAsync, buildDefaultIgnore, discoverEmbeddedRepoRoots, buildScopeIgnore, type ScanSkipStats } from '../src/extraction'; import { detectLanguage, isLanguageSupported, getSupportedLanguages, initGrammars, loadAllGrammars, isSourceFile } from '../src/extraction/grammars'; import { stripCppTemplateArgs, blankCppExportMacros, blankCppInlineMacros, blankMetalAttributes, blankCudaConstructs, blankCppAnnotationMacroCalls, blankCppApiPrefixMacros, blankCppInlineAnnotationMacros, blankCLeadingAttrMacros, recoverMangledCppName } from '../src/extraction/languages/c-cpp'; import { normalizePath } from '../src/utils'; @@ -12721,3 +12722,63 @@ describe('C/C++ kernel-port preParse blanks (R7a)', () => { expect(result.nodes.some((n) => n.kind === 'method' && n.name === 'size')).toBe(true); }); }); + +// `init` on a project CodeGraph has no grammar for used to look identical to a +// successful index of an empty repo: 0 files, `index_state: complete`, exit 0. +// Nothing said "there are 24k files here and I understood none of them", so an +// agent told to trust the graph concluded the code did not exist (#1502). +// +// The scan already visits every file, so the count comes from the walk it +// already does — no second pass. +describe('Unsupported-language projects report what they skipped (#1502)', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + it('counts files it could not index, by extension, on the git path', async () => { + const runGit = (...args: string[]) => + execFileSync('git', args, { cwd: tempDir, stdio: 'pipe' }); + fs.mkdirSync(tempDir, { recursive: true }); + runGit('init', '-q'); + runGit('config', 'user.email', 'test@test.com'); + runGit('config', 'user.name', 'Test'); + fs.writeFileSync(path.join(tempDir, 'a.move'), 'module a {}'); + fs.writeFileSync(path.join(tempDir, 'b.move'), 'module b {}'); + fs.writeFileSync(path.join(tempDir, 'c.pl'), 'print 1;'); + runGit('add', '-A'); + runGit('commit', '-q', '-m', 'unsupported only'); + + const stats: ScanSkipStats = { unsupportedByExtension: new Map() }; + const files = await scanDirectoryAsync(tempDir, undefined, stats); + + expect(files).toEqual([]); + expect(stats.unsupportedByExtension.get('.move')).toBe(2); + expect(stats.unsupportedByExtension.get('.pl')).toBe(1); + }); + + it('counts them on the filesystem-walk path too (non-git project)', async () => { + fs.mkdirSync(tempDir, { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'a.move'), 'module a {}'); + fs.writeFileSync(path.join(tempDir, 'b.pl'), 'print 1;'); + + const stats: ScanSkipStats = { unsupportedByExtension: new Map() }; + const files = await scanDirectoryAsync(tempDir, undefined, stats); + + expect(files).toEqual([]); + expect(stats.unsupportedByExtension.get('.move')).toBe(1); + expect(stats.unsupportedByExtension.get('.pl')).toBe(1); + }); + + it('stays silent when every file was indexable', async () => { + fs.mkdirSync(tempDir, { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'a.ts'), 'export const a = 1;'); + + const stats: ScanSkipStats = { unsupportedByExtension: new Map() }; + const files = await scanDirectoryAsync(tempDir, undefined, stats); + + expect(files).toEqual(['a.ts']); + expect(stats.unsupportedByExtension.size).toBe(0); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index c59eca7..d52f35a 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -388,6 +388,8 @@ type IndexResult = { edgesCreated: number; errors: Array<{ message: string; filePath?: string; severity: string; code?: string }>; durationMs: number; + filesSkippedUnsupported?: number; + topUnsupportedExtensions?: { ext: string; count: number }[]; }; /** @@ -445,6 +447,20 @@ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexR } } else if (hasErrors) { clack.log.error(`Indexing failed ${getGlyphs().dash} all ${formatNumber(result.filesErrored)} files had errors`); + } else if (result.filesSkippedUnsupported) { + // A project CodeGraph has no grammar for used to be indistinguishable from + // an empty one: same message, same `complete` state, same exit 0. Say which + // files were there and that the graph is empty on purpose, so nobody — and + // no agent trusting the graph — reads silence as "this code doesn't exist" + // (#1502). + const top = (result.topUnsupportedExtensions ?? []) + .map(e => `${e.ext} (${formatNumber(e.count)})`) + .join(', '); + clack.log.warn( + `No supported source files found ${getGlyphs().dash} ${formatNumber(result.filesSkippedUnsupported)} file(s) present, none in a language CodeGraph indexes` + + (top ? `: ${top}` : '') + ); + clack.log.info('CodeGraph is inactive for this workspace — searches will return nothing. Use your own file tools here.'); } else { clack.log.warn('No files found to index'); } diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 4608a5f..8095ee5 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -99,6 +99,16 @@ export interface IndexResult { * counts. Only set by full-index runs (indexAll), not indexFiles/sync. */ filesDiscovered?: number; + /** + * Files the scan saw but has no grammar for, tallied by extension. Only the + * degenerate case needs it: a project of unsupported files otherwise looks + * exactly like an empty one (0 files, state `complete`), so nothing tells the + * user — or an agent — that there was code here CodeGraph could not read + * (#1502). Counted during the scan's existing walk. + */ + filesSkippedUnsupported?: number; + /** The most common unsupported extensions, biggest first. */ + topUnsupportedExtensions?: { ext: string; count: number }[]; nodesCreated: number; edgesCreated: number; errors: ExtractionError[]; @@ -1408,9 +1418,30 @@ export function scanDirectory( * Async variant of scanDirectory that yields to the event loop periodically, * allowing worker threads to receive and render progress messages. */ +/** + * What a scan saw but could not index, tallied by extension. + * + * Filled during the walk the scan already performs — a project of unsupported + * files is otherwise indistinguishable from an empty one, because unsupported + * extensions are filtered out at discovery and never counted anywhere (#1502). + */ +export interface ScanSkipStats { + /** Lowercased extension (with dot) → how many files carried it. */ + unsupportedByExtension: Map; +} + +/** Record one file the scan declined to index. */ +function tallySkip(stats: ScanSkipStats | undefined, rel: string): void { + if (!stats) return; + const ext = path.extname(rel).toLowerCase(); + if (!ext) return; + stats.unsupportedByExtension.set(ext, (stats.unsupportedByExtension.get(ext) ?? 0) + 1); +} + export async function scanDirectoryAsync( rootDir: string, - onProgress?: (current: number, file: string) => void + onProgress?: (current: number, file: string) => void, + stats?: ScanSkipStats ): Promise { // Custom extension → language overrides from the project's codegraph.json. const overrides = loadExtensionOverrides(rootDir); @@ -1428,12 +1459,14 @@ export async function scanDirectoryAsync( if (count % 100 === 0) { await new Promise(r => setImmediate(r)); } + } else { + tallySkip(stats, filePath); } } return files; } - return scanDirectoryWalk(rootDir, onProgress); + return scanDirectoryWalk(rootDir, onProgress, stats); } /** @@ -1441,7 +1474,8 @@ export async function scanDirectoryAsync( */ function scanDirectoryWalk( rootDir: string, - onProgress?: (current: number, file: string) => void + onProgress?: (current: number, file: string) => void, + stats?: ScanSkipStats ): string[] { const files: string[] = []; let count = 0; @@ -1524,10 +1558,14 @@ function scanDirectoryWalk( walk(fullPath, active); } } else if (stat.isFile()) { - if (!isIgnored(fullPath, false, active) && isSourceFile(relativePath, overrides)) { - files.push(relativePath); - count++; - onProgress?.(count, relativePath); + if (!isIgnored(fullPath, false, active)) { + if (isSourceFile(relativePath, overrides)) { + files.push(relativePath); + count++; + onProgress?.(count, relativePath); + } else { + tallySkip(stats, relativePath); + } } } } catch { @@ -1541,10 +1579,14 @@ function scanDirectoryWalk( walk(fullPath, active); } } else if (entry.isFile()) { - if (!isIgnored(fullPath, false, active) && isSourceFile(relativePath, overrides)) { - files.push(relativePath); - count++; - onProgress?.(count, relativePath); + if (!isIgnored(fullPath, false, active)) { + if (isSourceFile(relativePath, overrides)) { + files.push(relativePath); + count++; + onProgress?.(count, relativePath); + } else { + tallySkip(stats, relativePath); + } } } } @@ -1791,6 +1833,7 @@ export class ExtractionOrchestrator { // early-run 5-10s single stalls were observed on 95k-file repos but never // attributed — these labels settle scan vs framework-detect vs grammars. const tScan = Date.now(); + const skipStats: ScanSkipStats = { unsupportedByExtension: new Map() }; const files = await scanDirectoryAsync(this.rootDir, (current, file) => { onProgress?.({ phase: 'scanning', @@ -1798,8 +1841,20 @@ export class ExtractionOrchestrator { total: 0, currentFile: file, }); - }); + }, skipStats); if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] scan: ${Date.now() - tScan}ms (${files.length} files)`); + /** Only meaningful when nothing was indexable — see IndexResult (#1502). */ + const skipSummary = (): Pick => { + let total = 0; + for (const n of skipStats.unsupportedByExtension.values()) total += n; + if (total === 0) return {}; + const top = [...skipStats.unsupportedByExtension.entries()] + .map(([ext, count]) => ({ ext, count })) + .sort((a, b) => b.count - a.count || a.ext.localeCompare(b.ext)) + .slice(0, 5); + return { filesSkippedUnsupported: total, topUnsupportedExtensions: top }; + }; + // A re-index over an existing DB skips unchanged-hash files at the store, // which would preserve wiped zero-node rows (#1541) — drop them first so @@ -2191,6 +2246,7 @@ export class ExtractionOrchestrator { filesSkipped, filesErrored, filesDiscovered: total, + ...skipSummary(), nodesCreated: totalNodes, edgesCreated: totalEdges, errors: [{ message: 'Aborted', severity: 'error' }, ...errors], @@ -2347,6 +2403,7 @@ export class ExtractionOrchestrator { filesSkipped, filesErrored, filesDiscovered: total, + ...skipSummary(), nodesCreated: totalNodes, edgesCreated: totalEdges, errors,