feat(graph): expose isTestCode on definition nodes

Test-file classification was reachable only from inside the codebase
(`isTestFilePath`). A Cypher consumer — the `cypher` MCP tool, or an agent
reasoning about the graph — had no way to ask "is this test code?" and had to
reimplement the path heuristic in the query itself. That reimplementation is
fragile in the exact way the duplicated predicate was: it drifts from the real
definition, silently, with no test covering it.

Add `isTestCode BOOLEAN` to the five definition-bearing node tables (Function,
Class, Interface, Method, CodeElement — the ones already carrying `isExported`)
and derive it centrally in the CSV writer from each node's `filePath`, so it
cannot be forgotten at an emit site.

Naming: `isTestCode`, not `isTestEntryPoint`. The predicate classifies the FILE,
so it is true for a helper in a test file as well as for the case the framework
invokes. Distinguishing those needs name patterns (`test_*`, `Test*`) and is a
separate question; claiming "entry point" here would overstate what the value
means.

What this buys, measured on a 12.4k-file Python/Go/TypeScript repository — the
whole reachability picture, in Cypher, with no path heuristics:

    lang    bucket                  n
    go      has_caller           1231
    go      framework_entrypoint    5
    go      test_code            2965
    go      UNEXPLAINED            38   (0.9%)
    python  has_caller           4495
    python  framework_entrypoint  183
    python  test_code            6477
    python  UNEXPLAINED           398   (3.4%)

The residual is what should stay non-zero: genuinely unreferenced code plus
dynamic dispatch static resolution cannot see. That is the dead-code signal, and
driving it to zero would destroy it.

MIGRATION: this changes the graph DDL, so SCHEMA_FINGERPRINT changes and every
existing index is rebuilt once on next analyze. Verified: the fingerprint guard
detects it and forces the re-analyze automatically — no user action, no stale
column.

Also adds `test/unit/node-column-parity.test.ts`. A node column lives in three
places that must agree — the DDL, the CSV header/row builder, and the explicit
COPY column list in `getCopyQuery`. Updating only the first two produced
`COPY failed for Function: Number of columns mismatch. Expected 8 but got 9`
at load time, after a full parse had already run. The new test pins every table's
COPY list to its DDL, in order, and asserts the parse is non-vacuous so a broken
regex cannot make it pass silently. `spring-bean-schema.test.ts` already guarded
this for Class alone; its expectation is updated and the new test generalizes it.

`npx tsc --noEmit` clean; 27 tests across the two schema-parity suites, 417 across
the schema/lbug/csv suites.
This commit is contained in:
Carter LaSalle 2026-08-06 18:37:17 -07:00 • committed by Carter LaSalle
parent d4ab43be96
commit da488a37a0
No known key found for this signature in database
GPG key ID: 7A1D23AECC0C1EFA
5 changed files with 98 additions and 6 deletions

View file

@ -18,6 +18,7 @@ import path from 'path';
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
import { KnowledgeGraph } from '../graph/types.js';
import { NodeTableName, RELATION_SCHEMA } from './schema.js';
import { isTestFilePath } from '../ingestion/utils/test-file-path.js';
import { VALID_NODE_TABLES, parseRelationSchemaPairs, RelPairRouter } from './rel-pair-routing.js';
import { parseTruthyEnv } from '../ingestion/utils/env.js';
import { SYMBOL_NODE_LABELS } from '../ingestion/utils/symbol-labels.js';
@ -439,7 +440,8 @@ export const streamAllCSVsToDisk = async (
'id,name,filePath,content',
);
const folderWriter = new BufferedCSVWriter(path.join(csvDir, 'folder.csv'), 'id,name,filePath');
const codeElementHeader = 'id,name,filePath,startLine,endLine,isExported,content,description';
const codeElementHeader =
'id,name,filePath,startLine,endLine,isExported,isTestCode,content,description';
const functionWriter = new BufferedCSVWriter(
path.join(csvDir, 'function.csv'),
codeElementHeader,
@ -453,7 +455,7 @@ export const streamAllCSVsToDisk = async (
codeElementHeader,
);
const methodHeader =
'id,name,filePath,startLine,endLine,isExported,content,description,parameterCount,returnType';
'id,name,filePath,startLine,endLine,isExported,isTestCode,content,description,parameterCount,returnType';
const methodWriter = new BufferedCSVWriter(path.join(csvDir, 'method.csv'), methodHeader);
const codeElemWriter = new BufferedCSVWriter(
path.join(csvDir, 'codeelement.csv'),
@ -615,6 +617,7 @@ export const streamAllCSVsToDisk = async (
escapeCSVNumber(node.properties.startLine, -1),
escapeCSVNumber(node.properties.endLine, -1),
node.properties.isExported ? 'true' : 'false',
isTestFilePath(node.properties.filePath) ? 'true' : 'false',
escapeCSVField(content),
escapeCSVField(formatFtsDescription(node.properties.description || '')),
escapeCSVNumber(node.properties.parameterCount, 0),
@ -687,6 +690,7 @@ export const streamAllCSVsToDisk = async (
escapeCSVNumber(node.properties.startLine, -1),
escapeCSVNumber(node.properties.endLine, -1),
node.properties.isExported ? 'true' : 'false',
isTestFilePath(node.properties.filePath) ? 'true' : 'false',
escapeCSVField(content),
escapeCSVField(formatFtsDescription(node.properties.description || '')),
];

View file

@ -1478,17 +1478,17 @@ export const getCopyQuery = (table: NodeTableName, filePath: string): string =>
return `COPY ${t}(id, filePath, startLine, endLine, text, callees, calleeIds) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'Class') {
return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description, frameworkAnnotations) FROM "${filePath}" ${COPY_CSV_OPTS}`;
return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, isTestCode, content, description, frameworkAnnotations) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'Method') {
return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description, parameterCount, returnType) FROM "${filePath}" ${COPY_CSV_OPTS}`;
return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, isTestCode, content, description, parameterCount, returnType) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'Property') {
return `COPY ${t}(id, name, filePath, startLine, endLine, content, description, declaredType) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
// TypeScript/JS code element tables have isExported; multi-language tables do not
if (TABLES_WITH_EXPORTED.has(table)) {
return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, isTestCode, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
// Multi-language tables (Struct, Impl, Trait, Macro, etc.)
return `COPY ${t}(id, name, filePath, startLine, endLine, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;

View file

@ -49,6 +49,7 @@ CREATE NODE TABLE Function (
startLine INT64,
endLine INT64,
isExported BOOLEAN,
isTestCode BOOLEAN,
content STRING,
description STRING,
PRIMARY KEY (id)
@ -62,6 +63,7 @@ CREATE NODE TABLE Class (
startLine INT64,
endLine INT64,
isExported BOOLEAN,
isTestCode BOOLEAN,
content STRING,
description STRING,
frameworkAnnotations STRING[],
@ -76,6 +78,7 @@ CREATE NODE TABLE Interface (
startLine INT64,
endLine INT64,
isExported BOOLEAN,
isTestCode BOOLEAN,
content STRING,
description STRING,
PRIMARY KEY (id)
@ -89,6 +92,7 @@ CREATE NODE TABLE Method (
startLine INT64,
endLine INT64,
isExported BOOLEAN,
isTestCode BOOLEAN,
content STRING,
description STRING,
parameterCount INT32,
@ -104,6 +108,7 @@ CREATE NODE TABLE CodeElement (
startLine INT64,
endLine INT64,
isExported BOOLEAN,
isTestCode BOOLEAN,
content STRING,
description STRING,
PRIMARY KEY (id)

View file

@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest';
import { NODE_SCHEMA_QUERIES } from '../../src/core/lbug/schema.js';
import { getCopyQuery } from '../../src/core/lbug/lbug-adapter.js';
import type { NodeTableName } from '../../src/core/lbug/schema.js';
/**
* A node column lives in THREE places that must agree:
*
* 1. the `CREATE NODE TABLE` DDL (`lbug/schema.ts`)
* 2. the CSV header + row builder (`lbug/csv-generator.ts`)
* 3. the explicit COPY column list (`lbug/lbug-adapter.ts` getCopyQuery)
*
* Adding `isTestCode` to only (1) and (2) produced
* `COPY failed for Function: Number of columns mismatch. Expected 8 but got 9`
* at load time, AFTER a full parse — the whole analyze wasted before the failure
* surfaced. The DDL and the COPY list are the pair that can disagree silently,
* so pin them to each other here.
*/
/** Parse column names out of a `CREATE NODE TABLE X (...)` statement. */
function ddlColumns(stmt: string): { table: string; columns: string[] } | null {
const m = /CREATE NODE TABLE (\w+) \(([\s\S]*)\)/.exec(stmt);
if (!m) return null;
const [, table, body] = m;
const columns = body
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.startsWith('PRIMARY KEY'))
.map((l) => l.replace(/,$/, '').split(/\s+/)[0])
.filter((c): c is string => Boolean(c));
return { table: table!, columns };
}
/** Parse column names out of a `COPY X(a, b, c) FROM ...` statement. */
function copyColumns(query: string): string[] | null {
const m = /COPY\s+`?\w+`?\(([^)]*)\)/.exec(query);
if (!m) return null;
return m[1]!.split(',').map((c) => c.trim());
}
const ddl = NODE_SCHEMA_QUERIES.map(ddlColumns).filter(
(d): d is { table: string; columns: string[] } => d !== null,
);
describe('node DDL and COPY column lists agree', () => {
it('parsed the DDL at all (guard against a regex that silently matches nothing)', () => {
expect(ddl.length).toBeGreaterThan(5);
const fn = ddl.find((d) => d.table === 'Function');
expect(fn?.columns).toContain('id');
expect(fn?.columns).toContain('isTestCode');
});
for (const { table, columns } of ddl) {
it(`${table}: COPY column list matches its DDL, in order`, () => {
const query = getCopyQuery(table as NodeTableName, '/tmp/x.csv');
const copied = copyColumns(query);
// A table whose COPY omits an explicit list is out of scope — the
// positional form cannot drift from the DDL.
if (copied === null) return;
expect(copied).toEqual(columns);
});
}
});
describe('isTestCode is declared everywhere a definition table needs it', () => {
// The five tables carrying `isExported` are the definition-bearing ones; a
// symbol in any of them can live in test code, so all five need the flag for
// the reachability query to be answerable without path heuristics.
const EXPECTED = ['Function', 'Class', 'Interface', 'Method', 'CodeElement'];
for (const table of EXPECTED) {
it(`${table} declares isTestCode in its DDL`, () => {
const entry = ddl.find((d) => d.table === table);
expect(entry, `${table} not found in NODE_SCHEMA_QUERIES`).toBeDefined();
expect(entry!.columns).toContain('isTestCode');
});
it(`${table} COPY includes isTestCode`, () => {
const copied = copyColumns(getCopyQuery(table as NodeTableName, '/tmp/x.csv'));
expect(copied).toContain('isTestCode');
});
}
});

View file

@ -15,7 +15,7 @@ describe('Spring Bean Class persistence schema', () => {
expect(CLASS_SCHEMA).toContain('frameworkAnnotations STRING[]');
expect(getCopyQuery('Class', '/tmp/class.csv')).toContain(
'(id, name, filePath, startLine, endLine, isExported, content, description, frameworkAnnotations)',
'(id, name, filePath, startLine, endLine, isExported, isTestCode, content, description, frameworkAnnotations)',
);
});