refactor(test): simplify the structural pair coverage guard

Cleanup pass over the previous commit. No behaviour change to the schema.

- reuse `FIXTURES` and `runPipelineFromRepo` from resolvers/helpers.ts instead
  of re-deriving the fixture root and importing pipeline.js directly
- gate on `distWorkerExists()` like every other integration test that passes
  `workerUrlForTest`, so a missing dist skips rather than fails
- run the three fixtures with `it.concurrent.each`; they share nothing and the
  cost is almost all worker spawn plus grammar load, which overlaps well
  (tests phase 21-24s -> 5.6s measured)
- replace the sentinel-in-a-Set filter with a plain `.filter()` chain, matching
  the sibling unit test, and move the declared/table lookups off the per-edge
  path onto the deduped set
- move the pure string pin out of the integration tier into
  schema-pair-coverage.test.ts, where the identical construct already lives, so
  it needs no build and survives fixture deletion
- trim the schema and test prose that restated the code, and correct the
  BINDS_EVENT_HANDLER attribution: it is emitted by
  languages/vue/scope-resolver.ts, not vue-sfc-extractor.ts
- amend the v35 comment to mention the 10 structural pairs it now also stamps

Still mutation-checked: dropping `FROM Function TO File` now fails both the
integration sweep and the unit pin with exactly Function|File. 89 tests green.
This commit is contained in:
Gergo Magyar 2026-08-02 09:05:48 +00:00
parent c17fd82d78
commit da8eeb154c
4 changed files with 71 additions and 98 deletions

View file

@ -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,

View file

@ -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 handlercomponent 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;

View file

@ -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<string>(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<string[]> => {
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<string>();
const emitted = new Set<string>();
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([]);
});
});

View file

@ -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([]);
});
});