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:
co-authored by
Claude Opus 5
parent
b37f191f5a
commit
16e17495f4
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user