فهرست منبع

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>
Colby McHenry 1 ماه پیش
والد
کامیت
16e17495f4

+ 2 - 0
CHANGELOG.md

@@ -11,6 +11,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
+- Generated code is now recognized by what's written at the top of the file, not just by its filename. Go's own convention — a `// Code generated by … DO NOT EDIT.` line — is a comment, not a naming rule, so a generated `payroll.go` sitting beside hand-written code was previously indistinguishable from it and could outrank the real implementation in `codegraph_explore` and search results. CodeGraph now reads the header while indexing and recognizes the standard banners across languages (Go's, protoc's, `@generated` in JavaScript/TypeScript, `<auto-generated>` in C#, Thrift, OpenAPI Generator, FlatBuffers, ANTLR, bindgen and more), so those files rank behind hand-written source everywhere the existing filename rules already applied. On a Kubernetes `client-go` checkout this identifies 2,001 generated files that filename rules alone caught none of. Re-index after upgrading to pick up the new detection. (#1500)
+
 - Anonymous usage telemetry is now stored entirely on CodeGraph's own first-party infrastructure — no third-party analytics vendor receives any of it, and the endpoint that receives it makes no outbound requests at all. Individual events are deleted after 90 days, leaving only anonymous daily totals. Nothing about what is collected changed, your IP address is still never read or stored, and every off-switch works exactly as before (`codegraph telemetry off`, `CODEGRAPH_TELEMETRY=0`, `DO_NOT_TRACK=1`). `TELEMETRY.md` remains the complete field-by-field list.
 
 ### Fixes

+ 6 - 2
__tests__/db-perf.test.ts

@@ -16,7 +16,7 @@ import * as path from 'path';
 import * as os from 'os';
 import { DatabaseConnection } from '../src/db';
 import { QueryBuilder } from '../src/db/queries';
-import { runMigrations, getCurrentVersion } from '../src/db/migrations';
+import { runMigrations, getCurrentVersion, CURRENT_SCHEMA_VERSION } from '../src/db/migrations';
 import { Node, Edge } from '../src/types';
 
 function makeNode(id: string, name = id): Node {
@@ -344,7 +344,11 @@ describe('migration v6: dedup edges + add identity index on upgrade (#1034)', ()
     runMigrations(raw, 5);
 
     expect(count()).toBe(2); // duplicate collapsed, the distinct `calls` edge kept
-    expect(getCurrentVersion(raw)).toBe(8);
+    // Migrations ran to completion. Tracked against the constant, not a
+    // literal, so adding a migration doesn't require editing this assertion —
+    // and so replaying every migration over a current-schema database (which
+    // is what this test does) stays covered as new ones land.
+    expect(getCurrentVersion(raw)).toBe(CURRENT_SCHEMA_VERSION);
     const idx = raw
       .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_edges_identity'")
       .get();

+ 4 - 1
__tests__/foundation.test.ts

@@ -12,6 +12,7 @@ import { CodeGraph } from '../src';
 import { Node, Edge } from '../src/types';
 import { isInitialized, getCodeGraphDir, validateDirectory, codeGraphDirName, isCodeGraphDataDir } from '../src/directory';
 import { DatabaseConnection, getDatabasePath, removeDatabaseFiles } from '../src/db';
+import { CURRENT_SCHEMA_VERSION } from '../src/db/migrations';
 
 // Create a temporary directory for each test
 function createTempDir(): string {
@@ -370,7 +371,9 @@ describe('Database Connection', () => {
 
     const version = db.getSchemaVersion();
     expect(version).not.toBeNull();
-    expect(version?.version).toBe(8);
+    // A freshly initialized database records the current version outright
+    // (schema.sql already contains every migration's end state).
+    expect(version?.version).toBe(CURRENT_SCHEMA_VERSION);
 
     db.close();
   });

+ 160 - 1
__tests__/generated-detection.test.ts

@@ -4,10 +4,23 @@
  * list is a contract: if a future edit drops `.pb.go`, the cosmos-sdk
  * trace endpoint regresses to the gRPC stub (see
  * `project_go_multi_module_audit` memory + the audit in #N/A).
+ *
+ * The content-header half (#1500) is a second contract: the marker table is
+ * precision-first, because a false positive silently demotes hand-written code
+ * in EVERY ranking path. Measured on a shallow clone of kubernetes/client-go
+ * (2,453 Go files): the path check flags 0, the content check flags 2,001 —
+ * exactly the set that greps to the canonical banner, no false positives and
+ * no misses. Every one of those files has an ordinary name.
  */
 
 import { describe, it, expect } from 'vitest';
-import { isGeneratedFile } from '../src/extraction/generated-detection';
+import * as fs from 'fs';
+import * as path from 'path';
+import {
+  isGeneratedFile,
+  hasGeneratedHeader,
+  detectGeneratedFile,
+} from '../src/extraction/generated-detection';
 
 describe('isGeneratedFile', () => {
   it('classifies Go protobuf / gRPC / pulsar / mock outputs as generated', () => {
@@ -45,3 +58,149 @@ describe('isGeneratedFile', () => {
     expect(isGeneratedFile('app/db.py')).toBe(false);
   });
 });
+
+describe('hasGeneratedHeader — per-marker coverage (#1500)', () => {
+  // One case per banner the marker table claims to recognize. Each string is
+  // the real thing a generator emits, not a paraphrase — if a regex is
+  // narrowed, the case that motivated it fails by name.
+  const GENERATED: ReadonlyArray<[string, string]> = [
+    [
+      'Go — the #1500 case: ordinary filename, banner below the package clause',
+      'package payroll\n\n// Code generated by fkit. DO NOT EDIT.\n\nimport "context"\n\nfunc CreatePayroll(ctx context.Context) error { return nil }\n',
+    ],
+    [
+      'Go — protoc-gen-go',
+      '// Code generated by protoc-gen-go. DO NOT EDIT.\n// versions:\n//   protoc-gen-go v1.28.0\n\npackage pb\n',
+    ],
+    [
+      'Go — banner under build tags',
+      '//go:build !windows\n// +build !windows\n\n// Code generated by MockGen. DO NOT EDIT.\npackage mocks\n',
+    ],
+    [
+      'Go — banner under an Apache-2.0 license preamble',
+      '// Copyright 2021 The Foo Authors.\n// Licensed under the Apache License, Version 2.0 (the "License");\n// you may not use this file except in compliance with the License.\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an "AS IS" BASIS.\n\n// Code generated by sqlc. DO NOT EDIT.\n// source: query.sql\n\npackage db\n',
+    ],
+    [
+      'protoc — Java banner ("DO NOT EDIT!")',
+      '// Generated by the protocol buffer compiler.  DO NOT EDIT!\n// source: foo.proto\n\npackage com.example;\n',
+    ],
+    [
+      'protoc — Python banner behind a coding cookie',
+      '# -*- coding: utf-8 -*-\n# Generated by the protocol buffer compiler.  DO NOT EDIT!\n# source: foo.proto\n',
+    ],
+    [
+      'C# — Roslyn / designer <auto-generated> block',
+      '//------------------------------------------------------------------------------\n// <auto-generated>\n//     This code was generated by a tool.\n// </auto-generated>\n//------------------------------------------------------------------------------\n',
+    ],
+    ['C# — EF self-closing <auto-generated />', '// <auto-generated />\nusing System;\n'],
+    [
+      'JS — Meta/Relay @generated with a SignedSource',
+      '/**\n * @generated SignedSource<<0123456789abcdef0123456789abcdef>>\n * @flow\n */\n',
+    ],
+    [
+      'TS — protobuf-es / Buf @generated',
+      '// @generated by protoc-gen-es v1.2.0 with parameter "target=ts"\n// @generated from file foo.proto (package example, syntax proto3)\n',
+    ],
+    [
+      'Thrift — "Autogenerated by Thrift Compiler"',
+      '/**\n * Autogenerated by Thrift Compiler (0.14.1)\n *\n * DO NOT EDIT UNLESS YOU ARE SURE THAT YOU KNOW WHAT YOU ARE DOING\n */\n',
+    ],
+    [
+      'OpenAPI Generator — "This class is auto generated by"',
+      '/*\n * Pet Store API\n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * Do not edit the class manually.\n */\n',
+    ],
+    [
+      'FlatBuffers — "automatically generated by … do not modify"',
+      '// automatically generated by the FlatBuffers compiler, do not modify\n\npackage MyGame;\n',
+    ],
+    [
+      'Rust — bindgen block comment',
+      '/* automatically generated by rust-bindgen 0.59.2 */\n\npub const FOO: u32 = 1;\n',
+    ],
+    ['ANTLR — "Generated from … -- DO NOT EDIT"', '// Generated from Expr.g4 by ANTLR 4.9.2 -- DO NOT EDIT\npackage parser;\n'],
+    [
+      'banner on an unprefixed line INSIDE a block comment',
+      '/*\n   Code generated by ent. DO NOT EDIT.\n*/\npackage ent\n',
+    ],
+    [
+      'Python — banner inside a module docstring',
+      '"""Generated by the protocol buffer compiler.  DO NOT EDIT!"""\nimport sys\n',
+    ],
+    ['YAML/shell — "#" comment leader', '# This file is generated by kustomize. Do not edit.\napiVersion: v1\n'],
+    ['SQL — "--" comment leader', '-- Code generated by sqlc. DO NOT EDIT.\nCREATE TABLE foo (id INT);\n'],
+    ['HTML/XML — "<!--" comment leader', '<!-- Autogenerated by docgen. Do not edit. -->\n<html></html>\n'],
+  ];
+
+  it.each(GENERATED)('flags: %s', (_label, source) => {
+    expect(hasGeneratedHeader(source)).toBe(true);
+  });
+
+  // Precision cases. Each is a shape that a looser marker table WOULD flag.
+  const HAND_WRITTEN: ReadonlyArray<[string, string]> = [
+    [
+      'ordinary Go source',
+      'package keeper\n\nimport "context"\n\n// SendCoins moves coins between accounts.\nfunc (k Keeper) SendCoins(ctx context.Context) error { return nil }\n',
+    ],
+    [
+      'a generator\'s own source, which merely talks about generating',
+      '// This package generates SQL migrations from the schema.\n// The generated output lives under db/migrations.\npackage gen\n',
+    ],
+    [
+      'prose using "automatically generated" without naming a tool',
+      '"""Report builder.\n\nThe summary table is automatically generated at runtime from the\nrows below; callers should not edit it in place.\n"""\n',
+    ],
+    [
+      'a generator holding the banner as a string constant in its BODY',
+      'package main\n\n// Package main implements the fkit CRUD generator.\n\nimport "fmt"\n\nfunc header() string {\n\treturn "// Code generated by fkit. DO NOT EDIT."\n}\n',
+    ],
+    ['an email address that happens to contain "@generated"', '// Contact: build@generated.example.com for issues.\npackage main\n'],
+    ['"DO NOT EDIT" with no generation claim', '// DO NOT EDIT THIS FILE BY HAND — run `make fmt` instead.\npackage main\n'],
+    ['empty file', ''],
+  ];
+
+  it.each(HAND_WRITTEN)('does not flag: %s', (_label, source) => {
+    expect(hasGeneratedHeader(source)).toBe(false);
+  });
+
+  it('only looks at the header — a banner buried 80 lines down is not a banner', () => {
+    const filler = Array.from({ length: 80 }, (_, i) => `// filler line ${i}`).join('\n');
+    expect(hasGeneratedHeader(`${filler}\n// Code generated by foo. DO NOT EDIT.\npackage main\n`)).toBe(false);
+    // …but the same banner within the window is caught.
+    const shortFiller = Array.from({ length: 20 }, (_, i) => `// filler line ${i}`).join('\n');
+    expect(hasGeneratedHeader(`${shortFiller}\n// Code generated by foo. DO NOT EDIT.\npackage main\n`)).toBe(true);
+  });
+
+  it('requires a comment line — the same words in executable code are not a banner', () => {
+    // No comment leader, no open block: this is a bare statement.
+    expect(hasGeneratedHeader('const banner = "Code generated by tool. DO NOT EDIT.";\n')).toBe(false);
+  });
+
+  it('does not classify the detector module itself (the pattern table must stay below the header window)', () => {
+    const self = fs.readFileSync(
+      path.join(__dirname, '..', 'src', 'extraction', 'generated-detection.ts'),
+      'utf-8'
+    );
+    expect(hasGeneratedHeader(self)).toBe(false);
+  });
+});
+
+describe('detectGeneratedFile — the union the indexer persists', () => {
+  it('is true when only the PATH says so', () => {
+    expect(detectGeneratedFile('x/bank/types/tx.pb.go', 'package types\n')).toBe(true);
+  });
+
+  it('is true when only the CONTENT says so — the #1500 acceptance case', () => {
+    // A Go file named `payroll.go` sitting beside hand-written workflow
+    // use-cases. Nothing in the path gives it away.
+    expect(
+      detectGeneratedFile('internal/payroll/payroll.go', 'package payroll\n\n// Code generated by fkit. DO NOT EDIT.\n\nfunc Create() {}\n')
+    ).toBe(true);
+    expect(isGeneratedFile('internal/payroll/payroll.go')).toBe(false);
+  });
+
+  it('is false for a hand-written file with an ordinary name', () => {
+    expect(
+      detectGeneratedFile('internal/payroll/workflow.go', 'package payroll\n\n// RunPayrollWorkflow drives the monthly run.\nfunc RunPayrollWorkflow() {}\n')
+    ).toBe(false);
+  });
+});

+ 204 - 0
__tests__/generated-flag-index.test.ts

@@ -0,0 +1,204 @@
+/**
+ * Index-time persistence of the generated-file flag (#1500).
+ *
+ * `isGeneratedFile` is path-only, so a Go monorepo's generated CRUD — ordinary
+ * filenames, a `// Code generated by … DO NOT EDIT.` banner in the header — is
+ * invisible to it and outranks the hand-written use-case beside it. The fix
+ * decides the verdict ONCE during extraction (content is already in memory for
+ * parsing) and persists it on `files.generated`, so ranking reads a column
+ * instead of re-reading file headers per request.
+ *
+ * This suite pins the whole path: extraction writes it, `sync` re-decides it,
+ * the migration adds the column to an old database, and the bounded lookup
+ * that ranking uses unions it with the filename convention.
+ */
+
+import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import CodeGraph from '../src';
+import { QueryBuilder } from '../src/db/queries';
+import { createDatabase, type SqliteDatabase } from '../src/db/sqlite-adapter';
+import { runMigrations, getCurrentVersion, CURRENT_SCHEMA_VERSION } from '../src/db/migrations';
+
+/** The FKIT-style generated CRUD from the issue: ordinary name, banner inside. */
+const GENERATED_PAYROLL = `package payroll
+
+// Code generated by fkit. DO NOT EDIT.
+
+type PayrollRecord struct {
+	ID     string
+	Amount int
+}
+
+func CreatePayrollRecord(r PayrollRecord) error { return nil }
+func UpdatePayrollRecord(r PayrollRecord) error { return nil }
+func DeletePayrollRecord(id string) error       { return nil }
+`;
+
+/** The hand-written use-case that must NOT be demoted. */
+const HANDWRITTEN_WORKFLOW = `package payroll
+
+// RunPayrollWorkflow computes the monthly run and persists each record.
+func RunPayrollWorkflow(records []PayrollRecord) error {
+	for _, r := range records {
+		if err := CreatePayrollRecord(r); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+`;
+
+describe('generated flag — written at index time', () => {
+  let dir: string;
+  let cg: CodeGraph;
+
+  beforeAll(async () => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-genflag-'));
+    fs.writeFileSync(path.join(dir, 'payroll.go'), GENERATED_PAYROLL);
+    fs.writeFileSync(path.join(dir, 'workflow.go'), HANDWRITTEN_WORKFLOW);
+    // A path-convention generated file, so both signals are exercised together.
+    fs.writeFileSync(path.join(dir, 'payroll.pb.go'), 'package payroll\n\ntype PayrollProto struct{}\n');
+    cg = await CodeGraph.init(dir, { index: true });
+  });
+
+  afterAll(() => {
+    cg?.close();
+    fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  it('flags an ORDINARY-named Go file carrying the DO-NOT-EDIT banner (the acceptance case)', () => {
+    expect(cg.getFile('payroll.go')?.generated).toBe(true);
+  });
+
+  it('leaves the hand-written use-case beside it unflagged', () => {
+    expect(cg.getFile('workflow.go')?.generated).toBe(false);
+  });
+
+  it('still flags the filename convention', () => {
+    expect(cg.getFile('payroll.pb.go')?.generated).toBe(true);
+  });
+
+  it('counts the flagged files', () => {
+    expect(cg.getGeneratedFileCount()).toBe(2);
+  });
+
+  it('exposes a bounded predicate that unions both signals', () => {
+    const isGen = cg.generatedFilePredicate(['payroll.go', 'workflow.go', 'payroll.pb.go']);
+    expect(isGen('payroll.go')).toBe(true); // content only
+    expect(isGen('payroll.pb.go')).toBe(true); // path (and content)
+    expect(isGen('workflow.go')).toBe(false);
+  });
+
+  it('falls back to the filename check for a path outside the queried set', () => {
+    const isGen = cg.generatedFilePredicate([]);
+    // Not in the bounded set, but the path convention still decides.
+    expect(isGen('some/other/tx.pb.go')).toBe(true);
+    expect(isGen('some/other/keeper.go')).toBe(false);
+  });
+
+  it('re-decides on sync: removing the banner clears the flag', async () => {
+    fs.writeFileSync(
+      path.join(dir, 'payroll.go'),
+      GENERATED_PAYROLL.replace('// Code generated by fkit. DO NOT EDIT.\n\n', '')
+    );
+    await cg.sync();
+    expect(cg.getFile('payroll.go')?.generated).toBe(false);
+
+    // …and adding it back re-flags it, so a stale 1 can never linger.
+    fs.writeFileSync(path.join(dir, 'payroll.go'), GENERATED_PAYROLL);
+    await cg.sync();
+    expect(cg.getFile('payroll.go')?.generated).toBe(true);
+  });
+});
+
+describe('generated flag — schema migration to v9', () => {
+  let dir: string;
+  let db: SqliteDatabase | null = null;
+
+  afterEach(() => {
+    db?.close();
+    db = null;
+    if (dir) fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  /** A pre-v9 `files` table: no `generated` column, no partial index. */
+  function makeLegacyDb(): SqliteDatabase {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-genmigrate-'));
+    const conn = createDatabase(path.join(dir, 'legacy.db')).db;
+    conn.exec(`
+      CREATE TABLE schema_versions (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL, description TEXT);
+      INSERT INTO schema_versions VALUES (8, 0, 'legacy');
+      CREATE TABLE files (
+        path TEXT PRIMARY KEY,
+        content_hash TEXT NOT NULL,
+        language TEXT NOT NULL,
+        size INTEGER NOT NULL,
+        modified_at INTEGER NOT NULL,
+        indexed_at INTEGER NOT NULL,
+        node_count INTEGER DEFAULT 0,
+        errors TEXT
+      );
+      INSERT INTO files VALUES ('x/bank/types/tx.pb.go', 'h1', 'go', 10, 0, 0, 1, NULL);
+      INSERT INTO files VALUES ('internal/payroll/payroll.go', 'h2', 'go', 10, 0, 0, 1, NULL);
+    `);
+    db = conn;
+    return conn;
+  }
+
+  const columnNames = (conn: SqliteDatabase): string[] =>
+    (conn.prepare('PRAGMA table_info(files)').all() as Array<{ name: string }>).map((c) => c.name);
+
+  it('adds the column and the partial index without touching existing rows', () => {
+    const conn = makeLegacyDb();
+
+    expect(getCurrentVersion(conn)).toBe(8);
+    runMigrations(conn, 8);
+    expect(getCurrentVersion(conn)).toBe(CURRENT_SCHEMA_VERSION);
+
+    expect(columnNames(conn)).toContain('generated');
+
+    const indexes = (conn.prepare('PRAGMA index_list(files)').all() as Array<{ name: string }>).map((i) => i.name);
+    expect(indexes).toContain('idx_files_generated');
+
+    // NO backfill: the flag is derived from file CONTENT, which the migration
+    // cannot see (files stores a hash, not bytes). Rows stay 0 until a
+    // re-index, and readers union with the path check so behavior is unchanged
+    // rather than regressed. This is why the CHANGELOG says "requires a
+    // re-index".
+    expect((conn.prepare('SELECT COUNT(*) AS n FROM files WHERE generated = 1').get() as { n: number }).n).toBe(0);
+    expect((conn.prepare('SELECT COUNT(*) AS n FROM files').get() as { n: number }).n).toBe(2);
+  });
+
+  it('is idempotent — replaying v9 over a database that already has the column does not throw', () => {
+    const conn = makeLegacyDb();
+    runMigrations(conn, 8);
+
+    // ALTER TABLE has no IF NOT EXISTS, so v9 guards on PRAGMA table_info.
+    // Replay happens for real whenever the recorded version trails the on-disk
+    // shape — a database created straight from current schema.sql already HAS
+    // the column, and the v6 regression test rewinds `schema_versions` and
+    // re-runs. Rewind the same way here; without the guard this is
+    // "duplicate column name: generated".
+    conn.prepare('DELETE FROM schema_versions WHERE version >= 9').run();
+    expect(() => runMigrations(conn, 8)).not.toThrow();
+    expect(columnNames(conn).filter((c) => c === 'generated')).toHaveLength(1);
+    expect(getCurrentVersion(conn)).toBe(CURRENT_SCHEMA_VERSION);
+  });
+
+  it('an un-backfilled database still down-ranks by the path convention', () => {
+    const conn = makeLegacyDb();
+    runMigrations(conn, 8);
+
+    const queries = new QueryBuilder(conn);
+    const paths = ['x/bank/types/tx.pb.go', 'internal/payroll/payroll.go'];
+    // Nothing carries the content flag yet…
+    expect(queries.getGeneratedPathsAmong(paths).size).toBe(0);
+    // …but the union predicate still knows `.pb.go`.
+    const isGen = queries.generatedPredicateFor(paths);
+    expect(isGen('x/bank/types/tx.pb.go')).toBe(true);
+    expect(isGen('internal/payroll/payroll.go')).toBe(false);
+  });
+});

+ 15 - 2
__tests__/pr19-improvements.test.ts

@@ -298,8 +298,21 @@ describe('Best-Candidate Resolution', () => {
 
 describe('Schema v2 Migration', () => {
   it.skipIf(!HAS_SQLITE)('should have correct current schema version', async () => {
-    const { CURRENT_SCHEMA_VERSION } = await import('../src/db/migrations');
-    expect(CURRENT_SCHEMA_VERSION).toBe(8);
+    const { CURRENT_SCHEMA_VERSION, getPendingMigrations } = await import('../src/db/migrations');
+    const { DatabaseConnection } = await import('../src/db');
+
+    // The constant must track the migration table, not a literal — a literal
+    // just makes every schema change edit this test (v9/#1500 was the latest).
+    // A fresh database records the current version, so nothing is pending;
+    // ask a version-0 database instead to see the full migration list.
+    const dbPath = path.join(createTempDir(), 'schema-version.db');
+    const conn = DatabaseConnection.initialize(dbPath);
+    const raw = conn.getDb();
+    raw.prepare('DELETE FROM schema_versions').run();
+    const highest = Math.max(...getPendingMigrations(raw).map((m) => m.version));
+    conn.close();
+
+    expect(CURRENT_SCHEMA_VERSION).toBe(highest);
   });
 
   it.skipIf(!HAS_SQLITE)('should have migration for version 2', async () => {

+ 4 - 0
__tests__/security.test.ts

@@ -408,6 +408,10 @@ describe('MCP Input Validation', () => {
     }));
     const fakeCg = {
       searchNodes: () => many,
+      // Search down-ranks generated files, and since #1500 that verdict comes
+      // from the index (path convention ∪ content banner) rather than the
+      // filename alone. No database here — none of these paths is generated.
+      generatedFilePredicate: () => () => false,
     };
     const fakeHandler = new ToolHandler(fakeCg as unknown as CodeGraph);
 

+ 123 - 0
docs/design/generated-file-detection.md

@@ -0,0 +1,123 @@
+# Generated-file detection — path convention plus content banner
+
+CG-5, issue #1500. Companion to `explore-budget-allocation.md` (CG-4's instrument) and a
+prerequisite for the scoring overhaul (CG-10).
+
+## The problem
+
+`isGeneratedFile` was path-only. It matches the `<basename>.<tool>.<ext>` convention —
+`.pb.go`, `_grpc.pb.go`, `.g.dart`, `_pb2.py` — which is where most codegen output lives,
+and which was enough for the cosmos-sdk audit that motivated it.
+
+It is not enough for Go. **Go's convention is a content marker, not a filename one:**
+
+```go
+// Code generated by <tool>. DO NOT EDIT.
+```
+
+codified by `go generate`, honored by gofmt, golangci-lint and GitHub linguist, and emitted
+by protoc-gen-go, mockgen, sqlc, ent, wire, stringer — and by in-house generators. #1500 is
+exactly this: a Go monorepo with generated FKIT CRUD in ordinarily-named files
+(`payroll.go`) sitting beside hand-written workflow use-cases. Nothing in the path gives it
+away, so every generated-file down-rank in the codebase was a no-op on it.
+
+### How large the gap is
+
+Measured on a shallow clone of `kubernetes/client-go` (2,453 Go files):
+
+| signal | files flagged |
+|---|---|
+| ground truth (grep the canonical banner in the first 60 lines) | 2,001 |
+| path convention (`isGeneratedFile`) | **0** |
+| content banner (`hasGeneratedHeader`) | **2,001** — 0 false positives, 0 misses |
+
+82% of that repository is generated code with ordinary filenames, and the path check saw
+none of it. This is not a long-tail case.
+
+## Design
+
+**Decide at index time, store on the file record, read from the DB.** Explore must never
+read file headers per request.
+
+- `isGeneratedFile(path)` — unchanged. Path-only, pure, synchronous, free to call in a sort
+  comparator. Kept for callers with no database in hand.
+- `hasGeneratedHeader(content)` — the content signal (below).
+- `detectGeneratedFile(path, content)` — the union, which is what the indexer persists.
+- `files.generated INTEGER NOT NULL DEFAULT 0` (schema v9) + a **partial** index
+  `idx_files_generated ON files(path) WHERE generated = 1`, so lookups cost the generated
+  minority, not the repo.
+- `QueryBuilder.generatedPredicateFor(paths)` / `CodeGraph.generatedFilePredicate(paths)` —
+  one bounded probe up front, `O(1)` per comparison after, unioned with the path check.
+
+### Why a bounded lookup and not a cached set
+
+Every consumer already holds a short candidate list — a ranked file group, an FTS result
+page, a `LIMIT 20` aggregate. Intersecting that list against the partial index needs no
+whole-repo set to materialize and, more importantly, **no cache to invalidate**: a ranking
+call can never serve a verdict the last sync already replaced. The alternative (a lazily
+materialized `Set` of all generated paths) has to be invalidated on every file write and
+goes stale on the read-only pool workers, in exchange for saving a sub-millisecond query.
+
+### Precision over recall
+
+A false positive silently demotes hand-written code in every ranking path, so the marker
+table is precision-first and the scan is fenced three ways:
+
+1. **Header window only** — first 8,192 chars / 60 lines. Generous enough for build tags
+   plus an Apache-2.0 preamble above the banner; tight enough that a generator's own source,
+   which holds the banner as a *string constant in its body*, is not flagged.
+2. **Comment lines only** — the marker must sit on a line with a comment leader (`//`, `#`,
+   `--`, `<!--`, `%`, `;`, `'`, …) or inside an open block comment (`/* */`, `<!-- -->`,
+   `"""`, `'''`, `=begin`, `<# #>`), tracked with a small state machine over the window.
+   Generators always emit banners as comments; requiring it rules out identifiers and
+   string literals that merely contain the words.
+3. **Tight markers** — `automatically generated` alone is prose ("the table is automatically
+   generated at runtime"); `automatically generated **by**` is a banner. `DO NOT EDIT` alone
+   is a style directive; paired with a generation claim it is a banner.
+
+The module deliberately keeps its own quoted banner literals **below** the header window so
+it does not classify itself; `generated-detection.test.ts` pins that, so moving the pattern
+table upward fails a test rather than silently demoting this file.
+
+### Migration: no backfill, by necessity
+
+v9 is DDL only. The flag derives from file **content**, which the migration cannot see —
+`files` stores a hash, not bytes. Migrated rows stay 0 until a re-index, and because every
+reader unions the flag with the path check, 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.
+
+## Cost
+
+The acceptance bar was "no measurable index-time cost regression."
+
+A single unanchored `/generat/i` test over the header rejects ~every hand-written file
+before any line splitting happens. `String.prototype.slice` on a long string yields a V8
+sliced view, not a copy, so the fast path allocates nothing.
+
+- **Microbenchmark** (`detectGeneratedFile` over a whole corpus, 5 passes):
+  4.6 µs/file on client-go (2,453 files, 14.2 MB, 82% generated — the worst case, where the
+  gate passes and the full line scan runs), 7.3 µs/file on this repo's `src`.
+- **End-to-end** `codegraph init` on client-go, n=3 alternating arms
+  (current build vs. the same build with the content scan stubbed out):
+
+  | arm | runs (s) | median |
+  |---|---|---|
+  | with content detection | 5.66, 5.73, 5.89 | **5.73** |
+  | path-only baseline | 5.52, 5.76, 5.88 | **5.76** |
+
+  The arms cross over between runs — the difference is inside run-to-run noise.
+
+## What this task does NOT change
+
+Generated status remains a **stable tiebreak at equal score**, exactly where it was
+(`src/mcp/tools.ts` file sort, `findSymbolMatches`, `findAllSymbols`, search formatting,
+`getDominantFile`/`getTopRouteFile`/`getRoutingManifest`, the context formatter). A
+generated file with a higher raw score still outranks a hand-written one. Turning generated
+status into a **strong negative signal** is CG-10, which this task unblocks by making the
+signal correct and available.
+
+Verified end-to-end on a two-file Go package where a generated `payroll.go` and a
+hand-written `workflow.go` both define `ProcessPayroll`: with the flag set the hand-written
+file ranks first; clearing the flag in the same index (i.e. pre-#1500 behavior) puts the
+generated file first.

+ 3 - 3
src/bin/codegraph.ts

@@ -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
src/context/formatter.ts

@@ -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
src/context/index.ts

@@ -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
src/db/migrations.ts

@@ -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
src/db/queries.ts

@@ -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
src/db/schema.sql

@@ -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
src/extraction/generated-detection.ts

@@ -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 - 0
src/extraction/index.ts

@@ -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 - 0
src/index.ts

@@ -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
src/mcp/tools.ts

@@ -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 - 0
src/types.ts

@@ -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;
 }
 
 // =============================================================================