feat(extraction): content-based generated-file detection (CG-5, #1500)

`isGeneratedFile` was path-only, but Go's own convention is a CONTENT
marker (`// Code generated by <tool>. DO NOT EDIT.`), not a filename one.
A Go monorepo with generated CRUD in ordinarily-named files sitting beside
hand-written use-cases was therefore invisible to every generated-file
down-rank in the codebase — that is #1500.

Measured on kubernetes/client-go (2,453 Go files): the canonical banner
appears in 2,001 of them, the path check flags 0, the new content check
flags exactly those 2,001 — no false positives, no misses.

Design: decide at INDEX time (content is already in memory for parsing),
persist on `files.generated`, read from the DB. Explore never reads file
headers per request.

- `hasGeneratedHeader(content)` recognizes the standard banners — Go's,
  protoc's, `@generated`, `<auto-generated>`, Thrift, OpenAPI Generator,
  FlatBuffers, bindgen, ANTLR. Precision-first and fenced three ways: an
  8KB/60-line header window, a comment-line requirement (leader or open
  block comment), and markers tight enough that prose can't trip them. A
  generator's own source, holding the banner as a string constant in its
  body, is not flagged; neither is this module itself (pinned by test).
- `isGeneratedFile(path)` is unchanged — cheap, sync, still the fallback.
- Schema v9 adds `files.generated` + a PARTIAL index. DDL only, no
  backfill: the flag derives from content the migration cannot see, so
  rows stay 0 until a re-index and every reader unions the flag with the
  path check — an un-migrated index keeps pre-#1500 behavior rather than
  regressing. Re-index required; noted in the CHANGELOG.
- `generatedPredicateFor(paths)` gives ranking a bounded probe + O(1)
  lookups. Bounded, not cached: no invalidation, so a ranking call can
  never serve a verdict the last sync already replaced. Wired into explore
  ranking, findSymbolMatches, findAllSymbols, search (MCP + CLI), the
  context formatter, and the dominant-file/route-file hygiene filters.

Cost (acceptance bar was no measurable index-time regression): a single
unanchored `/generat/i` test over the header rejects ~every hand-written
file before any line splitting. 4.6 µs/file on client-go (worst case —
82% generated). End-to-end `codegraph init` on client-go, n=3 alternating
arms: 5.73s median with detection vs 5.76s path-only baseline; the arms
cross over between runs, so the difference is inside run-to-run noise.

Scope note: generated status remains a stable TIEBREAK at equal score,
exactly where it was. Making it a strong negative signal is CG-10, which
this unblocks by making the signal correct and available.

Two pre-existing tests hard-coded schema version 8; both now track
CURRENT_SCHEMA_VERSION (or the migration table) so future migrations
don't require editing them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-08-03 23:13:59 -05:00
co-authored by Claude Opus 5
parent b37f191f5a
commit 16e17495f4
19 changed files with 898 additions and 55 deletions
+74 -12
View File
@@ -24,13 +24,18 @@ import { isGeneratedFile } from '../extraction/generated-detection';
import { splitIdentifierSegments } from '../search/identifier-segments';
/**
* Path-only heuristic for files that should not be candidates for
* "dominant file" detection: test/spec files and tool-generated files.
* Generated files (`*.pb.go`, `*.pulsar.go`, mock outputs, …) often
* have huge in-file edge counts that dwarf the real source — etcd's
* `rpc.pb.go` has 4× the in-file edges of `server.go`.
* Files that should not be candidates for "dominant file" detection: test/spec
* files and tool-generated files. Generated files (`*.pb.go`, `*.pulsar.go`,
* mock outputs, …) often have huge in-file edge counts that dwarf the real
* source — etcd's `rpc.pb.go` has 4× the in-file edges of `server.go`.
*
* Path patterns plus, when the caller passes the indexed set, files whose
* HEADER declares them generated — a `payroll.go` full of generated CRUD has
* exactly the same edge-density problem as `rpc.pb.go` and nothing in its name
* to catch it (#1500).
*/
function isLowValueFile(filePath: string): boolean {
function isLowValueFile(filePath: string, generated?: ReadonlySet<string>): boolean {
if (generated?.has(filePath)) return true;
const lp = filePath.toLowerCase();
return (
/(?:^|\/)(tests?|__tests?__|spec)\//.test(lp) ||
@@ -97,6 +102,8 @@ interface FileRow {
indexed_at: number;
node_count: number;
errors: string | null;
/** Absent on pre-v9 rows read through a stale prepared statement. */
generated?: number | null;
}
interface UnresolvedRefRow {
@@ -182,6 +189,7 @@ function rowToFileRecord(row: FileRow): FileRecord {
indexedAt: row.indexed_at,
nodeCount: row.node_count,
errors: row.errors ? safeJsonParse(row.errors, undefined) : undefined,
generated: row.generated === 1,
};
}
@@ -922,7 +930,8 @@ export class QueryBuilder {
`);
}
const rows = this.stmts.getDominantFile.all() as Array<{ file_path: string; edge_count: number }>;
const filtered = rows.filter(r => !isLowValueFile(r.file_path));
const generated = this.getGeneratedPathsAmong(rows.map(r => r.file_path));
const filtered = rows.filter(r => !isLowValueFile(r.file_path, generated));
if (filtered.length === 0 || filtered[0]!.edge_count < 20) return null;
return {
filePath: filtered[0]!.file_path,
@@ -955,7 +964,8 @@ export class QueryBuilder {
`);
}
const rows = this.stmts.getTopRouteFile.all() as Array<{ file_path: string; cnt: number }>;
const filtered = rows.filter(r => !isLowValueFile(r.file_path));
const generated = this.getGeneratedPathsAmong(rows.map(r => r.file_path));
const filtered = rows.filter(r => !isLowValueFile(r.file_path, generated));
if (filtered.length === 0) return null;
const totalRoutes = filtered.reduce((sum, r) => sum + r.cnt, 0);
const top = filtered[0]!;
@@ -1006,7 +1016,8 @@ export class QueryBuilder {
url: string; handler: string; handler_file: string; handler_line: number; handler_kind: string;
}>;
// Drop test/generated handlers — same hygiene as elsewhere.
const filtered = rows.filter(r => !isLowValueFile(r.handler_file));
const generated = this.getGeneratedPathsAmong(rows.map(r => r.handler_file));
const filtered = rows.filter(r => !isLowValueFile(r.handler_file, generated));
if (filtered.length < 3) return null;
// Identify the file holding the most handlers (the "primary handler file").
const fileCounts = new Map<string, number>();
@@ -1865,8 +1876,8 @@ export class QueryBuilder {
upsertFile(file: FileRecord): void {
if (!this.stmts.upsertFile) {
this.stmts.upsertFile = this.db.prepare(`
INSERT INTO files (path, content_hash, language, size, modified_at, indexed_at, node_count, errors)
VALUES (@path, @contentHash, @language, @size, @modifiedAt, @indexedAt, @nodeCount, @errors)
INSERT INTO files (path, content_hash, language, size, modified_at, indexed_at, node_count, errors, generated)
VALUES (@path, @contentHash, @language, @size, @modifiedAt, @indexedAt, @nodeCount, @errors, @generated)
ON CONFLICT(path) DO UPDATE SET
content_hash = @contentHash,
language = @language,
@@ -1874,7 +1885,8 @@ export class QueryBuilder {
modified_at = @modifiedAt,
indexed_at = @indexedAt,
node_count = @nodeCount,
errors = @errors
errors = @errors,
generated = @generated
`);
}
@@ -1887,9 +1899,59 @@ export class QueryBuilder {
indexedAt: file.indexedAt,
nodeCount: file.nodeCount,
errors: file.errors ? JSON.stringify(file.errors) : null,
// The upsert always REWRITES the flag: a file that loses its banner in an
// edit must lose the flag on the next sync, not keep a stale 1.
generated: file.generated ? 1 : 0,
});
}
/**
* Which of `filePaths` the index flagged as tool-generated (schema v9+).
*
* Bounded-lookup by design: every consumer already holds a short candidate
* list (a ranked file group, an FTS result page, a LIMIT-20 aggregate), so
* this stays a partial-index probe over a handful of paths — no whole-repo
* set to materialize, and no cache to invalidate, which means a ranking call
* can never serve a verdict the last sync already replaced.
*
* Returns ONLY the content/index signal; callers union it with
* {@link isGeneratedFile} so pre-v9 databases (column present, all zeros
* until a re-index) keep the path-only behavior rather than regressing.
*/
getGeneratedPathsAmong(filePaths: Iterable<string>): Set<string> {
const unique = [...new Set(filePaths)];
const found = new Set<string>();
if (unique.length === 0) return found;
for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) {
const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
const placeholders = chunk.map(() => '?').join(',');
const rows = this.db
.prepare(`SELECT path FROM files WHERE generated = 1 AND path IN (${placeholders})`)
.all(...chunk) as Array<{ path: string }>;
for (const row of rows) found.add(row.path);
}
return found;
}
/**
* A reusable `(path) => boolean` over a bounded candidate list, unioning the
* indexed flag with the path convention. This is the shape every ranking
* comparator wants: one query up front, then O(1) per comparison.
*/
generatedPredicateFor(filePaths: Iterable<string>): (filePath: string) => boolean {
const flagged = this.getGeneratedPathsAmong(filePaths);
return (filePath: string) => flagged.has(filePath) || isGeneratedFile(filePath);
}
/** How many indexed files carry the generated flag. Surfaced by `status`. */
countGeneratedFiles(): number {
const row = this.db
.prepare('SELECT COUNT(*) AS n FROM files WHERE generated = 1')
.get() as { n: number } | undefined;
return row?.n ?? 0;
}
/**
* Delete a file record and its nodes
*/