generated-flag-index.test.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. /**
  2. * Index-time persistence of the generated-file flag (#1500).
  3. *
  4. * `isGeneratedFile` is path-only, so a Go monorepo's generated CRUD — ordinary
  5. * filenames, a `// Code generated by … DO NOT EDIT.` banner in the header — is
  6. * invisible to it and outranks the hand-written use-case beside it. The fix
  7. * decides the verdict ONCE during extraction (content is already in memory for
  8. * parsing) and persists it on `files.generated`, so ranking reads a column
  9. * instead of re-reading file headers per request.
  10. *
  11. * This suite pins the whole path: extraction writes it, `sync` re-decides it,
  12. * the migration adds the column to an old database, and the bounded lookup
  13. * that ranking uses unions it with the filename convention.
  14. */
  15. import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
  16. import * as fs from 'fs';
  17. import * as path from 'path';
  18. import * as os from 'os';
  19. import CodeGraph from '../src';
  20. import { QueryBuilder } from '../src/db/queries';
  21. import { createDatabase, type SqliteDatabase } from '../src/db/sqlite-adapter';
  22. import { runMigrations, getCurrentVersion, CURRENT_SCHEMA_VERSION } from '../src/db/migrations';
  23. /** The FKIT-style generated CRUD from the issue: ordinary name, banner inside. */
  24. const GENERATED_PAYROLL = `package payroll
  25. // Code generated by fkit. DO NOT EDIT.
  26. type PayrollRecord struct {
  27. ID string
  28. Amount int
  29. }
  30. func CreatePayrollRecord(r PayrollRecord) error { return nil }
  31. func UpdatePayrollRecord(r PayrollRecord) error { return nil }
  32. func DeletePayrollRecord(id string) error { return nil }
  33. `;
  34. /** The hand-written use-case that must NOT be demoted. */
  35. const HANDWRITTEN_WORKFLOW = `package payroll
  36. // RunPayrollWorkflow computes the monthly run and persists each record.
  37. func RunPayrollWorkflow(records []PayrollRecord) error {
  38. for _, r := range records {
  39. if err := CreatePayrollRecord(r); err != nil {
  40. return err
  41. }
  42. }
  43. return nil
  44. }
  45. `;
  46. describe('generated flag — written at index time', () => {
  47. let dir: string;
  48. let cg: CodeGraph;
  49. beforeAll(async () => {
  50. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-genflag-'));
  51. fs.writeFileSync(path.join(dir, 'payroll.go'), GENERATED_PAYROLL);
  52. fs.writeFileSync(path.join(dir, 'workflow.go'), HANDWRITTEN_WORKFLOW);
  53. // A path-convention generated file, so both signals are exercised together.
  54. fs.writeFileSync(path.join(dir, 'payroll.pb.go'), 'package payroll\n\ntype PayrollProto struct{}\n');
  55. cg = await CodeGraph.init(dir, { index: true });
  56. });
  57. afterAll(() => {
  58. cg?.close();
  59. fs.rmSync(dir, { recursive: true, force: true });
  60. });
  61. it('flags an ORDINARY-named Go file carrying the DO-NOT-EDIT banner (the acceptance case)', () => {
  62. expect(cg.getFile('payroll.go')?.generated).toBe(true);
  63. });
  64. it('leaves the hand-written use-case beside it unflagged', () => {
  65. expect(cg.getFile('workflow.go')?.generated).toBe(false);
  66. });
  67. it('still flags the filename convention', () => {
  68. expect(cg.getFile('payroll.pb.go')?.generated).toBe(true);
  69. });
  70. it('counts the flagged files', () => {
  71. expect(cg.getGeneratedFileCount()).toBe(2);
  72. });
  73. it('exposes a bounded predicate that unions both signals', () => {
  74. const isGen = cg.generatedFilePredicate(['payroll.go', 'workflow.go', 'payroll.pb.go']);
  75. expect(isGen('payroll.go')).toBe(true); // content only
  76. expect(isGen('payroll.pb.go')).toBe(true); // path (and content)
  77. expect(isGen('workflow.go')).toBe(false);
  78. });
  79. it('falls back to the filename check for a path outside the queried set', () => {
  80. const isGen = cg.generatedFilePredicate([]);
  81. // Not in the bounded set, but the path convention still decides.
  82. expect(isGen('some/other/tx.pb.go')).toBe(true);
  83. expect(isGen('some/other/keeper.go')).toBe(false);
  84. });
  85. it('re-decides on sync: removing the banner clears the flag', async () => {
  86. fs.writeFileSync(
  87. path.join(dir, 'payroll.go'),
  88. GENERATED_PAYROLL.replace('// Code generated by fkit. DO NOT EDIT.\n\n', '')
  89. );
  90. await cg.sync();
  91. expect(cg.getFile('payroll.go')?.generated).toBe(false);
  92. // …and adding it back re-flags it, so a stale 1 can never linger.
  93. fs.writeFileSync(path.join(dir, 'payroll.go'), GENERATED_PAYROLL);
  94. await cg.sync();
  95. expect(cg.getFile('payroll.go')?.generated).toBe(true);
  96. });
  97. });
  98. describe('generated flag — schema migration to v9', () => {
  99. let dir: string;
  100. let db: SqliteDatabase | null = null;
  101. afterEach(() => {
  102. db?.close();
  103. db = null;
  104. if (dir) fs.rmSync(dir, { recursive: true, force: true });
  105. });
  106. /** A pre-v9 `files` table: no `generated` column, no partial index. */
  107. function makeLegacyDb(): SqliteDatabase {
  108. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-genmigrate-'));
  109. const conn = createDatabase(path.join(dir, 'legacy.db')).db;
  110. conn.exec(`
  111. CREATE TABLE schema_versions (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL, description TEXT);
  112. INSERT INTO schema_versions VALUES (8, 0, 'legacy');
  113. CREATE TABLE files (
  114. path TEXT PRIMARY KEY,
  115. content_hash TEXT NOT NULL,
  116. language TEXT NOT NULL,
  117. size INTEGER NOT NULL,
  118. modified_at INTEGER NOT NULL,
  119. indexed_at INTEGER NOT NULL,
  120. node_count INTEGER DEFAULT 0,
  121. errors TEXT
  122. );
  123. INSERT INTO files VALUES ('x/bank/types/tx.pb.go', 'h1', 'go', 10, 0, 0, 1, NULL);
  124. INSERT INTO files VALUES ('internal/payroll/payroll.go', 'h2', 'go', 10, 0, 0, 1, NULL);
  125. `);
  126. db = conn;
  127. return conn;
  128. }
  129. const columnNames = (conn: SqliteDatabase): string[] =>
  130. (conn.prepare('PRAGMA table_info(files)').all() as Array<{ name: string }>).map((c) => c.name);
  131. it('adds the column and the partial index without touching existing rows', () => {
  132. const conn = makeLegacyDb();
  133. expect(getCurrentVersion(conn)).toBe(8);
  134. runMigrations(conn, 8);
  135. expect(getCurrentVersion(conn)).toBe(CURRENT_SCHEMA_VERSION);
  136. expect(columnNames(conn)).toContain('generated');
  137. const indexes = (conn.prepare('PRAGMA index_list(files)').all() as Array<{ name: string }>).map((i) => i.name);
  138. expect(indexes).toContain('idx_files_generated');
  139. // NO backfill: the flag is derived from file CONTENT, which the migration
  140. // cannot see (files stores a hash, not bytes). Rows stay 0 until a
  141. // re-index, and readers union with the path check so behavior is unchanged
  142. // rather than regressed. This is why the CHANGELOG says "requires a
  143. // re-index".
  144. expect((conn.prepare('SELECT COUNT(*) AS n FROM files WHERE generated = 1').get() as { n: number }).n).toBe(0);
  145. expect((conn.prepare('SELECT COUNT(*) AS n FROM files').get() as { n: number }).n).toBe(2);
  146. });
  147. it('is idempotent — replaying v9 over a database that already has the column does not throw', () => {
  148. const conn = makeLegacyDb();
  149. runMigrations(conn, 8);
  150. // ALTER TABLE has no IF NOT EXISTS, so v9 guards on PRAGMA table_info.
  151. // Replay happens for real whenever the recorded version trails the on-disk
  152. // shape — a database created straight from current schema.sql already HAS
  153. // the column, and the v6 regression test rewinds `schema_versions` and
  154. // re-runs. Rewind the same way here; without the guard this is
  155. // "duplicate column name: generated".
  156. conn.prepare('DELETE FROM schema_versions WHERE version >= 9').run();
  157. expect(() => runMigrations(conn, 8)).not.toThrow();
  158. expect(columnNames(conn).filter((c) => c === 'generated')).toHaveLength(1);
  159. expect(getCurrentVersion(conn)).toBe(CURRENT_SCHEMA_VERSION);
  160. });
  161. it('an un-backfilled database still down-ranks by the path convention', () => {
  162. const conn = makeLegacyDb();
  163. runMigrations(conn, 8);
  164. const queries = new QueryBuilder(conn);
  165. const paths = ['x/bank/types/tx.pb.go', 'internal/payroll/payroll.go'];
  166. // Nothing carries the content flag yet…
  167. expect(queries.getGeneratedPathsAmong(paths).size).toBe(0);
  168. // …but the union predicate still knows `.pb.go`.
  169. const isGen = queries.generatedPredicateFor(paths);
  170. expect(isGen('x/bank/types/tx.pb.go')).toBe(true);
  171. expect(isGen('internal/payroll/payroll.go')).toBe(false);
  172. });
  173. });