`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>
205 lines
8.0 KiB
TypeScript
205 lines
8.0 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|