Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### New Features

- PostgreSQL projects can now index `.sql`, `.psql`, and `.pgsql` files, including schemas, tables and altered columns, views, routines, types and enum changes, triggers, policies, indexes, sequences, extensions, and their table, foreign-key, trigger, type, sequence, and static SQL or PL/pgSQL routine-body dependencies. Migration-ordered relation drops, re-creations and renames, constraint renames, removed foreign keys, routine-local `search_path`, and malformed SQL are handled without inventing stale relationships.

## [1.5.0] - 2026-07-21

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ CodeGraph's parsing engine is a **native Rust kernel**: 20 languages — TypeScr
| **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 |
| **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes |
| **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config |
| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, PostgreSQL, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
| **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks |
| **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules |
| **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only |
Expand Down Expand Up @@ -794,6 +794,7 @@ is written):
| Erlang | `.erl`, `.hrl`, `.escript`, `.app.src`, `.app` | Full support (functions with multi-clause/multi-arity grouping, `-spec` signatures, records with fields, `-type`/`-opaque` aliases, `-define` macros, `-include`/`-include_lib`/`-import` edges, local and `mod:fn` remote call edges, `fun name/arity` references, `spawn`/`apply`/`proc_lib`/`timer`/`rpc` MFA-argument call edges, `gen_server:call/cast(?MODULE)` → own `handle_call`/`handle_cast` links, `-behaviour` links, `-export`-based visibility) |
| Solidity | `.sol` | Full support (contracts, libraries, interfaces, structs, enums, modifiers, events, errors, state variables, `import`/`using` directives, `emit`/`revert` calls) |
| Terraform / OpenTofu | `.tf`, `.tfvars`, `.tofu` | Full support (resources, data sources, modules, variables, outputs, providers incl. aliases, `locals`; `var.`/`local.`/`module.`/resource references with Terraform's per-directory scoping enforced; module calls bridged across the boundary — inputs to the child module's variables, `module.M.out` to the child's output, `source` to the module's files; cloudposse/atmos `remote-state` cross-component wiring when the component is statically named; `provider = aws.east` selections resolved up the module tree; `moved`/`import`/`removed`/`check` block references; `.tfvars` assignments linked to the variables they set) |
| PostgreSQL | `.sql`, `.psql`, `.pgsql` | Initial support (schemas, tables and columns introduced by `CREATE TABLE` or relation-linked `ALTER TABLE ... ADD COLUMN` migration deltas, views/materialized views, functions/procedures, enums/domains, triggers, policies, indexes, sequences, extensions, foreign keys, relation dependencies, and routine calls; string-literal routine bodies including dollar-quoted PL/pgSQL are not parsed yet) |
| Nix | `.nix` | Full support (functions with simple/destructured/curried params, `let`/attrset bindings, `inherit`, `import ./path` file edges — `./dir` resolving through `default.nix` — plus NixOS module `imports = [ ./x.nix ]` lists and `callPackage ./pkg.nix` file edges; call edges; module-system option wiring — a config write like `launchd.user.agents.x = { ... }` links to the module declaring `options.launchd.user.agents`, so option flows trace across modules) |

## Measured cross-file coverage
Expand Down
69 changes: 68 additions & 1 deletion __tests__/db-perf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,73 @@ describe('runMaintenance', () => {
});
});

describe('bulk-load index crash recovery', () => {
it('recreates every secondary index dropped by interrupted bulk-load windows on reopen', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-bulk-heal-'));
const dbPath = path.join(dir, 'test.db');
const db = DatabaseConnection.initialize(dbPath);

// Simulate a process dying with all three bulk windows open. close() does
// not repair schema objects, so the persisted database matches the state a
// SIGKILL would leave behind.
db.beginBulkParseLoad();
db.beginBulkRefLoad();

const dropped = [
'idx_nodes_kind',
'idx_nodes_name',
'idx_nodes_qualified_name',
'idx_nodes_file_path',
'idx_nodes_language',
'idx_nodes_file_line',
'idx_nodes_lower_name',
'idx_unresolved_from_node',
'idx_unresolved_name',
'idx_unresolved_file_path',
'idx_unresolved_from_name',
'idx_unresolved_status',
'idx_unresolved_failed_tail',
'idx_files_language',
'idx_files_modified_at',
'idx_edges_kind',
'idx_edges_source_kind',
'idx_edges_target_kind',
'idx_edges_provenance',
'idx_edges_synthesized_by',
];
const indexExists = (connection: DatabaseConnection, name: string) =>
connection
.getDb()
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?")
.get(name);

for (const name of dropped) expect(indexExists(db, name)).toBeUndefined();
db.close();

const reopened = DatabaseConnection.open(dbPath);
try {
for (const name of dropped) expect(indexExists(reopened, name), name).toBeTruthy();

// The new expression index is not merely present by name: SQLite can use
// it for the ownership predicate exercised by whole-graph synthesizers.
const plan = reopened
.getDb()
.prepare(
`EXPLAIN QUERY PLAN
SELECT source, target, kind
FROM edges
WHERE json_extract(CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END, '$.synthesizedBy') = ?
AND json_extract(CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END, '$.synthesizedBy') IS NOT NULL`
)
.all('postgres-foreign-key') as Array<{ detail: string }>;
expect(plan.map((row) => row.detail).join(' ')).toContain('idx_edges_synthesized_by');
} finally {
reopened.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

// The edges table carried no UNIQUE constraint, so `insertEdge`'s
// `INSERT OR IGNORE` had nothing to conflict on and silently admitted
// byte-identical duplicate rows when two passes emitted the same edge (#1034).
Expand Down Expand Up @@ -344,7 +411,7 @@ describe('migration v6: dedup edges + add identity index on upgrade (#1034)', ()
runMigrations(raw, 5);

expect(count()).toBe(2); // duplicate collapsed, the distinct `calls` edge kept
expect(getCurrentVersion(raw)).toBe(8);
expect(getCurrentVersion(raw)).toBe(9);
const idx = raw
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_edges_identity'")
.get();
Expand Down
2 changes: 1 addition & 1 deletion __tests__/foundation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ describe('Database Connection', () => {

const version = db.getSchemaVersion();
expect(version).not.toBeNull();
expect(version?.version).toBe(8);
expect(version?.version).toBe(9);

db.close();
});
Expand Down
198 changes: 198 additions & 0 deletions __tests__/postgres-enum-rename-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { beforeAll, describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { CodeGraph } from '../src';
import { extractFromSource } from '../src/extraction';
import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
import {
decodePostgresEnumValueMutationDescriptor,
decodePostgresTypeRenameDescriptor,
} from '../src/postgres/type-lifecycle';

beforeAll(async () => {
await initGrammars();
await loadGrammarsForLanguages(['postgres']);
});

describe('PostgreSQL enum rename lifecycle', () => {
it('encodes type identity and old/new value labels without classifying the alias early', () => {
const result = extractFromSource('db/renames.sql', [
"CREATE TYPE app.job_status AS ENUM ('queued', 'running');",
'ALTER TYPE app.job_status RENAME TO task_status;',
"ALTER TYPE app.task_status RENAME VALUE 'queued' TO 'pending';",
].join('\n'), 'postgres');

expect(result.errors).toEqual([]);
const alias = result.nodes.find((node) => node.qualifiedName === 'app.task_status')!;
expect(alias).toMatchObject({
kind: 'type_alias',
decorators: expect.arrayContaining(['postgres:type', 'postgres:renamed-type']),
});
expect(alias.decorators).not.toContain('postgres:enum');
expect(decodePostgresTypeRenameDescriptor(alias.decorators)).toEqual({
sourceType: 'app.job_status',
targetType: 'app.task_status',
});

const pending = result.nodes.find((node) => node.qualifiedName === 'app.task_status.pending')!;
expect(decodePostgresEnumValueMutationDescriptor(pending.decorators)).toEqual({
mutation: 'rename',
enumType: 'app.task_status',
sourceValue: 'queued',
targetValue: 'pending',
});
});

it('carries exact effective membership through chained type and value renames', async () => {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-enum-lifecycle-'));
fs.writeFileSync(
path.join(projectDir, '001_enum.sql'),
"CREATE TYPE app.job_status AS ENUM ('queued', 'running');\n"
);
fs.writeFileSync(
path.join(projectDir, '002_first_rename.sql'),
'ALTER TYPE app.job_status RENAME TO task_status;\n'
);
fs.writeFileSync(
path.join(projectDir, '003_values.sql'),
[
"ALTER TYPE app.task_status ADD VALUE 'paused';",
"ALTER TYPE app.task_status RENAME VALUE 'queued' TO 'pending';",
].join('\n') + '\n'
);
fs.writeFileSync(
path.join(projectDir, '004_second_rename.sql'),
'ALTER TYPE app.task_status RENAME TO workflow_status;\n'
);
fs.writeFileSync(
path.join(projectDir, '005_final_values.sql'),
[
"ALTER TYPE app.workflow_status RENAME VALUE 'running' TO 'active';",
"ALTER TYPE app.workflow_status ADD VALUE 'done';",
].join('\n') + '\n'
);

const graph = CodeGraph.initSync(projectDir);
try {
await graph.indexAll();
const original = graph.getNodesByQualifiedName('app.job_status')[0]!;
const intermediate = graph.getNodesByQualifiedName('app.task_status')[0]!;
const current = graph.getNodesByQualifiedName('app.workflow_status')[0]!;

expect(current).toMatchObject({
kind: 'type_alias',
decorators: expect.arrayContaining(['postgres:type', 'postgres:renamed-type']),
});
const relationTargets = (nodeId: string) => graph.getOutgoingEdges(nodeId)
.filter((edge) => edge.metadata?.postgresRelation === 'enum-rename')
.map((edge) => edge.target);
expect(relationTargets(original.id)).toEqual([intermediate.id]);
expect(relationTargets(intermediate.id)).toEqual([current.id]);

const effectiveMembers = (nodeId: string) => graph.getOutgoingEdges(nodeId)
.filter((edge) => edge.metadata?.postgresRelation === 'enum-effective-containment')
.map((edge) => graph.getNode(edge.target)!.name)
.sort();
expect(effectiveMembers(intermediate.id)).toEqual(['paused', 'pending', 'running']);
expect(effectiveMembers(current.id)).toEqual(['active', 'done', 'paused', 'pending']);
expect(effectiveMembers(current.id)).not.toContain('queued');
expect(effectiveMembers(current.id)).not.toContain('running');

// Historical declarations and mutation nodes remain queryable even when
// their labels are no longer part of the current enum identity.
expect(graph.getNodesByQualifiedName('app.job_status.queued')).not.toEqual([]);
expect(graph.getNodesByQualifiedName('app.task_status.pending')).not.toEqual([]);
} finally {
graph.destroy();
fs.rmSync(projectDir, { recursive: true, force: true });
}
});

it('records a native enum value rename as an explicit historical transition', async () => {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-native-enum-rename-'));
fs.writeFileSync(
path.join(projectDir, '001_enum.sql'),
"CREATE TYPE app.status AS ENUM ('pending', 'active');\n"
);
fs.writeFileSync(
path.join(projectDir, '002_rename.sql'),
"ALTER TYPE app.status RENAME VALUE 'pending' TO 'queued';\n"
);

const graph = CodeGraph.initSync(projectDir);
try {
await graph.indexAll();
const status = graph.getNodesByQualifiedName('app.status')[0]!;
const pending = graph.getNodesByQualifiedName('app.status.pending')[0]!;
const queued = graph.getNodesByQualifiedName('app.status.queued')[0]!;
const containedMembers = graph.getOutgoingEdges(status.id)
.filter((edge) => edge.kind === 'contains')
.map((edge) => graph.getNode(edge.target)!)
.filter((node) => node.kind === 'enum_member');

// Ordinary containment is source history: CREATE TYPE's old label stays
// queryable, and ALTER TYPE's replacement is attached by synthesis.
expect(containedMembers.map((member) => member.name).sort()).toEqual([
'active',
'pending',
'queued',
]);

const transition = graph.getOutgoingEdges(pending.id).find(
(edge) => edge.metadata?.postgresRelation === 'enum-value-rename'
);
expect(transition).toMatchObject({
target: queued.id,
kind: 'references',
metadata: {
postgresRelation: 'enum-value-rename',
enumType: 'app.status',
sourceValue: 'pending',
targetValue: 'queued',
},
});

const superseded = new Set(containedMembers
.filter((member) => graph.getOutgoingEdges(member.id).some(
(edge) => edge.metadata?.postgresRelation === 'enum-value-rename'
))
.map((member) => member.id));
expect(containedMembers
.filter((member) => !superseded.has(member.id))
.map((member) => member.name)
.sort()).toEqual(['active', 'queued']);
} finally {
graph.destroy();
fs.rmSync(projectDir, { recursive: true, force: true });
}
});

it('does not infer enum identity for a renamed composite type', async () => {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-type-lifecycle-'));
fs.writeFileSync(
path.join(projectDir, '001_type.sql'),
'CREATE TYPE app.point AS (x integer, y integer);\n'
);
fs.writeFileSync(
path.join(projectDir, '002_rename.sql'),
'ALTER TYPE app.point RENAME TO coordinate;\n'
);

const graph = CodeGraph.initSync(projectDir);
try {
await graph.indexAll();
const alias = graph.getNodesByQualifiedName('app.coordinate')[0]!;
expect(alias.kind).toBe('type_alias');
expect(graph.getIncomingEdges(alias.id).some(
(edge) => edge.metadata?.postgresRelation === 'enum-rename'
)).toBe(false);
expect(graph.getOutgoingEdges(alias.id).some(
(edge) => edge.metadata?.postgresRelation === 'enum-effective-containment'
)).toBe(false);
} finally {
graph.destroy();
fs.rmSync(projectDir, { recursive: true, force: true });
}
});
});
Loading