diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index 1aba2201f..7c4cc6ced 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -305,23 +305,15 @@ const SCOPE_BRIDGE_TARGET_LABELS = [ * the PDG substrate — the surface no single label predicate describes, so it * stays hand-declared. The scope-resolution surface below is generated instead. * - * Because it is hand-declared, this block is the half that can still go stale — - * and it did. #2789 was fixed by declaring one pair, but with that fix applied - * `analyze` still aborted on this repo's own `test/fixtures/lang-resolution`: - * - * - COBOL (`cobol-processor.ts`) mints a program as `Module`, a SECTION as - * `Namespace`, an FD/record as `Record`, a working-storage item as `Property` - * and an EXEC/dynamic-call site as `CodeElement`, then wires them with - * CONTAINS/CALLS/ACCESSES. - * - Vue (`vue-sfc-extractor.ts`) emits BINDS_EVENT_HANDLER from a handler - * `Function` to the child component's `File` — the only edge in the graph - * whose TARGET is a `File`. - * - * `CodeElement`, `Namespace`, `Record` and `File` are in neither scope-bridge - * label set, so the generated block cannot reach those pairs and - * `schema-pair-coverage.test.ts`'s predicate-derived check cannot miss them - * either. `structural-pair-coverage.test.ts` covers this block from a corpus - * instead — that is the guard, not this comment. + * Being hand-declared, this is the half that can still go stale — and it did: + * after #2789 declared its one pair, `analyze` still aborted on this repo's own + * `test/fixtures/lang-resolution` (COBOL containment, and the Vue + * BINDS_EVENT_HANDLER edge from `languages/vue/scope-resolver.ts`, the only + * edge whose target is a `File`). Those endpoint labels — `CodeElement`, + * `Namespace`, `Record`, `File` — are in neither scope-bridge set, so no + * predicate-derived check can reach them. + * `test/integration/structural-pair-coverage.test.ts` guards this block from a + * corpus instead; that is the guard, not this comment. */ const STRUCTURAL_PAIR_DDL = ` FROM File TO File, FROM File TO Folder, diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 4dfe0e5d5..e38d8ad00 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -695,8 +695,10 @@ export interface RepoMeta { * versions of this same fix; both left the rest of the cross product undeclared, * so `analyze` kept aborting at `assertDeclaredPair` on the next codebase with a * different edge shape (`Class→Variable` on Java static/field initializers was - * the report). A pre-v35 database physically lacks these pairs, so force a full - * re-analyze. + * the report). Plus 10 hand-declared structural pairs the generated block cannot + * reach — COBOL containment and the Vue handler→component edge (#2789), whose + * endpoint labels are in neither scope-bridge set. A pre-v35 database physically + * lacks all of these, so force a full re-analyze. */ export const INCREMENTAL_SCHEMA_VERSION = 35; diff --git a/gitnexus/test/integration/structural-pair-coverage.test.ts b/gitnexus/test/integration/structural-pair-coverage.test.ts index 917a44671..46d6d6db4 100644 --- a/gitnexus/test/integration/structural-pair-coverage.test.ts +++ b/gitnexus/test/integration/structural-pair-coverage.test.ts @@ -1,117 +1,75 @@ /** - * Corpus-derived coverage for the HAND-DECLARED half of `RELATION_SCHEMA` - * (#2789 follow-up to #2792). + * Corpus-derived coverage for the HAND-DECLARED half of `RELATION_SCHEMA`. * - * `schema-pair-coverage.test.ts` derives its requirement from `LINKABLE_LABELS` - * and `CALL_TARGET_TYPES`, so it covers exactly the pairs the scope-resolution - * bridge can emit — the half that is now GENERATED and therefore cannot go - * stale. It is structurally blind to everything else: the containment, - * inheritance and route emitters run outside the bridge and put labels on - * endpoints (`CodeElement`, `Namespace`, `Record`, `File`) that are in neither - * set, so no pair they produce can ever appear in that test's requirement. + * `test/unit/schema-pair-coverage.test.ts` derives its requirement from + * `LINKABLE_LABELS` and `CALL_TARGET_TYPES`, so it covers the generated + * scope-bridge half and is structurally blind to everything else: the + * containment and inheritance emitters put labels on endpoints (`CodeElement`, + * `Namespace`, `Record`, `File`) that are in neither set. No predicate + * describes that surface, so this asks the emitters directly — run the real + * pipeline and require every FROM/TO pair it produces to be declared. * - * That blind spot is not hypothetical. With #2791's `Function→Variable` fix - * applied, `analyze` still aborted on this repo's own fixtures with - * `Module→Property is not declared`; a full sweep found 13 undeclared pairs - * across COBOL, Vue and PHP. A predicate cannot describe that surface — only a - * corpus can — so this test asks the emitters directly: run the real pipeline - * over fixtures that exercise the non-bridge paths, and require every FROM/TO - * pair they produce to be declared. - * - * Failing here means adding the pairs to `STRUCTURAL_PAIR_DDL`, not relaxing - * the assertion: an undeclared pair does not degrade, it throws in - * `assertDeclaredPair` and takes down `analyze` for every user of that language. - * - * Coverage is bounded by the fixtures listed in `NON_BRIDGE_CORPUS`. It is a - * sample, not a proof — a language whose fixture is absent is unguarded, so a - * new structural emitter should land with an entry here. + * Coverage is bounded by `NON_BRIDGE_CORPUS`: a sample, not a proof. A + * language whose fixture is absent is unguarded, so a new structural emitter + * should land with an entry here. */ import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; import path from 'path'; -import { fileURLToPath } from 'url'; import { NODE_TABLES } from 'gitnexus-shared'; -import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import { FIXTURES, runPipelineFromRepo } from './resolvers/helpers.js'; import { RELATION_SCHEMA } from '../../src/core/lbug/schema.js'; import { getNodeLabel, parseRelationSchemaPairs } from '../../src/core/lbug/rel-pair-routing.js'; -import { DIST_WORKER_URL } from '../helpers/worker-parse.js'; +import { DIST_WORKER_URL, distWorkerExists } from '../helpers/worker-parse.js'; vi.setConfig({ testTimeout: 180_000 }); -const here = path.dirname(fileURLToPath(import.meta.url)); -const FIXTURE_ROOT = path.resolve(here, '..', 'fixtures', 'lang-resolution'); +const describeIfWorkerBuilt = distWorkerExists() ? describe : describe.skip; /** - * Fixtures whose graphs are built by emitters OUTSIDE the scope-resolution - * bridge, i.e. the ones the predicate-derived test cannot reach: - * - * - `cobol-app` — `cobol-processor.ts` mints `Module` / `Namespace` / `Record` / - * `Property` / `CodeElement` and wires them with CONTAINS, CALLS and ACCESSES. - * - `vue-basic` — `vue-sfc-extractor.ts` emits BINDS_EVENT_HANDLER, the only - * edge in the graph whose target is a `File`. - * - `php-transitive-traits` — trait-to-trait IMPLEMENTS, from the inheritance - * pass rather than the call bridge. + * Fixtures whose graphs are built OUTSIDE the scope-resolution bridge: + * `cobol-processor.ts` (CONTAINS/CALLS/ACCESSES over Module / Namespace / + * Record / Property / CodeElement), `languages/vue/scope-resolver.ts` + * (BINDS_EVENT_HANDLER, the only edge whose target is a `File`), and the + * inheritance pass (trait-to-trait IMPLEMENTS). */ const NON_BRIDGE_CORPUS = ['cobol-app', 'vue-basic', 'php-transitive-traits'] as const; const DECLARED = parseRelationSchemaPairs(RELATION_SCHEMA); const VALID_TABLES = new Set(NODE_TABLES); -// Three pipeline runs mean three cold worker-pool startups, so this file has 3x -// the usual exposure to the 5s default ready budget — enough to fail on a loaded -// CI runner for reasons that have nothing to do with the schema. Raise it here so -// a failure means an undeclared pair and never a slow host (#1741). +// Cold worker-pool startups otherwise flake against the 5s default ready budget +// on a loaded runner, failing for reasons unrelated to the schema (#1741). beforeAll(() => vi.stubEnv('GITNEXUS_WORKER_READY_TIMEOUT_MS', '60000')); afterAll(() => vi.unstubAllEnvs()); /** - * The undeclared FROM/TO pairs a fixture emits, as `From|To`, deduped and - * sorted so a failure is stable and names the pair to declare. + * The undeclared FROM/TO pairs a fixture emits, deduped and sorted so a failure + * is stable and names the pair to declare. * * Mirrors `RelPairRouter.route`: derive both labels with the router's own - * `getNodeLabel`, drop any edge whose endpoint is not a real node table (the - * router counts those as `skipped` and never routes them), then keep what is - * left undeclared. Using the router's function rather than the node's `label` - * field is deliberate — the router derives from the id, and that derivation is - * what `assertDeclaredPair` actually sees. + * `getNodeLabel` (from the id, which is what `assertDeclaredPair` actually + * sees, not the node's `label` field) and drop any edge whose endpoint is not a + * real node table, exactly as the router's `skipped` branch does. */ const undeclaredPairsIn = async (fixture: string): Promise => { - const result = await runPipelineFromRepo(path.join(FIXTURE_ROOT, fixture), () => {}, { + const result = await runPipelineFromRepo(path.join(FIXTURES, fixture), () => {}, { workerPoolSize: 1, workerUrlForTest: DIST_WORKER_URL, }); - const undeclared = new Set(); + const emitted = new Set(); for (const rel of result.graph.iterRelationships()) { - const from = getNodeLabel(rel.sourceId); - const to = getNodeLabel(rel.targetId); - const routable = VALID_TABLES.has(from) && VALID_TABLES.has(to); - const pairKey = `${from}|${to}`; - const missing = routable && !DECLARED.has(pairKey); - undeclared.add(missing ? pairKey : ''); + emitted.add(`${getNodeLabel(rel.sourceId)}|${getNodeLabel(rel.targetId)}`); } - undeclared.delete(''); - return [...undeclared].sort(); + return [...emitted] + .filter((pair) => pair.split('|').every((label) => VALID_TABLES.has(label))) + .filter((pair) => !DECLARED.has(pair)) + .sort(); }; -describe('RELATION_SCHEMA covers the non-bridge emitters', () => { - it.each(NON_BRIDGE_CORPUS)('%s emits only declared FROM/TO pairs', async (fixture) => { +describeIfWorkerBuilt('RELATION_SCHEMA covers the non-bridge emitters', () => { + // Concurrent because the cases share nothing but cost ~12s each serially, + // almost all of it worker spawn and grammar load, which overlaps well. + it.concurrent.each(NON_BRIDGE_CORPUS)('%s emits only declared FROM/TO pairs', async (fixture) => { expect(await undeclaredPairsIn(fixture)).toEqual([]); }); - - it('pins the pairs whose absence aborted analyze after #2791', () => { - // COBOL containment/call/access plus the Vue handler→component edge. Listed - // explicitly so deleting a fixture above cannot silently drop the guard. - const regressions = [ - 'CodeElement|CodeElement', - 'CodeElement|Module', - 'CodeElement|Property', - 'CodeElement|Record', - 'Function|File', - 'Module|CodeElement', - 'Module|Namespace', - 'Module|Record', - 'Namespace|Function', - 'Record|Record', - ]; - expect(regressions.filter((pair) => !DECLARED.has(pair))).toEqual([]); - }); }); diff --git a/gitnexus/test/unit/schema-pair-coverage.test.ts b/gitnexus/test/unit/schema-pair-coverage.test.ts index ff16a5edb..b5b902f9c 100644 --- a/gitnexus/test/unit/schema-pair-coverage.test.ts +++ b/gitnexus/test/unit/schema-pair-coverage.test.ts @@ -73,4 +73,25 @@ describe('RELATION_SCHEMA pair coverage', () => { [], ); }); + + it('declares the non-bridge structural pairs (#2789)', () => { + // COBOL containment/call/access plus the Vue handler→component edge. These + // are outside both label sets above, so the derived requirement cannot + // reach them; `test/integration/structural-pair-coverage.test.ts` guards + // them from a corpus. Pinned here too so deleting a fixture there cannot + // silently drop the guard, and so the cheap check does not need a build. + const structural = [ + 'CodeElement|CodeElement', + 'CodeElement|Module', + 'CodeElement|Property', + 'CodeElement|Record', + 'Function|File', + 'Module|CodeElement', + 'Module|Namespace', + 'Module|Record', + 'Namespace|Function', + 'Record|Record', + ]; + expect(structural.filter((pair) => !declared.has(pair))).toEqual([]); + }); });