/** * 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); }); });