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
+3 -3
View File
@@ -1131,10 +1131,10 @@ program
// Mirror the MCP search down-rank so the CLI also surfaces the
// hand-written implementation before protobuf/gRPC scaffolding
// when both share a name. See extraction/generated-detection.ts.
const { isGeneratedFile } = await import('../extraction/generated-detection');
const isGen = cg.generatedFilePredicate(rawResults.map((r) => r.node.filePath));
const results = [...rawResults].sort((a, b) => {
const aGen = isGeneratedFile(a.node.filePath) ? 1 : 0;
const bGen = isGeneratedFile(b.node.filePath) ? 1 : 0;
const aGen = isGen(a.node.filePath) ? 1 : 0;
const bGen = isGen(b.node.filePath) ? 1 : 0;
return aGen - bGen;
});
+14 -6
View File
@@ -15,7 +15,15 @@ import { isGeneratedFile } from '../extraction/generated-detection';
* - Entry points with locations
* - Code blocks only for key symbols
*/
export function formatContextAsMarkdown(context: TaskContext): string {
export function formatContextAsMarkdown(
context: TaskContext,
/**
* Generated-file test. Defaults to the filename convention alone; the
* ContextBuilder passes a DB-backed predicate so files flagged by their
* HEADER at index time (#1500) demote here too.
*/
isGenerated: (filePath: string) => boolean = isGeneratedFile
): string {
const lines: string[] = [];
// Header with query
@@ -26,8 +34,8 @@ export function formatContextAsMarkdown(context: TaskContext): string {
// .pulsar.go, mocks, …) rank LAST — a flow query should lead with the
// hand-written implementation, not protobuf scaffolding.
const orderedEntries = [...context.entryPoints].sort((a, b) => {
const aGen = isGeneratedFile(a.filePath) ? 1 : 0;
const bGen = isGeneratedFile(b.filePath) ? 1 : 0;
const aGen = isGenerated(a.filePath) ? 1 : 0;
const bGen = isGenerated(b.filePath) ? 1 : 0;
return aGen - bGen;
});
if (orderedEntries.length > 0) {
@@ -49,7 +57,7 @@ export function formatContextAsMarkdown(context: TaskContext): string {
// Related Symbols, pure noise that displaced real-flow entries).
const otherSymbols = Array.from(context.subgraph.nodes.values())
.filter(n => !context.entryPoints.some(e => e.id === n.id))
.filter(n => !isGeneratedFile(n.filePath))
.filter(n => !isGenerated(n.filePath))
.slice(0, 10); // Limit to 10 related symbols
if (otherSymbols.length > 0) {
@@ -72,8 +80,8 @@ export function formatContextAsMarkdown(context: TaskContext): string {
// show first (consistent with Entry Points reordering above).
if (context.codeBlocks.length > 0) {
const orderedBlocks = [...context.codeBlocks].sort((a, b) => {
const aGen = isGeneratedFile(a.filePath) ? 1 : 0;
const bGen = isGeneratedFile(b.filePath) ? 1 : 0;
const aGen = isGenerated(a.filePath) ? 1 : 0;
const bGen = isGenerated(b.filePath) ? 1 : 0;
return aGen - bGen;
});
lines.push('### Code\n');
+8 -1
View File
@@ -265,7 +265,14 @@ export class ContextBuilder {
// Return formatted output or raw context
if (opts.format === 'markdown') {
return formatContextAsMarkdown(context)
// Bounded candidate set (entry points + subgraph + code blocks), so the
// DB-backed generated check is one probe, not a per-comparison query.
const isGenerated = this.queries.generatedPredicateFor([
...entryPoints.map((n) => n.filePath),
...Array.from(subgraph.nodes.values(), (n) => n.filePath),
...codeBlocks.map((b) => b.filePath),
]);
return formatContextAsMarkdown(context, isGenerated)
+ this.buildCallPathsSection(subgraph)
+ (subgraph.confidence === 'low' ? this.buildLowConfidenceNote(entryPoints) : '');
} else if (opts.format === 'json') {
+28 -1
View File
@@ -9,7 +9,7 @@ import { SqliteDatabase } from './sqlite-adapter';
/**
* Current schema version
*/
export const CURRENT_SCHEMA_VERSION = 8;
export const CURRENT_SCHEMA_VERSION = 9;
/**
* Migration definition
@@ -150,6 +150,33 @@ const migrations: Migration[] = [
`);
},
},
{
version: 9,
description:
'Add files.generated — index-time content-header generated-file detection for ranking (#1500)',
up: (db) => {
// DDL only — instant on any size database, and NO backfill: the flag is
// derived from file CONTENT, which this migration has no access to (the
// files table stores a hash, not the bytes). Migrated rows therefore stay
// 0 until the next full index re-extracts them, and every reader unions
// the flag with the path-only check, so an un-backfilled database keeps
// exactly the pre-#1500 behavior instead of regressing. `sync` heals it
// file-by-file as files change. This is why the CHANGELOG entry says a
// re-index is required to pick up the new detection.
//
// ALTER TABLE has no IF NOT EXISTS, so guard for idempotency — a database
// created from current schema.sql already has the column (matters when
// migrations are re-run from an older recorded version, as the v6
// regression test does). Keep in lockstep with schema.sql.
const cols = db.prepare('PRAGMA table_info(files)').all() as Array<{ name: string }>;
if (!cols.some((c) => c.name === 'generated')) {
db.exec('ALTER TABLE files ADD COLUMN generated INTEGER NOT NULL DEFAULT 0');
}
db.exec(
'CREATE INDEX IF NOT EXISTS idx_files_generated ON files(path) WHERE generated = 1'
);
},
},
];
/**
+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
*/
+16 -3
View File
@@ -55,7 +55,15 @@ CREATE TABLE IF NOT EXISTS edges (
FOREIGN KEY (target) REFERENCES nodes(id) ON DELETE CASCADE
);
-- Files: Tracked source files
-- Files: Tracked source files.
-- `generated` is the index-time verdict from extraction/generated-detection.ts:
-- the filename convention (*.pb.go, *.g.dart, …) OR a generation banner in the
-- file's header. Go's convention is a CONTENT marker, so a generated
-- `payroll.go` beside hand-written use-cases is invisible to the path check
-- alone (#1500) — deciding it here means ranking never reads file headers per
-- request. Migration v9 adds the column to existing databases; rows keep the
-- 0 default until the next full index, so readers treat it as a hint that
-- only ever ADDS to the path signal, never overrides it.
CREATE TABLE IF NOT EXISTS files (
path TEXT PRIMARY KEY,
content_hash TEXT NOT NULL,
@@ -64,7 +72,8 @@ CREATE TABLE IF NOT EXISTS files (
modified_at INTEGER NOT NULL,
indexed_at INTEGER NOT NULL,
node_count INTEGER DEFAULT 0,
errors TEXT -- JSON array
errors TEXT, -- JSON array
generated INTEGER NOT NULL DEFAULT 0
);
-- Unresolved References: References that need resolution after full indexing.
@@ -173,9 +182,13 @@ CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target, kind);
CREATE UNIQUE INDEX IF NOT EXISTS idx_edges_identity
ON edges(source, target, kind, IFNULL(line, -1), IFNULL(col, -1));
-- File indexes
-- File indexes.
-- idx_files_generated is PARTIAL: the generated set is a small minority of any
-- repo, so a lookup that intersects a bounded candidate list with it stays
-- proportional to the generated files, not to the repo.
CREATE INDEX IF NOT EXISTS idx_files_language ON files(language);
CREATE INDEX IF NOT EXISTS idx_files_modified_at ON files(modified_at);
CREATE INDEX IF NOT EXISTS idx_files_generated ON files(path) WHERE generated = 1;
-- Unresolved refs indexes
CREATE INDEX IF NOT EXISTS idx_unresolved_from_node ON unresolved_refs(from_node_id);
+175 -11
View File
@@ -8,18 +8,39 @@
* see project_go_multi_module_audit memory). Generated stubs frequently
* have no body to trace from, so the agent ends up reading source anyway.
*
* This helper is a pure path-based classifier consulted at disambiguation
* time (findSymbol / findAllSymbols / codegraph_search formatting), NOT
* a hard filter — generated nodes are still in the graph and remain
* reachable; they just rank LAST when there's a real implementation
* with the same name.
* This is a relevance hint consulted at disambiguation time (findSymbol /
* findAllSymbols / explore ranking / codegraph_search formatting), NOT a
* hard filter — generated nodes are still in the graph and remain
* reachable; they just rank LAST when there's a real implementation with
* the same name.
*
* Scope: suffix patterns only. Most generated files follow the
* `<basename>.<tool>.<ext>` convention (`.pb.go`, `_grpc.pb.go`,
* `.g.dart`, `_pb2.py`), and that covers ~all of what we saw in the
* Go audit. A future addition would be scanning for the canonical
* `// Code generated by` header during extraction, for the rare files
* that defy the suffix convention.
* Two signals, deliberately separate:
*
* 1. {@link isGeneratedFile} — PATH only, pure and synchronous. Most
* generated files follow the `<basename>.<tool>.<ext>` convention
* (`.pb.go`, `_grpc.pb.go`, `.g.dart`, `_pb2.py`). Free to call
* anywhere, including in a sort comparator.
*
* 2. {@link hasGeneratedHeader} — CONTENT banner in the file's head. Go's
* own convention is a content marker, not a filename one, so a
* generated `payroll.go` sitting beside hand-written use-cases is
* invisible to (1) — that is issue #1500. Evaluated ONCE at index time
* (the file's content is already in memory for parsing) and persisted
* on the file record as `files.generated`; readers get it from the DB
* rather than re-reading headers per request. See
* GENERATED_CONTENT_PATTERNS below for the banners recognized.
*
* Consumers that have a bounded candidate list should use the DB-backed
* union (`QueryBuilder.getGeneratedPathsAmong` /
* `CodeGraph.getGeneratedFilePaths`) so both signals apply; the path-only
* check remains the fallback for callers with no database in hand and for
* indexes built before the flag existed.
*
* NOTE for future editors: the banner literals quoted in this file sit
* BELOW the header window this detector scans, so the module does not
* classify itself. `generated-detection.test.ts` pins that — if you move
* the pattern table upward, the test fails rather than the repo silently
* demoting its own file.
*/
const GENERATED_PATTERNS: ReadonlyArray<RegExp> = [
@@ -79,3 +100,146 @@ const GENERATED_PATTERNS: ReadonlyArray<RegExp> = [
export function isGeneratedFile(filePath: string): boolean {
return GENERATED_PATTERNS.some((p) => p.test(filePath));
}
// =============================================================================
// Content-header detection (#1500)
// =============================================================================
/**
* How much of a file's head to consider "the header". Generous enough for a
* build-tag block + an Apache-2.0 license preamble (~15 lines) sitting above
* the banner, tight enough that a `"// Code generated ... DO NOT EDIT."`
* string constant in the *body* of a code generator's own source can't
* masquerade as a banner.
*/
const HEADER_SCAN_CHARS = 8192;
const HEADER_SCAN_LINES = 60;
/**
* Cheap pre-filter run on the header of EVERY indexed file. Every marker
* below contains the stem "generat", so one unanchored scan rejects ~all
* hand-written source before any line splitting happens — this is what keeps
* content detection off the index-time cost budget.
*/
const GENERATED_STEM = /generat/i;
/**
* Line-comment leaders across the languages we index. A banner must sit on a
* comment line (or inside an open block comment, tracked below): generators
* always emit theirs as a comment, and requiring it rules out string literals
* and identifiers that merely contain the words.
*
* `--` covers SQL/Haskell/Lua, `%` LaTeX/Erlang/Prolog, `;` Lisp/asm/ini,
* `'` VB, `!` Fortran, `*` a continuation line inside a `/* … *\/` block.
*/
const COMMENT_LEADER =
/^\s*(?:\/\/|\/\*+|\*+\/?|#+|--+|<!--|%+|;+|'|!|\(\*|\{-|"""|'''|=begin|<#|@rem\b|rem\b)/i;
/**
* Openers/closers for block comments, so a banner on an unprefixed line
* inside `/* … *\/` (or `<!-- … -->`, or a Python module docstring) still
* counts. Deliberately naive — it only runs over a file's first few dozen
* lines, where a `/*` inside a string literal is vanishingly rare, and the
* worst case of a mis-tracked state is a ranking hint, not a wrong answer.
*/
const BLOCK_DELIMS: ReadonlyArray<{ open: string; close: string }> = [
{ open: '/*', close: '*/' },
{ open: '<!--', close: '-->' },
{ open: '"""', close: '"""' },
{ open: "'''", close: "'''" },
{ open: '=begin', close: '=end' },
{ open: '<#', close: '#>' },
];
/**
* The banners themselves. Each is a real convention emitted by a widely-used
* generator; the list is precision-first, because a false positive silently
* demotes hand-written code in every ranking path.
*/
const GENERATED_CONTENT_PATTERNS: ReadonlyArray<RegExp> = [
// Go's codified convention — `^// Code generated .* DO NOT EDIT\.$`, defined
// by `go generate` and honored by gofmt, golangci-lint and GitHub linguist.
// Emitted verbatim by protoc-gen-go, mockgen, sqlc, ent, wire, stringer, and
// by in-house generators like the FKIT CRUD in #1500 — where the file is
// named `payroll.go` and nothing in the PATH gives it away.
/\bcode generated\b.{0,200}?\bdo not edit\b/i,
// protoc's Java/C#/Python banner ("Generated by the protocol buffer
// compiler. DO NOT EDIT!"), ANTLR, Dagger, FlatBuffers, rust-bindgen,
// Xcode asset catalogs, Bazel rules.
/\b(?:automatically |auto[- ]?)?generated (?:by|from|with)\b.{0,200}?\bdo not (?:edit|modify|change)\b/i,
// The `@generated` marker: the JS/TS ecosystem's convention (Relay, GraphQL
// codegen, protobuf-es/Buf, Meta's `@generated SignedSource<<…>>`), also
// what linguist and `git diff` collapse on. Guarded against `foo@generated`
// and `@@generated` so only a standalone tag matches.
/(?:^|[^\p{L}\p{N}_@])@generated\b/u,
// .NET's `<auto-generated>` / `<auto-generated />` doc tag: Roslyn, the
// WinForms designer, T4 templates, protoc-gen-csharp, EF scaffolding.
/<auto-?generated\s*\/?>/i,
// swagger-codegen / OpenAPI Generator ("NOTE: This class is auto generated
// by OpenAPI Generator"), Thrift ("Autogenerated by Thrift Compiler"),
// FlatBuffers ("automatically generated by the FlatBuffers compiler").
// "by" is required — bare "automatically generated" appears in hand-written
// prose ("the table below is automatically generated at runtime").
/\b(?:automatically generated|auto[- ]?generated|autogenerated) by\b/i,
// Self-declaring in-house banners that name no tool.
/\bthis (?:file|class|code|module) (?:is|was) (?:auto[- ]?)?generated\b/i,
// The reverse ordering: "DO NOT EDIT — this is a generated file".
/\bdo not (?:edit|modify)\b.{0,120}?\b(?:auto[- ]?generated|generated file|generated code)\b/i,
];
/**
* Whether the head of `content` carries a recognized machine-generation
* banner. Bounded to {@link HEADER_SCAN_CHARS} / {@link HEADER_SCAN_LINES},
* and the marker must sit on a comment line — a generator's own source, which
* holds the banner as a string constant in its body, is not flagged.
*
* Called once per file during extraction (content is already in memory), NOT
* per query: the verdict is persisted on the file record.
*/
export function hasGeneratedHeader(content: string): boolean {
if (!content) return false;
const head = content.length > HEADER_SCAN_CHARS ? content.slice(0, HEADER_SCAN_CHARS) : content;
// Fast reject for ~every hand-written file: no line splitting, no allocation
// (V8 keeps `head` as a sliced view of `content`).
if (!GENERATED_STEM.test(head)) return false;
const lines = head.split('\n');
const limit = Math.min(lines.length, HEADER_SCAN_LINES);
let openBlock: (typeof BLOCK_DELIMS)[number] | null = null;
for (let i = 0; i < limit; i++) {
const line = lines[i]!;
const inBlock = openBlock !== null;
if (inBlock || COMMENT_LEADER.test(line)) {
for (const pattern of GENERATED_CONTENT_PATTERNS) {
if (pattern.test(line)) return true;
}
}
// Advance the block-comment state AFTER testing, so the opening line of a
// `/* Code generated … */` block is itself matched by the leader rule.
if (openBlock) {
if (line.includes(openBlock.close)) openBlock = null;
continue;
}
for (const delim of BLOCK_DELIMS) {
const at = line.indexOf(delim.open);
if (at < 0) continue;
// Same-line close (`/* … */`, a one-line docstring) leaves no open block.
if (line.indexOf(delim.close, at + delim.open.length) < 0) openBlock = delim;
break;
}
}
return false;
}
/**
* The union signal: path convention OR content banner. This is what the
* indexer persists to `files.generated`.
*/
export function detectGeneratedFile(filePath: string, content: string): boolean {
return isGeneratedFile(filePath) || hasGeneratedHeader(content);
}
+12
View File
@@ -25,6 +25,7 @@ import { extractFromSource } from './tree-sitter';
import { ParseWorkerPool, resolveParsePoolSize, resolveParseTimeoutMs } from './parse-pool';
import { StoreWriter, StoreBundle, finalizeStoreBundle } from './store-writer';
import { materializeKernelResult } from './kernel';
import { detectGeneratedFile } from './generated-detection';
import { detectLanguage, isSourceFile, isLanguageSupported, isFileLevelOnlyLanguage, initGrammars, loadGrammarsForLanguages, readGrammarWasmBytes } from './grammars';
import { loadExtensionOverrides, loadIncludeIgnoredPatterns, loadExcludePatterns, loadIncludePatterns } from '../project-config';
import { isCodeGraphDataDir } from '../directory';
@@ -2275,6 +2276,11 @@ export class ExtractionOrchestrator {
return; // No changes
}
// Re-decided on every re-index of a changed file, so a banner added (or
// removed) by an edit is reflected on the next sync (#1500). Computed after
// the unchanged-file early return so untouched files pay nothing.
const generated = detectGeneratedFile(filePath, content);
// Snapshot incoming cross-file edges BEFORE deleting this file's nodes.
// `deleteFile` cascades to delete every edge whose source OR target is a
// node in this file (edges.FK ... ON DELETE CASCADE). Edges whose SOURCE is
@@ -2340,6 +2346,7 @@ export class ExtractionOrchestrator {
indexedAt: Date.now(),
nodeCount: result.nodes.length,
errors: result.errors.length > 0 ? result.errors : undefined,
generated,
},
});
if (crossFileIncomingEdges.length > 0) {
@@ -2400,6 +2407,7 @@ export class ExtractionOrchestrator {
indexedAt: Date.now(),
nodeCount: result.nodes.length,
errors: result.errors.length > 0 ? result.errors : undefined,
generated,
};
this.queries.upsertFile(fileRecord);
}
@@ -2427,6 +2435,10 @@ export class ExtractionOrchestrator {
indexedAt: Date.now(),
nodeCount,
errors: resultErrors.length > 0 ? resultErrors : undefined,
// Decided here, once, while the content is already in memory — never at
// query time (#1500). The header scan short-circuits on a single
// substring test for ~every hand-written file.
generated: detectGeneratedFile(filePath, content),
};
}
+18
View File
@@ -1537,6 +1537,24 @@ export class CodeGraph {
return this.queries.getAllFiles();
}
/**
* A `(path) => boolean` generated-file test over a BOUNDED candidate list,
* unioning the index-time content-banner flag with the filename convention
* (#1500). One query up front, O(1) per call after — built for use inside a
* ranking comparator, where re-querying per comparison would be quadratic.
*
* Pass every path you might ask about; a path outside the list falls back to
* the filename check alone.
*/
generatedFilePredicate(filePaths: Iterable<string>): (filePath: string) => boolean {
return this.queries.generatedPredicateFor(filePaths);
}
/** How many indexed files are flagged tool-generated. Reported by `status`. */
getGeneratedFileCount(): number {
return this.queries.countGeneratedFiles();
}
// ===========================================================================
// Graph Query Methods
// ===========================================================================
+23 -12
View File
@@ -39,7 +39,6 @@ import {
} from 'fs';
import { createHash } from 'crypto';
import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, CONFIG_LEAF_LANGUAGES } from '../utils';
import { isGeneratedFile } from '../extraction/generated-detection';
import { scanDynamicDispatch } from './dynamic-boundaries';
import { getUpdateNotice } from '../upgrade/update-check';
import { ExploreDiagnostics } from './explore-diagnostics';
@@ -1588,9 +1587,10 @@ export class ToolHandler {
// Down-rank generated files within the FTS-returned set so a search
// for "Send" surfaces the hand-written keeper before .pb.go stubs
// that share the name. Stable: only reorders generated vs. not.
const isGen = cg.generatedFilePredicate(results.map((r) => r.node.filePath));
const ranked = [...results].sort((a, b) => {
const aGen = isGeneratedFile(a.node.filePath) ? 1 : 0;
const bGen = isGeneratedFile(b.node.filePath) ? 1 : 0;
const aGen = isGen(a.node.filePath) ? 1 : 0;
const bGen = isGen(b.node.filePath) ? 1 : 0;
return aGen - bGen;
});
@@ -3113,6 +3113,13 @@ export class ToolHandler {
!MULTITERM_OFF &&
(fileTermHits.get(fp) ?? 0) >= 2 &&
(entryFiles.has(fp) || centralFiles.has(fp));
// One DB probe over the ranked candidates, then O(1) per comparison. Unions
// the index-time content-banner flag with the filename convention, so a Go
// monorepo's generated CRUD (`payroll.go` carrying a DO-NOT-EDIT banner and
// nothing in its name) down-ranks the same way `.pb.go` always has (#1500).
const isGeneratedCandidate = cg.generatedFilePredicate(relevantFiles.map(([fp]) => fp));
const sortedFiles = relevantFiles.sort((a, b) => {
const aPath = a[0].toLowerCase();
const bPath = b[0].toLowerCase();
@@ -3147,8 +3154,8 @@ export class ToolHandler {
// the response (the cosmos Q3 explore otherwise leads with
// `expected_keepers_mocks.go`, displacing the real `tally.go` content
// and forcing the agent to Read tally.go anyway).
const aGen = isGeneratedFile(a[0]);
const bGen = isGeneratedFile(b[0]);
const aGen = isGeneratedCandidate(a[0]);
const bGen = isGeneratedCandidate(b[0]);
if (aGen !== bGen) return aGen ? 1 : -1;
if (a[1].score !== b[1].score) return b[1].score - a[1].score;
@@ -3233,7 +3240,7 @@ export class ToolHandler {
entry: entryFiles.has(fp),
spine: group.nodes.some((n) => flow.pathNodeIds.has(n.id)),
lowValue: isLowValue(fp),
generated: isGeneratedFile(fp),
generated: isGeneratedCandidate(fp),
});
});
}
@@ -4753,7 +4760,8 @@ export class ToolHandler {
if (!isQualified) {
const exact = cg.getNodesByName(symbol);
if (exact.length > 0) {
return [...exact].sort((a, b) => (isGeneratedFile(a.filePath) ? 1 : 0) - (isGeneratedFile(b.filePath) ? 1 : 0));
const isGen = cg.generatedFilePredicate(exact.map((n) => n.filePath));
return [...exact].sort((a, b) => (isGen(a.filePath) ? 1 : 0) - (isGen(b.filePath) ? 1 : 0));
}
// No exact match — use the single top fuzzy result (e.g. a file basename).
const fuzzy = cg.searchNodes(symbol, { limit: 10 });
@@ -4781,10 +4789,12 @@ export class ToolHandler {
return isQualified ? [] : results[0] ? [results[0].node] : [];
}
// Down-rank generated files (.pb.go, .pulsar.go, _grpc.pb.go, …) so a flow
// query prefers the keeper implementation over the protobuf-generated stub.
// Down-rank generated files (.pb.go, .pulsar.go, _grpc.pb.go, and anything
// whose header declares it generated) so a flow query prefers the keeper
// implementation over the generated stub.
const isGen = cg.generatedFilePredicate(exactMatches.map((r) => r.node.filePath));
return [...exactMatches]
.sort((a, b) => (isGeneratedFile(a.node.filePath) ? 1 : 0) - (isGeneratedFile(b.node.filePath) ? 1 : 0))
.sort((a, b) => (isGen(a.node.filePath) ? 1 : 0) - (isGen(b.node.filePath) ? 1 : 0))
.map((r) => r.node);
}
@@ -4837,9 +4847,10 @@ export class ToolHandler {
// Same generated-file down-rank as findSymbol — keeps callers/callees
// /impact aggregation aligned (a query against "Send" returns the
// hand-written implementations before the protobuf scaffold).
const isGen = cg.generatedFilePredicate(exactMatches.map((r) => r.node.filePath));
const ranked = [...exactMatches].sort((a, b) => {
const aGen = isGeneratedFile(a.node.filePath) ? 1 : 0;
const bGen = isGeneratedFile(b.node.filePath) ? 1 : 0;
const aGen = isGen(a.node.filePath) ? 1 : 0;
const bGen = isGen(b.node.filePath) ? 1 : 0;
return aGen - bGen;
});
+9
View File
@@ -252,6 +252,15 @@ export interface FileRecord {
/** Any extraction errors */
errors?: ExtractionError[];
/**
* Tool-generated source, decided at index time from the filename
* convention OR a generation banner in the file's header (see
* extraction/generated-detection.ts). A relevance hint for ranking, not a
* hard filter. Absent on indexes built before schema v9 — treat
* `undefined` as "content signal unknown, fall back to the path check".
*/
generated?: boolean;
}
// =============================================================================