GitNexus/gitnexus/test/integration/lbug-query-importers-batch.test.ts
Gergő Magyar 51fb64c976
fix(swift): model Swift modules like the compiler (nested packages, Xcode targets, linear visibility) (#3387)
* fix(swift): discover nested Package.swift manifests for module grouping

A Swift monorepo laid out as Core/<pkg>/Package.swift has no root manifest
and no root Sources/, so every Swift file fell into one __default__ module.
The Swift resolver now walks the repo (bounded, skipping dot, ignored, and
Xcode bundle directories) and adds each nested package's targets, keyed by
repo-relative directory and ordered deepest-first so first-match grouping
picks the most specific target. Import resolution keeps the root view.

Refs #3355

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(swift): bound implicit IMPORTS edges by a total budget

Implicit same-module IMPORTS are n*(n-1) edges per module, and the graph
keeps every relationship in one Map capped by V8 at 2^24 entries. Emission
now fills a 4M-edge budget smallest module first and skips, with a warning,
any module that does not fit, so no module layout can crash analyze.

Refs #3355

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(swift): skip pairwise sibling passes for oversized modules

The target-siblings and sibling-type-bindings passes copy every file's
declarations into every other file of a module, so heap grows as n^2:
about 3.8 GB at 1,000 files with 15 defs each, about 15 GB at 2,000.
Modules over 1,000 files now skip both passes with a warning and resolve
through the global name fallback. GITNEXUS_SWIFT_MAX_MODULE_FILES changes
the ceiling.

Refs #3355

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(swift): cover nested SwiftPM packages end to end

A fixture with three nested Package.swift manifests (two declaring a target
named Net) and no root manifest. Each target gets its own implicit IMPORTS,
none cross packages, and Config() resolves to the caller's own package.
The Swift capture golden and scope-capture fingerprint grow with the new
fixture corpus only.

Refs #3355

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(review): apply review findings

- Rebase nested target paths with the existing Zig path helpers, which also
  reject Windows drive paths and paths that resolve to the repo root (whose
  empty prefix would group every file into one target).
- Document GITNEXUS_SWIFT_MAX_MODULE_FILES in the README env table.
- State that skipped modules resolve through lower-confidence fallback edges,
  why nested-type fragments still run for oversized modules, and fix a stale
  loader name in the target-grouping header.

Refs #3355

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(swift): cover every skipped Xcode bundle suffix in the package walk

Refs #3355

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(swift): model modules the way the compiler does

Replace the caps from the first round with representations that stay
linear, and derive module identity from the same sources the compiler uses.

- Same-module visibility is one File -> Module IMPORTS edge per file
  (reason `module-membership`) instead of an edge per ordered file pair.
  Incremental importer expansion treats files sharing a Module hub as
  importers of each other. The 4M edge budget is gone.
- Sibling declarations and type bindings live in one shared table per
  module in the namespace channel C# uses since #1871, instead of being
  copied into every file. The 1,000-file ceiling and
  GITNEXUS_SWIFT_MAX_MODULE_FILES are gone.
- Modules come from root and nested SwiftPM manifests (Sources, Source,
  src, srcs; plugins under Plugins; the newest Package@swift-X.Y.swift)
  and from Xcode native targets in project.pbxproj, including Xcode 16
  synchronized folders. Target paths are matched from the repo root, so
  a vendored copy of the same layout is no longer grouped into a root
  target (this reverses #2931's floating match).
- `import X` resolves to the modules named X instead of any folder named
  X. A name no module carries is external when every manifest and
  project was read completely.
- The global-name-fallback veto uses the same membership, so test
  targets, custom-path targets and Xcode targets have module identity.

Refs #3355

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(bench): move the Swift package bench to module grouping

The bench still imported the removed groupSwiftFilesBySpmTarget. Its
first-wins probe keeps its meaning under root-anchored grouping: the
clash file sits under Sources/Mod0, and the Sources/Mod1 further down its
path is a vendored copy.

Plugins are now non-importable modules under Plugins/, so parse_targets
counts importable source targets (still 3) and parse_binary_skipped also
checks that the plugin is recorded that way. Baseline values unchanged;
the notes say why the definitions moved.

Refs #3355

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(swift): match compiler module names, manifests, and target filters

- Module names follow the compiler's c99 mangling (`my-lib` imports as
  `my_lib`); Xcode targets use a literal PRODUCT_MODULE_NAME or
  PRODUCT_NAME when the project sets one.
- Package manifests are one-file modules, as SwiftPM compiles them. Files
  outside every target are one-file modules once every manifest and
  project was read; otherwise they keep the shared __default__ module.
- Xcode 16 synchronized-folder exceptions add a file to a target that
  does not list the folder, and remove it from one that does.
- SwiftPM `sources:` / `exclude:` narrow a target; a computed list marks
  the manifest unreadable.
- The default target folder is chosen once per package, as SwiftPM does,
  instead of per target.

Refs #3355

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Address PR review feedback (#3387)

- Inferred Swift folders keep SwiftPM's first predefined parent when a
  target name repeats (Sources before srcs).
- The pbxproj parser rejects a \U escape without four hex digits instead
  of decoding garbage, so the project reads as incomplete.
- Extension owners are stamped in every Xcode membership of a shared file.
- A plugin name never reaches a plugin: neither the fallback veto nor the
  folder-index fallback treats a known non-importable module as imported.
- A file an Xcode target compiles keeps that membership even when it also
  lies under a SwiftPM target directory.
- The workspace scan bounds the queue, not only the directories read.
- Integration tests require each module hub to exist before comparing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 06:24:35 +01:00

206 lines
9.2 KiB
TypeScript

/**
* Integration coverage for `queryImportersBatch` — the batched importer-BFS
* read introduced for #2409 (one `IN [...]` IMPORTS query per 200-path chunk
* per BFS depth, instead of one lock-taking round-trip per frontier file).
*
* Pins the contract the incremental writeback depends on:
*
* - a >1-chunk target set is answered by ONE call (two queries) returning
* the full importer set, SORTED and DEDUPED across the chunk boundary
* - an importer of multiple targets inside the SAME chunk appears once
* - quoted-path targets match (list-literal escaping, not injection)
* - empty targets → `[]` (zero queries)
* - failure branch (tri-review 4669518496 P2-5): a failing chunk query is
* degrade-don't-fail — the result just shrinks — but no longer silent:
* `onChunkFailure` fires once per dropped chunk with the engine error.
* Empirically provoked with `DROP TABLE CodeRelation` (supported by
* @ladybugdb/core 0.18.0), which poisons that block's DB — hence the
* DEDICATED trailing `withTestLbugDB` block.
*/
import { describe, it, expect, vi } from 'vitest';
import path from 'path';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import { buildTestGraph, type TestNodeInput, type TestRelInput } from '../helpers/test-graph.js';
import { DELETE_FILES_CHUNK_SIZE } from '../../src/core/lbug/lbug-adapter.js';
const TARGET_COUNT = DELETE_FILES_CHUNK_SIZE + 1; // 201 — crosses the chunk boundary (2 queries)
const QUOTED_TARGET = "src/targets/we'ird.ts";
// Importer names chosen so lexicographic order ≠ discovery order: the
// second chunk's exclusive importer (`aa-…`) must sort FIRST in the final
// result even though its chunk is queried LAST.
const SECOND_CHUNK_IMPORTER = 'src/importers/aa-second-chunk.ts';
const SAME_CHUNK_IMPORTER = 'src/importers/mm-same-chunk.ts';
const QUOTED_IMPORTER = 'src/importers/qq-quoted.ts';
const CROSS_CHUNK_IMPORTER = 'src/importers/zz-cross-chunk.ts';
const targetPath = (i: number): string => `src/targets/t-${String(i).padStart(4, '0')}.ts`;
/** Index 0 is the quoted path; the rest are plain. Length = TARGET_COUNT. */
function buildTargetList(): string[] {
const targets: string[] = [QUOTED_TARGET];
for (let i = 1; i < TARGET_COUNT; i++) targets.push(targetPath(i));
return targets;
}
function buildFixtureGraph() {
const nodes: TestNodeInput[] = [];
const rels: TestRelInput[] = [];
for (const fp of buildTargetList()) {
nodes.push({ id: `File:${fp}`, label: 'File', name: path.basename(fp), filePath: fp });
}
for (const fp of [
SECOND_CHUNK_IMPORTER,
SAME_CHUNK_IMPORTER,
QUOTED_IMPORTER,
CROSS_CHUNK_IMPORTER,
]) {
nodes.push({ id: `File:${fp}`, label: 'File', name: path.basename(fp), filePath: fp });
}
const imports = (importer: string, target: string): void => {
rels.push({ sourceId: `File:${importer}`, targetId: `File:${target}`, type: 'IMPORTS' });
};
// Chunk 1 targets (list indices 0-199): the quoted path, t-0001…t-0199.
// Chunk 2 target (index 200): t-0200.
imports(SECOND_CHUNK_IMPORTER, targetPath(TARGET_COUNT - 1)); // chunk 2 only
imports(SAME_CHUNK_IMPORTER, targetPath(2)); // both in chunk 1 —
imports(SAME_CHUNK_IMPORTER, targetPath(3)); // same-chunk dedup
imports(QUOTED_IMPORTER, QUOTED_TARGET); // quoted-path escaping
imports(CROSS_CHUNK_IMPORTER, targetPath(1)); // chunk 1 —
imports(CROSS_CHUNK_IMPORTER, targetPath(TARGET_COUNT - 1)); // cross-chunk dedup
return buildTestGraph(nodes, rels);
}
withTestLbugDB('query-importers-batch', (handle) => {
describe('queryImportersBatch (batched importer BFS, #2409)', () => {
it('returns the full sorted, deduped importer set across the chunk boundary, dedups within a chunk, matches quoted targets, and no-ops on empty input', async () => {
const { loadGraphToLbug, queryImportersBatch } =
await import('../../src/core/lbug/lbug-adapter.js');
await loadGraphToLbug(buildFixtureGraph(), '/tmp/repo', path.dirname(handle.dbPath));
// One call over all 201 targets → two chunked queries. The result is
// the union of both chunks, deduped (CROSS_CHUNK_IMPORTER matched in
// BOTH chunks, appears once) and sorted (SECOND_CHUNK_IMPORTER was
// discovered by the LAST query yet sorts first).
const onChunkFailure = vi.fn();
const importers = await queryImportersBatch(buildTargetList(), { onChunkFailure });
expect(importers).toEqual([
SECOND_CHUNK_IMPORTER,
SAME_CHUNK_IMPORTER,
QUOTED_IMPORTER,
CROSS_CHUNK_IMPORTER,
]);
expect(onChunkFailure).not.toHaveBeenCalled();
// Multi-target dedup WITHIN a single chunk: one importer of two
// targets in the same IN-list appears once.
await expect(queryImportersBatch([targetPath(2), targetPath(3)])).resolves.toEqual([
SAME_CHUNK_IMPORTER,
]);
// Quoted-path target: the list literal is escaped, not injected.
await expect(queryImportersBatch([QUOTED_TARGET])).resolves.toEqual([QUOTED_IMPORTER]);
// Empty targets → [] without touching the DB (zero chunks).
await expect(queryImportersBatch([])).resolves.toEqual([]);
}, 120_000);
});
});
withTestLbugDB('query-importers-batch-module-members', (handle) => {
describe('queryImportersBatch — module co-members (#3355)', () => {
it('returns files sharing a module-membership hub, and ignores other File->Module edges', async () => {
const { loadGraphToLbug, queryImportersBatch } =
await import('../../src/core/lbug/lbug-adapter.js');
const { MODULE_MEMBERSHIP_REASON } = await import('../../src/core/graph/edge-reasons.js');
const file = (fp: string): TestNodeInput => ({
id: `File:${fp}`,
label: 'File',
name: path.basename(fp),
filePath: fp,
});
const nodes: TestNodeInput[] = [
file('Net/A.swift'),
file('Net/B.swift'),
file('Net/C.swift'),
file('Login/D.swift'),
file('jobs/one.jcl'),
file('jobs/two.jcl'),
{ id: 'Module:swift:Net', label: 'Module', name: 'Net', filePath: 'Net' },
{ id: 'Module:swift:Login', label: 'Module', name: 'Login', filePath: 'Login' },
{ id: 'Module:proc', label: 'Module', name: 'PROC', filePath: 'jobs/proc.jcl' },
];
const member = (fp: string, moduleId: string): TestRelInput => ({
sourceId: `File:${fp}`,
targetId: moduleId,
type: 'IMPORTS',
reason: MODULE_MEMBERSHIP_REASON,
});
const rels: TestRelInput[] = [
member('Net/A.swift', 'Module:swift:Net'),
member('Net/B.swift', 'Module:swift:Net'),
member('Net/C.swift', 'Module:swift:Net'),
member('Login/D.swift', 'Module:swift:Login'),
// Two JCL jobs including the same PROC are not co-dependent.
{
sourceId: 'File:jobs/one.jcl',
targetId: 'Module:proc',
type: 'IMPORTS',
reason: 'jcl-include',
},
{
sourceId: 'File:jobs/two.jcl',
targetId: 'Module:proc',
type: 'IMPORTS',
reason: 'jcl-include',
},
];
await loadGraphToLbug(buildTestGraph(nodes, rels), '/tmp/repo', path.dirname(handle.dbPath));
await expect(queryImportersBatch(['Net/B.swift'])).resolves.toEqual([
'Net/A.swift',
'Net/C.swift',
]);
await expect(queryImportersBatch(['Login/D.swift'])).resolves.toEqual([]);
await expect(queryImportersBatch(['jobs/one.jcl'])).resolves.toEqual([]);
}, 120_000);
});
});
// Dedicated trailing block: the DROP below poisons this DB for any further
// CodeRelation query, so no other test may share it.
withTestLbugDB('query-importers-batch-failure', () => {
describe('queryImportersBatch failure branch (tri-review 4669518496 P2-5)', () => {
it('degrades to [] and reports each dropped chunk via onChunkFailure with the engine error', async () => {
const { executeQuery, queryImportersBatch } =
await import('../../src/core/lbug/lbug-adapter.js');
// Real engine failure, not a mock: DROP TABLE is supported by
// @ladybugdb/core 0.18.0, and every subsequent MATCH on the table
// fails with `Binder exception: Table CodeRelation does not exist.`
await executeQuery('DROP TABLE CodeRelation');
const failures: Array<{ chunkIndex: number; chunkSize: number; err: unknown }> = [];
const importers = await queryImportersBatch(buildTargetList(), {
onChunkFailure: (chunkIndex, chunkSize, err) =>
failures.push({ chunkIndex, chunkSize, err }),
});
// Degrade-don't-fail: no throw, empty expansion…
expect(importers).toEqual([]);
// …but LOUD: one callback per dropped chunk (200 + 1 paths).
expect(failures.map(({ chunkIndex, chunkSize }) => ({ chunkIndex, chunkSize }))).toEqual([
{ chunkIndex: 0, chunkSize: DELETE_FILES_CHUNK_SIZE },
{ chunkIndex: 1, chunkSize: 1 },
]);
expect(
failures.map((f) => String((f.err as { message?: unknown }).message ?? f.err)),
).toEqual([
expect.stringContaining('Table CodeRelation does not exist'),
expect.stringContaining('Table CodeRelation does not exist'),
]);
}, 120_000);
});
});