Files
codegraph/__tests__/generated-flag-index.test.ts
Colby Mchenry 2a2f71ec41 fix(sync): refresh synthesized edges after incremental changes (#1988) (#2033)
* fix(sync): refresh synthesized edges after incremental changes (#1988)

Scoped sync skipped synthesis and retained obsolete dispatch edges.
Refresh all synthesis passes after base resolution when dependencies or source patterns change.
Stage and atomically replace owned edges, preserve ordinary edges, and retry interrupted refreshes.
Stabilize traversal order and migrate existing indexes for rebuild convergence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: preserve structural Go containment during synthesis refresh

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:41:49 +00:00

203 lines
8.1 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 database: no `generated` column or partial index. */
function makeLegacyDb(): SqliteDatabase {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-genmigrate-'));
const conn = createDatabase(path.join(dir, 'legacy.db')).db;
db = conn;
// Later migrations also run, so retain the rest of the real graph schema.
conn.exec(fs.readFileSync(path.join(__dirname, '../src/db/schema.sql'), 'utf8'));
conn.exec(`
DELETE FROM schema_versions;
INSERT INTO schema_versions VALUES (8, 0, 'legacy');
DROP INDEX idx_files_generated;
ALTER TABLE files DROP COLUMN generated;
DROP TABLE synthesis_inputs;
DROP INDEX idx_edges_synthesis_site;
DROP INDEX idx_nodes_kind;
CREATE INDEX idx_nodes_kind ON nodes(kind);
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);
`);
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);
});
});