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