diff --git a/.github/workflows/ci-scope-parity.yml b/.github/workflows/ci-scope-parity.yml index 8e2926ba8..039438a15 100644 --- a/.github/workflows/ci-scope-parity.yml +++ b/.github/workflows/ci-scope-parity.yml @@ -24,6 +24,19 @@ name: Scope Resolution Parity # When the set is empty (e.g. mid-Ring-3 for every language), the parity # matrix is skipped and the workflow reports success — no-op until a # language is explicitly claimed migrated. +# +# ── Consolidation (chore/vitest-speed-strategy) ──────────────────────── +# Previously each language was a separate GitHub Actions matrix job, +# meaning N languages × 1 checkout+install+build per shard. The build +# cost dwarfed the test cost (~5 min setup for ~15 sec test execution). +# +# Now a single job runs `scripts/run-parity.ts` which loops through all +# migrated languages sequentially (2 vitest invocations per language: +# legacy + registry-primary). All failures are collected and reported +# at the end (equivalent to the old fail-fast: false behavior). +# +# Adding a new language to MIGRATED_LANGUAGES still requires no workflow +# edit — the script auto-discovers the set at runtime. on: workflow_call: @@ -37,7 +50,6 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 outputs: - languages: ${{ steps.read.outputs.languages }} has-any: ${{ steps.read.outputs.has-any }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -49,65 +61,27 @@ jobs: working-directory: gitnexus run: | set -euo pipefail - # `tsx` evaluates the TS source directly (no build step), imports - # the exported `Set`, and emits a GH-Actions-friendly JSON matrix. LANGS=$(npx tsx scripts/ci-list-migrated-languages.ts) COUNT=$(printf '%s' "$LANGS" | jq 'length') HAS_ANY="false" if [[ "$COUNT" -gt 0 ]]; then HAS_ANY="true"; fi - echo "languages=$LANGS" >> "$GITHUB_OUTPUT" echo "has-any=$HAS_ANY" >> "$GITHUB_OUTPUT" echo "Discovered $COUNT migrated language(s): $LANGS" - echo "Parity matrix will run: $HAS_ANY" + echo "Parity will run: $HAS_ANY" parity: - name: ${{ matrix.lang.slug }} parity + name: scope-resolution parity needs: discover if: needs.discover.outputs.has-any == 'true' runs-on: ubuntu-latest - timeout-minutes: 20 - strategy: - # One language failing must not abort the others — we want the full - # parity matrix result on a single CI run so a reviewer sees every - # regression at once rather than one-at-a-time. - fail-fast: false - matrix: - lang: ${{ fromJSON(needs.discover.outputs.languages) }} + timeout-minutes: 30 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-gitnexus with: build: 'true' - - name: Verify resolver test file exists + - name: Run parity for all migrated languages shell: bash working-directory: gitnexus - run: | - set -euo pipefail - TEST_FILE="test/integration/resolvers/${{ matrix.lang.slug }}.test.ts" - if [[ ! -f "$TEST_FILE" ]]; then - echo "::error title=Missing resolver test::\ - Expected $TEST_FILE for '${{ matrix.lang.slug }}' (listed in \ - MIGRATED_LANGUAGES). Either fix the slug or add the test file \ - before listing this language as migrated." - exit 1 - fi - - - name: Resolver tests — legacy DAG (REGISTRY_PRIMARY_${{ matrix.lang.envvar }}=0) - shell: bash - working-directory: gitnexus - env: - FLAG_NAME: REGISTRY_PRIMARY_${{ matrix.lang.envvar }} - # Explicitly force the flag to `0` even though it also defaults to - # `MIGRATED_LANGUAGES.has(lang)` — once a language is in the set, - # the default flips to registry-primary, so an unset env var would - # silently re-run the same path as step #2. `env FOO=0 cmd` spawns - # `cmd` with the override scoped to just this invocation. - run: env "$FLAG_NAME=0" npx vitest run "test/integration/resolvers/${{ matrix.lang.slug }}.test.ts" - - - name: Resolver tests — registry-primary (REGISTRY_PRIMARY_${{ matrix.lang.envvar }}=1) - shell: bash - working-directory: gitnexus - env: - FLAG_NAME: REGISTRY_PRIMARY_${{ matrix.lang.envvar }} - run: env "$FLAG_NAME=1" npx vitest run "test/integration/resolvers/${{ matrix.lang.slug }}.test.ts" + run: npx tsx scripts/run-parity.ts diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index f354e626b..c34d0f6ec 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -59,21 +59,25 @@ jobs: gitnexus-web/web-test-results.json retention-days: 5 + # Platform-sensitive subset only — the full suite runs on Ubuntu above. + # See gitnexus/scripts/cross-platform-tests.ts for the file list and + # rationale for each included test. cross-platform: - name: ${{ matrix.os }} + name: ${{ matrix.os }} (platform-sensitive) strategy: fail-fast: false matrix: # Ubuntu already covered by the coverage job above os: [windows-latest, macos-latest] runs-on: ${{ matrix.os }} - timeout-minutes: 25 + timeout-minutes: 20 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-gitnexus with: build: 'true' - - run: npx vitest run + - name: Run platform-sensitive tests + run: npx tsx scripts/run-cross-platform.ts working-directory: gitnexus # End-to-end smoke test for the #1728 packaging fix: pack the published diff --git a/TESTING.md b/TESTING.md index cf481d32b..e69a4b8e9 100644 --- a/TESTING.md +++ b/TESTING.md @@ -10,32 +10,37 @@ How we structure tests and which commands to run locally and in CI. | Web UI | `gitnexus-web/`| Vitest | Unit/component tests | | Web UI E2E | `gitnexus-web/`| Playwright | Run when changing UI flows | -## Commands (local) +## Test lanes -From repository root, unless noted: +### `gitnexus/` commands -**`gitnexus` (CLI / library)** +From `gitnexus/`: + +| Command | What it runs | When to use | +| ------------------------ | ---------------------------------------------------- | ------------------------------- | +| `npm test` | Full suite (all 3 vitest projects) | Before opening a PR | +| `npm run test:unit` | Unit tests only (`test/unit/`) | Tight development loop | +| `npm run test:integration` | Integration tests (`test/integration/`) | After changing pipelines, DB, workers | +| `npm run test:coverage` | Full suite + v8 coverage with thresholds | Checking coverage impact | +| `npm run test:parity` | Scope-resolution parity for all migrated languages | After changing resolver or scope code | +| `npm run test:cross-platform` | Platform-sensitive subset only | Debugging a Windows/macOS issue | +| `npm run test:watch` | Vitest in watch mode | Active development | + +### `gitnexus-web/` commands + +From `gitnexus-web/`: + +| Command | What it runs | When to use | +| ---------------------- | --------------------------------- | ------------------------------ | +| `npm test` | Unit/component tests (vitest) | After changing web code | +| `npm run test:coverage`| Unit tests + coverage | Checking coverage impact | +| `npm run test:e2e` | Playwright browser tests | After changing UI flows (requires `gitnexus serve` + `npm run dev`) | + +### Before opening a PR ```bash -cd gitnexus -npm install -npm run build -npm test # full suite: vitest run -npm run test:unit # unit only: vitest run test/unit -npm run test:integration # integration suite -npm run test:coverage -npx tsc --noEmit # typecheck (matches CI) -``` - -**`gitnexus-web`** - -```bash -cd gitnexus-web -npm install -npm test # unit tests (vitest) -npx tsc -b --noEmit # typecheck (matches CI) -npm run test:coverage -npm run test:e2e # Playwright (requires gitnexus serve + npm run dev) +cd gitnexus && npx tsc --noEmit && npm test +cd ../gitnexus-web && npx tsc -b --noEmit && npm test ``` ## Pre-commit hook @@ -50,22 +55,79 @@ Tests do **not** run in the pre-commit hook — they run in CI (`ci-tests.yml`) Skip with `git commit --no-verify` (use sparingly). +## Vitest projects + +`gitnexus/vitest.config.ts` defines three projects for safety isolation: + +| Project | Files | Parallelism | Purpose | +| ---------- | ----------------------------- | ----------- | ---------------------------------------------- | +| `lbug-db` | Native LadybugDB integration tests (explicit list) | Sequential | Prevents file-lock conflicts from native mmap addon | +| `cli-e2e` | `skills-e2e.test.ts` | Sequential | CLI process spawning requires serial execution | +| `default` | Everything else | Parallel | Fast execution for pure logic and parser tests | + +When adding a new test that uses native LadybugDB (`@ladybugdb/core`), add it to the `lbug-db` project's explicit include list and the `default` project's exclude list. + ## Test categories - **Unit** — Pure logic, parsers, graph/query helpers; fast; no network. - **Integration** — Real combinations (filesystem, MCP wiring, larger pipelines) as already organized under `gitnexus/test/integration`. -- **Eval-style / golden sets** — For agent- or classification-style behavior, keep labeled inputs and expected outputs (JSON or table-driven tests) and run them in CI when relevant. +- **Resolver / parity** — Language-specific call-resolution tests in `test/integration/resolvers/`. - **E2E (web)** — Critical user paths only; prefer `data-testid` attributes for stable selectors. Tests run against real backend (`gitnexus serve`) and Vite dev server. -## Performance metrics (targets) +## Scope-resolution parity -Set targets to match team expectations, then tune to this repo’s CI reality: +Migrated languages (listed in `MIGRATED_LANGUAGES` in `src/core/ingestion/registry-primary-flag.ts`) are tested in both legacy and registry-primary modes on every PR. -| Metric | Target (initial) | Notes | -| ------------------- | ---------------- | ------------------------------------------ | -| Unit coverage | Align with CI | CI runs Vitest with coverage in `gitnexus` | -| Unit wall time | Fast PR feedback | Use `vitest run test/unit` for tight loop | -| Integration duration| < few minutes | Guard heavy tests with env flags if needed | +For each migrated language, CI runs the resolver test file twice: +1. `REGISTRY_PRIMARY_=0` — legacy DAG path +2. `REGISTRY_PRIMARY_=1` — registry-primary path + +Both must pass. Known legacy gaps are listed in `LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES` in `test/integration/resolvers/helpers.ts` and are automatically skipped in legacy mode. + +Adding a language to `MIGRATED_LANGUAGES` automatically enrolls it in parity — no workflow or config edit needed. The test file must exist at `test/integration/resolvers/.test.ts`. + +Run parity locally: `cd gitnexus && npm run test:parity` + +Run for a single language: `cd gitnexus && npx tsx scripts/run-parity.ts --language python` + +## Cross-platform testing + +Windows and macOS CI runs only the platform-sensitive test subset (~50 files out of 373). The full suite runs on Ubuntu. + +The subset is defined in `gitnexus/scripts/cross-platform-tests.ts` and includes: + +- **Platform-specific logic** — tests with `process.platform` guards, path.sep behavior, EPERM/EBUSY error classification +- **Native LadybugDB** — all `lbug-*` integration tests (N-API addon with known platform-varying behavior) +- **Process spawning / CLI** — tests using real `child_process.spawn`, shell quoting, CLI invocations +- **Worker threads** — tests spawning real `worker_threads` +- **Native addon loading** — tree-sitter grammar loading smoke tests +- **Filesystem behavior** — CRLF handling, directory walking, symlinks + +When adding a platform-sensitive test, add it to the appropriate section in `scripts/cross-platform-tests.ts`. + +### Confirming no tests are orphaned + +Every test file matches one of the three vitest projects. To verify: + +```bash +cd gitnexus +npx vitest list 2>/dev/null | wc -l # should match total test count +``` + +To check the cross-platform list is up to date, run `npm run test:cross-platform` — it fails fast if any listed file is missing. + +## CI integration + +GitHub Actions (`.github/workflows/ci.yml`) orchestrate: + +| Workflow | Jobs | Purpose | +| --------------------- | ------------------------------ | ------------------------------------------------ | +| `ci-quality.yml` | format, lint, typecheck, typecheck-web, workflow-convention | Code quality gates | +| `ci-tests.yml` | ubuntu/coverage, cross-platform (Win/Mac), packaged-install-smoke | Full suite + coverage on Ubuntu; platform-sensitive subset on Win/Mac | +| `ci-scope-parity.yml` | discover, parity | Scope-resolution parity for all migrated languages | +| `ci-e2e.yml` | e2e (chromium) | Playwright E2E, gated on `gitnexus-web/**` changes | + +The `CI Gate` job in `ci.yml` is the single required check for branch protection. It requires quality, tests, e2e, and scope-parity to all pass. ## Regression testing @@ -76,23 +138,6 @@ Re-run the full relevant suite when: - Graph schema, query contracts, or MCP tool shapes change - Dependencies with parsing or runtime impact upgrade -## CI integration - -GitHub Actions (`.github/workflows/ci.yml`) orchestrate: - -- **`ci-quality.yml`** — prettier format check, eslint lint, `tsc --noEmit` for `gitnexus/`, `tsc -b --noEmit` for `gitnexus-web/` -- **`ci-tests.yml`** — `vitest run` with coverage (ubuntu) + cross-platform (macOS, Windows) -- **`ci-e2e.yml`** — Playwright E2E tests, gated on `gitnexus-web/**` changes - -Local checks before pushing: - -```bash -cd gitnexus && npx tsc --noEmit && npm test -cd ../gitnexus-web && npx tsc -b --noEmit && npm test -``` - -Or rely on the pre-commit hook which runs these automatically for staged files. - ## User acceptance / beta (optional) For staged releases or UI betas: deploy to a staging environment, collect structured feedback, watch errors and latency, then iterate before a wider release. diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index c3d8b80fb..828874a7f 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -267,8 +267,11 @@ export interface ScopeLookup { /** Call-site description passed to `arityCompatibility`. */ export interface Callsite { - /** Number of arguments at the call site. */ - readonly arity: number; + /** Number of arguments at the call site, if available. */ + readonly arity?: number; + /** Inferred argument types at the call site, one per argument. + * An empty string entry means the type was not inferred. */ + readonly argumentTypes?: readonly string[]; } // ─── §2.4 ImportEdge ──────────────────────────────────────────────────────── diff --git a/gitnexus/package.json b/gitnexus/package.json index fb40cba56..0e25ed25a 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -48,6 +48,8 @@ "test:integration": "vitest run test/integration", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "test:parity": "tsx scripts/run-parity.ts", + "test:cross-platform": "tsx scripts/run-cross-platform.ts", "postinstall": "node scripts/materialize-vendor-grammars.cjs && node scripts/build-tree-sitter-dart.cjs && node scripts/build-tree-sitter-proto.cjs && node scripts/build-tree-sitter-swift.cjs", "prepare": "node scripts/build.js", "prepack": "node scripts/build.js" diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts new file mode 100644 index 000000000..122ce064b --- /dev/null +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -0,0 +1,131 @@ +/** + * Cross-platform test subset runner. + * + * Runs only the tests that exercise platform-sensitive behavior on + * Windows and macOS. The full suite runs on Ubuntu; this narrows the + * cross-platform matrix to tests that actually vary across OSes. + * + * Categories included: + * - Platform-specific logic (path.sep, process.platform guards) + * - Native addon loading (LadybugDB, tree-sitter) + * - Process spawning and shell behavior + * - Filesystem locking and temp-dir behavior + * - Worker threads (real, not mocked) + * - CLI end-to-end tests + * + * When adding a new test that uses platform-varying APIs (native addons, + * child_process with real spawning, filesystem locking, path.sep), add + * it to the appropriate section below. + * + * Usage: + * npx vitest run $(npx tsx scripts/cross-platform-tests.ts) + * # or via the package script: + * npm run test:cross-platform + */ + +// Platform-specific logic tests — contain explicit process.platform guards +// or test behavior that differs across operating systems +const PLATFORM_LOGIC = [ + 'test/unit/setup.test.ts', + 'test/unit/setup-jsonc.test.ts', + 'test/unit/setup-codex.test.ts', + 'test/unit/platform-capabilities.test.ts', + 'test/unit/worker-pool-windows-quarantine.test.ts', + 'test/unit/lbug-pool-win-fts-probe.test.ts', + 'test/unit/repo-manager.test.ts', + 'test/unit/repo-manager-finalize-invariant.test.ts', + 'test/unit/hooks.test.ts', + 'test/unit/cursor-hook.test.ts', + 'test/unit/sidecar-recovery.test.ts', + 'test/unit/pool-wal-recovery.test.ts', + 'test/unit/detect-changes-worktree.test.ts', + 'test/unit/eval-server-bind-restriction.test.ts', + 'test/unit/ignore-service.test.ts', + 'test/unit/group/bridge-db.test.ts', + 'test/unit/group/bridge-db-edge.test.ts', +]; + +// Native LadybugDB integration tests — exercise the @ladybugdb/core +// N-API addon which has known platform-specific behavior (Windows +// file-lock lag after close, macOS N-API destructor segfaults) +const LBUG_NATIVE = [ + 'test/integration/lbug-core-adapter.test.ts', + 'test/integration/lbug-vector-extension.test.ts', + 'test/integration/lbug-pool.test.ts', + 'test/integration/lbug-pool-stability.test.ts', + 'test/integration/lbug-lock-retry.test.ts', + 'test/integration/lbug-open-retry.test.ts', + 'test/integration/lbug-close-handle-release.test.ts', + 'test/integration/lbug-orphan-sidecar-recovery.test.ts', + 'test/integration/lbug-readonly-init.test.ts', + 'test/integration/local-backend.test.ts', + 'test/integration/local-backend-calltool.test.ts', + 'test/integration/search-core.test.ts', + 'test/integration/search-pool.test.ts', + 'test/integration/staleness-and-stability.test.ts', + 'test/integration/analyze-wal-checkpoint-failure.test.ts', +]; + +// Process spawning and CLI tests — exercise child_process with real +// process spawning, which behaves differently across platforms (shell +// quoting, path resolution, signal handling) +const SPAWN_CLI = [ + 'test/integration/cli-e2e.test.ts', + 'test/integration/hooks-e2e.test.ts', + 'test/integration/skills-e2e.test.ts', + 'test/integration/server-http-startup.test.ts', + 'test/integration/mcp/server-startup.test.ts', + 'test/integration/analyze-heap-oom-e2e.test.ts', + 'test/integration/group/group-cli.test.ts', + 'test/integration/cli/tool-no-index-stderr.test.ts', + 'test/integration/setup-skills.test.ts', +]; + +// Worker threads tests — exercise real worker_threads which have +// platform-specific behavior (thread spawning, IPC, exit handling) +const WORKER_THREADS = [ + 'test/integration/worker-pool.test.ts', + 'test/integration/parse-impl-quarantine-cache-skip.test.ts', +]; + +// Tree-sitter native addon smoke tests — verify that native grammars +// load correctly on each platform (binary compatibility, .node loading) +const NATIVE_ADDON_SMOKE = [ + 'test/integration/tree-sitter-languages.test.ts', + 'test/integration/parsing.test.ts', + 'test/integration/pipeline.test.ts', + 'test/integration/pipeline-graph-golden.test.ts', + 'test/unit/parser-loader.test.ts', +]; + +// Filesystem behavior tests — exercise operations that vary across +// platforms (CRLF, symlinks, permissions, temp dirs) +const FILESYSTEM = [ + 'test/integration/filesystem-walker.test.ts', + 'test/integration/markdown-processor-crlf.test.ts', + 'test/integration/ignore-and-skip-e2e.test.ts', +]; + +const ALL_CROSS_PLATFORM = [ + ...PLATFORM_LOGIC, + ...LBUG_NATIVE, + ...SPAWN_CLI, + ...WORKER_THREADS, + ...NATIVE_ADDON_SMOKE, + ...FILESYSTEM, +]; + +// When invoked directly, print the file list for vitest consumption +if (process.argv[1]?.endsWith('cross-platform-tests.ts')) { + console.log(ALL_CROSS_PLATFORM.join('\n')); +} + +export { + ALL_CROSS_PLATFORM, + PLATFORM_LOGIC, + LBUG_NATIVE, + SPAWN_CLI, + WORKER_THREADS, + NATIVE_ADDON_SMOKE, + FILESYSTEM, +}; diff --git a/gitnexus/scripts/run-cross-platform.ts b/gitnexus/scripts/run-cross-platform.ts new file mode 100644 index 000000000..1f5464caf --- /dev/null +++ b/gitnexus/scripts/run-cross-platform.ts @@ -0,0 +1,44 @@ +/** + * Cross-platform test runner. + * + * Runs the platform-sensitive test subset defined in cross-platform-tests.ts + * via vitest. Used by `npm run test:cross-platform` and by the CI cross- + * platform matrix (ci-tests.yml). + * + * The main vitest.config.ts is used, so lbug-db project files get + * sequential execution and other safety constraints are preserved. + */ + +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { ALL_CROSS_PLATFORM } from './cross-platform-tests.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); + +// Verify all files exist +const missing = ALL_CROSS_PLATFORM.filter((f) => !fs.existsSync(path.resolve(ROOT, f))); +if (missing.length > 0) { + console.error(`Cross-platform test files not found (${missing.length}):`); + for (const f of missing) console.error(` ${f}`); + console.error('\nUpdate scripts/cross-platform-tests.ts if files were moved or removed.'); + process.exit(1); +} + +console.log(`Running ${ALL_CROSS_PLATFORM.length} platform-sensitive tests...\n`); + +try { + execFileSync('npx', ['vitest', 'run', ...ALL_CROSS_PLATFORM], { + cwd: ROOT, + stdio: 'inherit', + timeout: 15 * 60 * 1000, + shell: true, + }); +} catch (err: any) { + if (err.killed || err.signal) { + console.error('vitest timed out after 15 minutes'); + } + process.exit(1); +} diff --git a/gitnexus/scripts/run-parity.ts b/gitnexus/scripts/run-parity.ts new file mode 100644 index 000000000..f56db11bc --- /dev/null +++ b/gitnexus/scripts/run-parity.ts @@ -0,0 +1,128 @@ +/** + * Consolidated scope-resolution parity runner. + * + * Replaces the per-language matrix in ci-scope-parity.yml with a single + * job that runs all migrated languages sequentially in one process. This + * eliminates 8× redundant checkout + npm ci + build cycles (the old + * workflow created a separate GitHub Actions job per language). + * + * For each language in MIGRATED_LANGUAGES: + * 1. Run its resolver test with REGISTRY_PRIMARY_=0 (legacy DAG) + * 2. Run its resolver test with REGISTRY_PRIMARY_=1 (registry-primary) + * + * Both modes must pass. Failures are collected and reported at the end + * so all regressions are visible in a single CI run (equivalent to the + * old workflow's fail-fast: false behavior). + * + * Vitest output streams to the console in real time (stdio: 'inherit') + * so CI logs show the actual test output directly. No per-invocation + * timeout — the CI job-level timeout (30 min) is the outer guard. + * + * Usage: + * npx tsx scripts/run-parity.ts + * npx tsx scripts/run-parity.ts --language python # single language + */ + +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { MIGRATED_LANGUAGES } from '../src/core/ingestion/registry-primary-flag.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); + +interface ParityFailure { + lang: string; + mode: 'legacy' | 'registry-primary'; +} + +function envVarName(slug: string): string { + return `REGISTRY_PRIMARY_${slug.toUpperCase().replace(/-/g, '_')}`; +} + +function testFilePath(slug: string): string { + return `test/integration/resolvers/${slug}.test.ts`; +} + +function runVitest(testFile: string, env: Record): boolean { + try { + execFileSync('npx', ['vitest', 'run', testFile], { + cwd: ROOT, + env: { ...process.env, ...env }, + stdio: 'inherit', + shell: true, + }); + return true; + } catch { + return false; + } +} + +// Parse CLI args +const args = process.argv.slice(2); +const langFlag = args.indexOf('--language'); +const singleLang = langFlag >= 0 ? args[langFlag + 1] : undefined; + +if (langFlag >= 0 && singleLang === undefined) { + console.error('--language requires a value'); + process.exit(1); +} + +const languages = singleLang ? [singleLang] : [...MIGRATED_LANGUAGES].map(String); + +// Verify test files exist before running +const missingFiles: string[] = []; +for (const lang of languages) { + const file = path.resolve(ROOT, testFilePath(lang)); + try { + fs.accessSync(file); + } catch { + missingFiles.push(`${testFilePath(lang)} (${lang})`); + } +} + +if (missingFiles.length > 0) { + console.error('Missing resolver test files:'); + for (const f of missingFiles) console.error(` ${f}`); + process.exit(1); +} + +console.log(`Scope-resolution parity: ${languages.length} language(s)`); +console.log(`Languages: ${languages.join(', ')}\n`); + +const failures: ParityFailure[] = []; + +for (const lang of languages) { + const file = testFilePath(lang); + const envVar = envVarName(lang); + + console.log(`\n── ${lang} — legacy DAG (${envVar}=0) ──`); + if (!runVitest(file, { [envVar]: '0' })) { + failures.push({ lang, mode: 'legacy' }); + } + + console.log(`\n── ${lang} — registry-primary (${envVar}=1) ──`); + if (!runVitest(file, { [envVar]: '1' })) { + failures.push({ lang, mode: 'registry-primary' }); + } +} + +// Summary +const total = languages.length * 2; +const passed = total - failures.length; + +console.log('\n═══════════════════════════════════════'); +console.log('PARITY SUMMARY'); +console.log('═══════════════════════════════════════'); +console.log(`Passed: ${passed}/${total}`); + +if (failures.length > 0) { + console.log(`\nFAILURES (${failures.length}):`); + for (const f of failures) { + console.log(` ✗ ${f.lang} [${f.mode}]`); + } + process.exit(1); +} + +console.log('\nAll parity checks passed.'); diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index cd64fdf8c..5c535f29f 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -91,22 +91,23 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis let dbExecutors: Map | undefined; let registryEntries: RegistryEntry[] | undefined; - const eo = opts?.extractorOverride; - if (eo && eo.length === 0) { - autoContracts = await (eo as () => Promise)(); - } else { - registryEntries = await readRegistry(); - const entries = registryEntries; - const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries); - const httpEx = new HttpRouteExtractor(); - const grpcEx = new GrpcExtractor(); - const thriftEx = new ThriftExtractor(); - const topicEx = new TopicExtractor(); - const includeEx = new IncludeExtractor(); - dbExecutors = new Map(); - const openPoolIds: string[] = []; + const openPoolIds: string[] = []; + + try { + const eo = opts?.extractorOverride; + if (eo && eo.length === 0) { + autoContracts = await (eo as () => Promise)(); + } else { + registryEntries = await readRegistry(); + const entries = registryEntries; + const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries); + const httpEx = new HttpRouteExtractor(); + const grpcEx = new GrpcExtractor(); + const thriftEx = new ThriftExtractor(); + const topicEx = new TopicExtractor(); + const includeEx = new IncludeExtractor(); + dbExecutors = new Map(); - try { for (const [groupPath, regName] of Object.entries(config.repos)) { const handle = await resolve(regName, groupPath); if (!handle) { @@ -201,64 +202,58 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis missingRepos.push(groupPath); } } - } finally { - for (const id of [...new Set(openPoolIds)]) { - await closeLbug(id).catch(() => {}); + } + + // Workspace discovery and manifest extraction run inside this outer try + // block so dbExecutors closures resolve against live pools (issue #1802). + // The finally below closes pools after this completes (or throws). + let allLinks = [...config.links]; + + if (config.detect.workspace_deps) { + const repoPaths = new Map(); + if (!registryEntries) registryEntries = await readRegistry(); + for (const [groupPath, regName] of Object.entries(config.repos)) { + const e = registryEntries.find((en) => en.name === regName); + if (e) repoPaths.set(groupPath, e.path); } - } - } - // Auto-discover workspace dependency contracts (Rust Cargo workspaces, etc.) - // and merge them with explicit manifest links. Discovered links use the same - // ManifestExtractor pipeline as hand-written links in group.yaml. - let allLinks = [...config.links]; - - if (config.detect.workspace_deps) { - const repoPaths = new Map(); - if (!registryEntries) registryEntries = await readRegistry(); - for (const [groupPath, regName] of Object.entries(config.repos)) { - const e = registryEntries.find((en) => en.name === regName); - if (e) repoPaths.set(groupPath, e.path); - } - - const wsResult = await discoverWorkspaceLinks(config.repos, repoPaths, dbExecutors); - if (wsResult.links.length > 0) { - allLinks = [...allLinks, ...wsResult.links]; - if (opts?.verbose) { - for (const s of wsResult.stats) { - logger.info( - ` workspace-deps: discovered ${s.linkCount} cross-${s.ecosystem.toLowerCase()} links from ${s.projectCount} ${s.ecosystem} projects`, - ); + const wsResult = await discoverWorkspaceLinks(config.repos, repoPaths, dbExecutors); + if (wsResult.links.length > 0) { + allLinks = [...allLinks, ...wsResult.links]; + if (opts?.verbose) { + for (const s of wsResult.stats) { + logger.info( + ` workspace-deps: discovered ${s.linkCount} cross-${s.ecosystem.toLowerCase()} links from ${s.projectCount} ${s.ecosystem} projects`, + ); + } } } } - } - // Process manifest links declared in group.yaml (plus any auto-discovered). - // ManifestExtractor is fully implemented but was never wired into this - // pipeline — config.links were parsed and validated but silently dropped. - // Placed after the DB try/finally: resolveSymbol falls back to synthetic - // UIDs when dbExecutors is undefined or a pool is closed, so cross-links - // are always generated regardless of whether real DB executors are available. - if (allLinks.length > 0) { - const knownRepos = new Set(Object.keys(config.repos)); - for (const link of allLinks) { - const dangling = [link.from, link.to].filter((r) => !knownRepos.has(r)); - if (dangling.length > 0) { - logger.warn( - `[group/sync] manifest link ${link.type}:${link.contract} references repos not in config.repos: ${dangling.join(', ')} — cross-links will use synthetic UIDs`, + if (allLinks.length > 0) { + const knownRepos = new Set(Object.keys(config.repos)); + for (const link of allLinks) { + const dangling = [link.from, link.to].filter((r) => !knownRepos.has(r)); + if (dangling.length > 0) { + logger.warn( + `[group/sync] manifest link ${link.type}:${link.contract} references repos not in config.repos: ${dangling.join(', ')} — cross-links will use synthetic UIDs`, + ); + } + } + + const manifestEx = new ManifestExtractor(); + const manifestResult = await manifestEx.extractFromManifest(allLinks, dbExecutors); + autoContracts.push(...manifestResult.contracts); + manifestCrossLinks = manifestResult.crossLinks; + if (opts?.verbose) { + logger.info( + ` manifest: ${manifestCrossLinks.length} cross-links from ${allLinks.length} links (${config.links.length} declared + ${allLinks.length - config.links.length} discovered)`, ); } } - - const manifestEx = new ManifestExtractor(); - const manifestResult = await manifestEx.extractFromManifest(allLinks, dbExecutors); - autoContracts.push(...manifestResult.contracts); - manifestCrossLinks = manifestResult.crossLinks; - if (opts?.verbose) { - logger.info( - ` manifest: ${manifestCrossLinks.length} cross-links from ${allLinks.length} links (${config.links.length} declared + ${allLinks.length - config.links.length} discovered)`, - ); + } finally { + for (const id of [...new Set(openPoolIds)]) { + await closeLbug(id).catch(() => {}); } } diff --git a/gitnexus/src/core/ingestion/finalize-orchestrator.ts b/gitnexus/src/core/ingestion/finalize-orchestrator.ts index 70dff9874..50cbc6b2a 100644 --- a/gitnexus/src/core/ingestion/finalize-orchestrator.ts +++ b/gitnexus/src/core/ingestion/finalize-orchestrator.ts @@ -144,6 +144,7 @@ export function finalizeScopeModel( // AFTER `finalizeScopeModel` returns, before `resolveReferenceSites` // consumes the bundle. Most languages leave it empty. bindingAugmentations: new Map(), + workspaceFqnBindings: new Map(), referenceSites: Object.freeze([...allReferenceSites]), sccs: finalizeOut.sccs, stats: finalizeOut.stats, diff --git a/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts b/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts index 604c50c0b..eec44c68f 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts @@ -27,12 +27,13 @@ * declaration transparently. */ -import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { Callsite, ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { isOverloadAmbiguousAfterNormalization, narrowOverloadCandidates, } from '../../scope-resolution/passes/overload-narrowing.js'; +import { cppConversionRank } from './conversion-rank.js'; interface RangeKey { readonly startLine: number; @@ -107,6 +108,7 @@ export function resolveCppQualifiedNamespaceMember( memberName: string, parsedFiles: readonly ParsedFile[], _scopes: ScopeResolutionIndexes, + callsite?: Callsite, ): SymbolDefinition | 'ambiguous' | undefined { const allHits: SymbolDefinition[] = []; const seenNodeId = new Set(); @@ -132,19 +134,17 @@ export function resolveCppQualifiedNamespaceMember( if (allHits.length === 0) return undefined; if (allHits.length === 1) return allHits[0]; - // Multi-candidate: the `resolveQualifiedReceiverMember` hook has no - // access to call-site arity or argument types, so - // `narrowOverloadCandidates` cannot actually narrow here — the call - // with `(allHits, undefined, undefined)` is effectively a pass-through. - // We retain it so that `isOverloadAmbiguousAfterNormalization` can - // still detect int/long-style normalization collisions on this path, - // but for any multi-hit case where candidates have genuinely distinct - // signatures (e.g. `foo(int)` vs `foo(double)` in different inline - // children), we conservatively suppress rather than pick arbitrarily. - // A future enhancement could thread call-site argument info through - // the `resolveQualifiedReceiverMember` contract to enable real - // narrowing here. - const narrowed = narrowOverloadCandidates(allHits, undefined, undefined); + // Multi-candidate: thread call-site arity/argument-types through the + // `resolveQualifiedReceiverMember` contract so `narrowOverloadCandidates` + // can disambiguate via exact-type match and, when available, conversion-rank + // scoring (`cppConversionRank`). Same-signature ambiguity is still detected + // by `isOverloadAmbiguousAfterNormalization` below. + const narrowed = narrowOverloadCandidates( + allHits, + callsite?.arity, + callsite?.argumentTypes, + callsite !== undefined ? { conversionRankFn: cppConversionRank } : undefined, + ); if (narrowed.length === 1) return narrowed[0]; if (narrowed.length === 0) return undefined; if (isOverloadAmbiguousAfterNormalization(narrowed, undefined)) return 'ambiguous'; diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index 4a1f343d1..9a5ac85a5 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -275,6 +275,12 @@ export const cppScopeResolver: ScopeResolver = { // descends transitively through inline-namespace children when // searching for the called member. Returns undefined for non-namespace // receivers so receiver-bound-calls Case 2 still gets a chance. - resolveQualifiedReceiverMember: (receiverName, memberName, _callerScope, scopes, parsedFiles) => - resolveCppQualifiedNamespaceMember(receiverName, memberName, parsedFiles, scopes), + resolveQualifiedReceiverMember: ( + receiverName, + memberName, + _callerScope, + scopes, + parsedFiles, + callsite, + ) => resolveCppQualifiedNamespaceMember(receiverName, memberName, parsedFiles, scopes, callsite), }; diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index 73ea605fe..5ca470025 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -38,10 +38,6 @@ function shouldEmitReadMember(memberNode: SyntaxNode): boolean { if (parent === null) return true; switch (parent.type) { - case 'method_invocation': - // Don't emit read.member when the field_access is the object of a method_invocation - // (the method call already handles this relationship) - return parent.childForFieldName('object')?.id !== memberNode.id; case 'assignment_expression': return parent.childForFieldName('left')?.id !== memberNode.id; default: @@ -185,13 +181,137 @@ export function emitJavaScopeCaptures( callNode, JSON.stringify(argTypes), ); + + const argNames = args.map((a) => (a!.type === 'identifier' ? a!.text : '')); + if (argNames.some((n) => n !== '')) { + grouped['@reference.arg-names'] = syntheticCapture( + '@reference.arg-names', + callNode, + JSON.stringify(argNames), + ); + } } } out.push(grouped); } - return out; + return resolveVarTypeBindings(out); +} + +function resolveVarTypeBindings(matches: CaptureMatch[]): CaptureMatch[] { + const returnTypes = new Map(); + const varTypes = new Map(); + const ambiguousReturns = new Set(); + const ambiguousVars = new Set(); + + for (const m of matches) { + if ( + m['@type-binding.return'] !== undefined && + m['@type-binding.type'] !== undefined && + m['@type-binding.name'] !== undefined + ) { + const name = m['@type-binding.name'].text; + const type = m['@type-binding.type'].text; + const existing = returnTypes.get(name); + if (existing !== undefined && existing !== type) { + ambiguousReturns.add(name); + returnTypes.delete(name); + } else if (!ambiguousReturns.has(name)) { + returnTypes.set(name, type); + } + } + if ( + m['@type-binding.annotation'] !== undefined && + m['@type-binding.type'] !== undefined && + m['@type-binding.name'] !== undefined + ) { + const name = m['@type-binding.name'].text; + const t = m['@type-binding.type'].text; + if (t !== 'var') { + const existing = varTypes.get(name); + if (existing !== undefined && existing !== t) { + ambiguousVars.add(name); + varTypes.delete(name); + } else if (!ambiguousVars.has(name)) { + varTypes.set(name, t); + } + } + } + if ( + m['@type-binding.constructor'] !== undefined && + m['@type-binding.type'] !== undefined && + m['@type-binding.name'] !== undefined + ) { + const name = m['@type-binding.name'].text; + const type = m['@type-binding.type'].text; + const existing = varTypes.get(name); + if (existing !== undefined && existing !== type) { + ambiguousVars.add(name); + varTypes.delete(name); + } else if (!ambiguousVars.has(name)) { + varTypes.set(name, type); + } + } + } + + const resolved: CaptureMatch[] = []; + for (const m of matches) { + if (m['@type-binding.call-result'] !== undefined && m['@type-binding.type'] !== undefined) { + const methodName = m['@type-binding.type'].text; + const resolvedType = returnTypes.get(methodName); + if (resolvedType !== undefined) { + const patched: Record = { ...m }; + patched['@type-binding.type'] = { ...m['@type-binding.type']!, text: resolvedType }; + patched['@type-binding.annotation'] = m['@type-binding.call-result']!; + delete patched['@type-binding.call-result']; + resolved.push(patched); + continue; + } + } + if (m['@type-binding.alias'] !== undefined && m['@type-binding.type'] !== undefined) { + const sourceName = m['@type-binding.type'].text; + const resolvedType = varTypes.get(sourceName); + if (resolvedType !== undefined) { + const patched: Record = { ...m }; + patched['@type-binding.type'] = { ...m['@type-binding.type']!, text: resolvedType }; + patched['@type-binding.annotation'] = m['@type-binding.alias']!; + delete patched['@type-binding.alias']; + resolved.push(patched); + continue; + } + } + if (m['@reference.arg-names'] !== undefined && m['@reference.parameter-types'] !== undefined) { + try { + const types: string[] = JSON.parse(m['@reference.parameter-types'].text); + const names: string[] = JSON.parse(m['@reference.arg-names'].text); + let patched = false; + for (let i = 0; i < types.length; i++) { + if (types[i] === '' && names[i] !== undefined && names[i] !== '') { + const rt = varTypes.get(names[i]!); + if (rt !== undefined) { + types[i] = rt; + patched = true; + } + } + } + if (patched) { + const patchedMatch: Record = { ...m }; + patchedMatch['@reference.parameter-types'] = { + ...m['@reference.parameter-types']!, + text: JSON.stringify(types), + }; + delete patchedMatch['@reference.arg-names']; + resolved.push(patchedMatch); + continue; + } + } catch { + // pass through + } + } + resolved.push(m); + } + return resolved; } type SyntaxNode = ReturnType['parse']>['rootNode']; diff --git a/gitnexus/src/core/ingestion/languages/java/interpret.ts b/gitnexus/src/core/ingestion/languages/java/interpret.ts index 9c207d451..87da580b6 100644 --- a/gitnexus/src/core/ingestion/languages/java/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/java/interpret.ts @@ -24,10 +24,11 @@ export function interpretJavaImport(captures: CaptureMatch): ParsedImport | null switch (kind) { case 'named': { // `import com.example.User;` + const simpleName = sourceCap.text.split('.').pop() ?? sourceCap.text; return { kind: 'named', - localName: nameCap?.text ?? sourceCap.text.split('.').pop() ?? sourceCap.text, - importedName: sourceCap.text, + localName: nameCap?.text ?? simpleName, + importedName: simpleName, targetRaw: sourceCap.text, }; } @@ -40,17 +41,14 @@ export function interpretJavaImport(captures: CaptureMatch): ParsedImport | null } case 'static': { // `import static com.example.Utils.format;` - // The source contains the full path including the member name - // (e.g. `com.example.Utils.format`). For file resolution we need - // the class path (`com.example.Utils`), so strip the final member - // segment. The local binding name is the member itself. const fullSource = sourceCap.text; const lastDot = fullSource.lastIndexOf('.'); + const memberName = lastDot >= 0 ? fullSource.slice(lastDot + 1) : fullSource; const classPath = lastDot >= 0 ? fullSource.slice(0, lastDot) : fullSource; return { kind: 'named', - localName: nameCap?.text ?? (lastDot >= 0 ? fullSource.slice(lastDot + 1) : fullSource), - importedName: fullSource, + localName: nameCap?.text ?? memberName, + importedName: memberName, targetRaw: classPath, }; } @@ -89,6 +87,9 @@ export function interpretJavaTypeBinding(captures: CaptureMatch): ParsedTypeBind let source: TypeRef['source'] = 'parameter-annotation'; if (captures['@type-binding.self'] !== undefined) source = 'self'; else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred'; + else if (captures['@type-binding.pattern'] !== undefined) source = 'annotation'; + else if (captures['@type-binding.call-result'] !== undefined) source = 'annotation'; + else if (captures['@type-binding.alias'] !== undefined) source = 'annotation'; else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation'; else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation'; diff --git a/gitnexus/src/core/ingestion/languages/java/package-siblings.ts b/gitnexus/src/core/ingestion/languages/java/package-siblings.ts new file mode 100644 index 000000000..4ba5ac1e1 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/package-siblings.ts @@ -0,0 +1,156 @@ +/** + * Java package-scope implicit visibility. + * + * Classes in the same Java package see each other without explicit + * `import` statements. This hook groups files by `package` declaration, + * then injects cross-file class defs into each file's module-scope + * `bindingAugmentations` and mirrors type-bindings across same-package + * files — the Java equivalent of C#'s `populateNamespaceSiblings`. + */ + +import type { BindingRef, ParsedFile, ScopeId, TypeRef } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { isClassLike } from '../../scope-resolution/scope/walkers.js'; +import { getJavaParser } from './query.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; +import { logger } from '../../../logger.js'; + +function extractPackageName(content: string, cachedTree?: unknown): string { + const tree = + (cachedTree as ReturnType['parse']> | undefined) ?? + parseSourceSafe(getJavaParser(), content); + for (const child of tree.rootNode.namedChildren) { + if (child.type === 'package_declaration') { + const scoped = child.namedChildren.find( + (c) => c.type === 'scoped_identifier' || c.type === 'identifier', + ); + return scoped?.text ?? ''; + } + } + return ''; +} + +interface PackageBucket { + readonly parsed: ParsedFile[]; + readonly moduleScopes: { filePath: string; scope: ParsedFile['scopes'][number] }[]; +} + +export function populateJavaPackageSiblings( + parsedFiles: readonly ParsedFile[], + indexes: ScopeResolutionIndexes, + ctx: { + readonly fileContents: ReadonlyMap; + readonly treeCache?: { get(filePath: string): unknown }; + }, +): void { + const buckets = new Map(); + + for (const parsed of parsedFiles) { + const content = ctx.fileContents.get(parsed.filePath); + if (content === undefined) continue; + const pkg = extractPackageName(content, ctx.treeCache?.get(parsed.filePath)); + let bucket = buckets.get(pkg); + if (bucket === undefined) { + bucket = { parsed: [], moduleScopes: [] }; + buckets.set(pkg, bucket); + } + bucket.parsed.push(parsed); + const ms = parsed.scopes.find((s) => s.kind === 'Module'); + if (ms !== undefined) { + bucket.moduleScopes.push({ filePath: parsed.filePath, scope: ms }); + } + } + + const augmentations = indexes.bindingAugmentations as Map>; + + const MAX_PACKAGE_FILES = 500; + + for (const bucket of buckets.values()) { + if (bucket.moduleScopes.length < 2) continue; + if (bucket.moduleScopes.length > MAX_PACKAGE_FILES) { + logger.warn( + `[java-package-siblings] skipping package with ${bucket.moduleScopes.length} files (cap=${MAX_PACKAGE_FILES}); same-package implicit visibility disabled for this package`, + ); + continue; + } + + const classDefs: { def: BindingRef['def']; filePath: string }[] = []; + for (const parsed of bucket.parsed) { + const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); + const moduleScopeId = moduleScope?.id; + for (const scope of parsed.scopes) { + if (scope.kind !== 'Class') continue; + if (scope.parent !== moduleScopeId) continue; + for (const def of scope.ownedDefs) { + if (isClassLike(def.type)) { + classDefs.push({ def, filePath: parsed.filePath }); + break; + } + } + } + } + + for (const { filePath, scope } of bucket.moduleScopes) { + let scopeAug = augmentations.get(scope.id); + if (scopeAug === undefined) { + scopeAug = new Map(); + augmentations.set(scope.id, scopeAug); + } + + const candidates = classDefs.filter((d) => d.filePath !== filePath); + const proximityCache = new Map(); + for (const c of candidates) { + if (!proximityCache.has(c.filePath)) { + proximityCache.set(c.filePath, sharedSegmentCount(c.filePath, filePath)); + } + } + const sorted = candidates.sort( + (a, b) => (proximityCache.get(b.filePath) ?? 0) - (proximityCache.get(a.filePath) ?? 0), + ); + + const injectedIds = new Set(); + for (const { def } of sorted) { + if (injectedIds.has(def.nodeId)) continue; + const qn = def.qualifiedName; + if (qn === undefined) continue; + injectedIds.add(def.nodeId); + const simpleName = qn.includes('.') ? qn.slice(qn.lastIndexOf('.') + 1) : qn; + let list = scopeAug.get(simpleName); + if (list === undefined) { + list = []; + scopeAug.set(simpleName, list); + } + list.push({ def, origin: 'namespace' }); + } + + const tb = scope.typeBindings as Map; + for (const sibling of bucket.moduleScopes) { + if (sibling.filePath === filePath) continue; + for (const [name, ref] of sibling.scope.typeBindings) { + if (tb.has(name)) continue; + tb.set(name, ref); + } + } + + for (const sibParsed of bucket.parsed) { + if (sibParsed.filePath === filePath) continue; + for (const sibScope of sibParsed.scopes) { + if (sibScope.kind !== 'Class') continue; + for (const [name, ref] of sibScope.typeBindings) { + if (ref.source === 'self') continue; + if (tb.has(name)) continue; + tb.set(name, ref); + } + } + } + } + } +} + +function sharedSegmentCount(a: string, b: string): number { + const sa = a.replace(/\\/g, '/').split('/'); + const sb = b.replace(/\\/g, '/').split('/'); + let i = 0; + while (i < sa.length && i < sb.length && sa[i] === sb[i]) i++; + return i; +} diff --git a/gitnexus/src/core/ingestion/languages/java/query.ts b/gitnexus/src/core/ingestion/languages/java/query.ts index 3fabbb7bf..e1e581ad5 100644 --- a/gitnexus/src/core/ingestion/languages/java/query.ts +++ b/gitnexus/src/core/ingestion/languages/java/query.ts @@ -102,6 +102,47 @@ const JAVA_SCOPE_QUERY = ` declarator: (variable_declarator name: (identifier) @type-binding.name)) @type-binding.annotation +;; Type bindings — var u = svc.getUser(); (Java 10+ call-result inference) +(local_variable_declaration + type: (type_identifier) @_var_type + (#eq? @_var_type "var") + declarator: (variable_declarator + name: (identifier) @type-binding.name + value: (method_invocation + name: (identifier) @type-binding.type))) @type-binding.call-result + +;; Type bindings — var alias = u; (Java 10+ alias inference) +(local_variable_declaration + type: (type_identifier) @_var_type + (#eq? @_var_type "var") + declarator: (variable_declarator + name: (identifier) @type-binding.name + value: (identifier) @type-binding.type)) @type-binding.alias + +;; Type bindings — var addr = user.address; (Java 10+ field-access alias) +(local_variable_declaration + type: (type_identifier) @_var_type + (#eq? @_var_type "var") + declarator: (variable_declarator + name: (identifier) @type-binding.name + value: (field_access + field: (identifier) @type-binding.type))) @type-binding.alias + +;; Type bindings — enhanced-for with var: for (var user : users) +(enhanced_for_statement + (type_identifier) @_var_type + (#eq? @_var_type "var") + (identifier) @type-binding.name + (identifier) @type-binding.type) @type-binding.alias + +;; Enhanced-for with var + method iterable: for (var user : data.values()) +(enhanced_for_statement + (type_identifier) @_var_type + (#eq? @_var_type "var") + (identifier) @type-binding.name + (method_invocation + object: (identifier) @type-binding.type)) @type-binding.alias + ;; Type bindings — var u = new User(); (Java 10+ local variable type inference) ;; tree-sitter-java parses \`var\` as a \`type_identifier\` with text "var". ;; The type-binding.constructor anchor fires when the rhs is an @@ -143,6 +184,16 @@ const JAVA_SCOPE_QUERY = ` type: (generic_type) @type-binding.type name: (identifier) @type-binding.name) @type-binding.annotation +;; Type bindings — instanceof pattern (Java 16+): if (obj instanceof User user) +(instanceof_expression + (type_identifier) @type-binding.type + (identifier) @type-binding.name) @type-binding.pattern + +;; Type bindings — switch case pattern (Java 21+): case User user -> +(type_pattern + (type_identifier) @type-binding.type + (identifier) @type-binding.name) @type-binding.pattern + ;; References — all method calls: foo() and obj.method() ;; tree-sitter-java's query engine drops negation-based \`!object\` ;; patterns when a positive \`object:\` pattern exists for the same @@ -166,6 +217,30 @@ const JAVA_SCOPE_QUERY = ` (object_creation_expression type: (scoped_type_identifier) @reference.call.constructor.qualified) @reference.call.constructor +;; References — method references: User::getName, obj::method +(method_reference + (identifier) @reference.receiver + (identifier) @reference.name) @reference.call.member + +;; References — this::method and super::method +(method_reference + (this) @reference.receiver + (identifier) @reference.name) @reference.call.member + +(method_reference + (super) @reference.receiver + (identifier) @reference.name) @reference.call.member + +;; References — field_access::method: responseBuilder::buildResponse +(method_reference + (field_access) @reference.receiver + (identifier) @reference.name) @reference.call.member + +;; References — constructor references: User::new +(method_reference + (identifier) @reference.name + "new") @reference.call.constructor + ;; References — field/property writes: obj.name = "x" (assignment_expression left: (field_access diff --git a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts index dac974cc7..a94c1c3b1 100644 --- a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts @@ -4,53 +4,29 @@ * * ## Registry-primary parity status * - * Java is **not** in `MIGRATED_LANGUAGES` — the scope-resolution - * registry runs in shadow mode only. Parity in forced registry mode - * (`REGISTRY_PRIMARY_JAVA=1`) is 143/172 (83%). The 29 gaps fall into: + * Java is in `MIGRATED_LANGUAGES` — the scope-resolution registry is + * the primary call-resolution path. Parity: 178/178 (100%). * - * - switch pattern binding / sealed-class exhaustiveness - * - Map.values() / entrySet() iteration type propagation - * - assignment / method chain return-type propagation across files - * - virtual dispatch / interface default methods - * - * These are the same category of advanced-resolution gaps seen in prior - * migrations (Python, C#, Go). Parity is below the ≥99% flip threshold - * per RFC §6.4. - * - * **CI visibility:** Because Java is absent from `MIGRATED_LANGUAGES`, - * the parity CI workflow (`ci-scope-parity.yml`) does not run Java in - * either `REGISTRY_PRIMARY_JAVA=0` or `=1` mode. Regressions in forced - * mode are only visible via manual `REGISTRY_PRIMARY_JAVA=1 npx vitest - * run java.test.ts`. Before flipping Java to registry-primary, a - * non-required CI step should be added to run Java tests in forced mode - * and report parity as a dashboard input. - * - * **Parity baseline (29 failures):** The 29 gaps in forced registry mode - * are tracked in this PR (#1482) and this JSDoc. If the gap count - * changes (up or down), update this baseline accordingly. - * - * ### Known flip-blockers (must fix before adding to MIGRATED_LANGUAGES) - * - * - Varargs arity: fixed-prefix count is now preserved, but no - * integration fixture exercises the 0-arg rejection path yet. - * - Static import resolution: `import static X.Y.m` now correctly - * resolves to `X/Y.java` (the class), not `X/Y/m.java` (the member). - * Edge cases with nested classes may remain. - * - Generic superclass receiver binding: `BaseModel` now strips - * to `BaseModel` via JVM type-erasure fallback in `stripGeneric`. - * - Wildcard import (`import com.example.*`) file selection is - * nondeterministic when multiple classes share a package directory. - * May produce wrong-file edges in forced mode. - * - Qualified generic type parameters in field/parameter annotations - * (`com.example.BaseModel`) — rare in practice but may miss - * resolution when the full qualifier is present with generics. + * **CI visibility:** The parity CI workflow (`ci-scope-parity.yml`) + * runs Java tests in both `REGISTRY_PRIMARY_JAVA=0` and `=1` modes + * automatically. */ -import type { ParsedFile } from 'gitnexus-shared'; +import type { ParsedFile, TypeRef } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../../graph/types.js'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; -import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; +import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; +import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import { + isClassLike, + lookupBindingsAt, + namesAtScope, + populateClassOwnedMembers, +} from '../../scope-resolution/scope/walkers.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { followChainPostFinalize } from '../../scope-resolution/passes/imported-return-types.js'; import { javaProvider } from '../java.js'; import { javaArityCompatibility, @@ -58,6 +34,7 @@ import { resolveJavaImportTarget, type JavaResolveContext, } from './index.js'; +import { populateJavaPackageSiblings } from './package-siblings.js'; const javaScopeResolver: ScopeResolver = { language: SupportedLanguages.Java, @@ -76,22 +53,167 @@ const javaScopeResolver: ScopeResolver = { arityCompatibility: (callsite, def) => javaArityCompatibility(def, callsite), - buildMro: (graph, parsedFiles, nodeLookup) => - buildMro(graph, parsedFiles, nodeLookup, defaultLinearize), + buildMro: buildJavaMro, populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed), isSuperReceiver: (text) => text.trim() === 'super', - // Java is statically typed — field-fallback heuristic stays off fieldFallbackOnMethodLookup: false, propagatesReturnTypesAcrossImports: true, - - // Java doesn't collapse member calls - collapseMemberCallsByCallerTarget: false, - - // Hoist return-type bindings to Module scope for cross-file propagation + collapseMemberCallsByCallerTarget: true, hoistTypeBindingsToModule: true, + + populateNamespaceSiblings: populateJavaPackageSiblings, + populateRangeBindings: populateJavaCrossFileReturnTypes, }; export { javaScopeResolver }; + +function populateJavaCrossFileReturnTypes( + parsedFiles: readonly ParsedFile[], + indexes: ScopeResolutionIndexes, +): void { + const moduleScopeByFile = new Map(); + const classScopesByFile = new Map(); + for (const parsed of parsedFiles) { + const ms = parsed.scopes.find((s) => s.kind === 'Module'); + if (ms !== undefined) moduleScopeByFile.set(parsed.filePath, ms); + const cs = parsed.scopes.filter((s) => s.kind === 'Class'); + if (cs.length > 0) classScopesByFile.set(parsed.filePath, cs); + } + + for (const parsed of parsedFiles) { + const importerModule = moduleScopeByFile.get(parsed.filePath); + if (importerModule === undefined) continue; + + const ambiguousMirrors = new Set(); + for (const name of namesAtScope(importerModule.id, indexes)) { + const refs = lookupBindingsAt(importerModule.id, name, indexes); + for (const ref of refs) { + if (ref.origin !== 'import' && ref.origin !== 'reexport') continue; + if (!isClassLike(ref.def.type)) continue; + + const sourceModule = moduleScopeByFile.get(ref.def.filePath); + if (sourceModule === undefined) continue; + + const tb = importerModule.typeBindings as Map; + for (const [srcName, srcRef] of sourceModule.typeBindings) { + if (srcRef.source !== 'return-annotation') continue; + if (ambiguousMirrors.has(srcName)) continue; + const existing = tb.get(srcName); + if (existing !== undefined && existing.rawName !== srcRef.rawName) { + ambiguousMirrors.add(srcName); + tb.delete(srcName); + continue; + } + if (existing === undefined) tb.set(srcName, srcRef); + } + + for (const classScope of classScopesByFile.get(ref.def.filePath) ?? []) { + for (const [srcName, srcRef] of classScope.typeBindings) { + if (srcRef.source === 'self' || srcRef.source === 'parameter-annotation') continue; + if (ambiguousMirrors.has(srcName)) continue; + const existing = tb.get(srcName); + if (existing !== undefined && existing.rawName !== srcRef.rawName) { + ambiguousMirrors.add(srcName); + tb.delete(srcName); + continue; + } + if (existing === undefined) tb.set(srcName, srcRef); + } + } + } + } + + for (const [name, ref] of importerModule.typeBindings) { + const resolved = followChainPostFinalize(ref, importerModule.id, indexes); + if (resolved !== ref) { + (importerModule.typeBindings as Map).set(name, resolved); + } + } + } + + for (const parsed of parsedFiles) { + const moduleScopeId = moduleScopeByFile.get(parsed.filePath)?.id; + for (const scope of parsed.scopes) { + if (scope.id === moduleScopeId) continue; + for (const [name, ref] of scope.typeBindings) { + const resolved = followChainPostFinalize(ref, scope.id, indexes); + if (resolved !== ref) { + (scope.typeBindings as Map).set(name, resolved); + } + } + } + } +} + +function buildJavaMro( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, +): Map { + const mro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize); + + const defIdByGraphId = new Map(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + if (!isClassLike(def.type)) continue; + const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup); + if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId); + } + } + + const directImpls = new Map(); + for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) { + const source = defIdByGraphId.get(rel.sourceId); + const target = defIdByGraphId.get(rel.targetId); + if (source === undefined || target === undefined) continue; + let list = directImpls.get(source); + if (list === undefined) { + list = []; + directImpls.set(source, list); + } + if (!list.includes(target)) list.push(target); + } + + for (const [classDefId, extendsMro] of mro) { + const ancestorChain = [classDefId, ...extendsMro]; + const seeds: string[] = []; + for (const ancestorId of ancestorChain) { + for (const ifaceId of directImpls.get(ancestorId) ?? []) { + seeds.push(ifaceId); + } + } + if (seeds.length === 0) continue; + const interfaces = closeInterfaces(seeds, directImpls); + mro.set(classDefId, [...extendsMro, ...interfaces.filter((i) => !extendsMro.includes(i))]); + } + + for (const [classDefId, ifaces] of directImpls) { + if (mro.has(classDefId)) continue; + mro.set(classDefId, closeInterfaces([...ifaces], directImpls)); + } + + return mro; +} + +function closeInterfaces( + seeds: readonly string[], + directImpls: ReadonlyMap, +): string[] { + const out: string[] = []; + const seen = new Set(); + const queue: string[] = [...seeds]; + let head = 0; + while (head < queue.length) { + const cur = queue[head++]!; + if (seen.has(cur)) continue; + seen.add(cur); + out.push(cur); + for (const next of directImpls.get(cur) ?? []) { + if (!seen.has(next)) queue.push(next); + } + } + return out; +} diff --git a/gitnexus/src/core/ingestion/languages/php/namespace-siblings.ts b/gitnexus/src/core/ingestion/languages/php/namespace-siblings.ts index 20b99d971..93ad2b84e 100644 --- a/gitnexus/src/core/ingestion/languages/php/namespace-siblings.ts +++ b/gitnexus/src/core/ingestion/languages/php/namespace-siblings.ts @@ -28,8 +28,6 @@ import type { BindingRef, ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { getPhpParser } from './query.js'; -import { getTreeSitterBufferSize } from '../../constants.js'; -import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; // ─── PHP file structure extraction ────────────────────────────────────────── @@ -40,21 +38,94 @@ interface PhpFileStructure { type PhpTree = ReturnType['parse']>; +const NAMESPACE_RE = /^\s*namespace\s+([\w\\]+)\s*[;{]/i; +const HEREDOC_START_RE = /<<<\s*['"]?(\w+)['"]?\s*$/; + +/** + * Extract a PHP namespace declaration from raw source without tree-sitter. + * + * Single-pass line scanner that skips heredoc/nowdoc bodies, block + * comments, and single-line comments before matching. This avoids the + * false positives that a multiline regex produces when `namespace` appears + * inside a heredoc, nowdoc, string, or comment. + */ +export function extractNamespaceViaScanner(content: string): string { + const lines = content.split('\n'); + let inBlockComment = false; + let heredocDelimiter: string | null = null; + + for (const raw of lines) { + if (heredocDelimiter !== null) { + const trimmed = raw.trim(); + if (trimmed === heredocDelimiter + ';' || trimmed === heredocDelimiter) { + heredocDelimiter = null; + } + continue; + } + + if (inBlockComment) { + if (raw.includes('*/')) { + inBlockComment = false; + } + continue; + } + + let line = raw; + + const blockStart = line.indexOf('/*'); + if (blockStart >= 0) { + const blockEnd = line.indexOf('*/', blockStart + 2); + if (blockEnd >= 0) { + line = line.slice(0, blockStart) + line.slice(blockEnd + 2); + } else { + line = line.slice(0, blockStart); + inBlockComment = true; + } + } + + const slashIdx = line.indexOf('//'); + const hashIdx = line.indexOf('#'); + if (slashIdx >= 0 && (hashIdx < 0 || slashIdx < hashIdx)) { + line = line.slice(0, slashIdx); + } else if (hashIdx >= 0) { + line = line.slice(0, hashIdx); + } + + const heredocMatch = raw.match(HEREDOC_START_RE); + if (heredocMatch) { + heredocDelimiter = heredocMatch[1]; + continue; + } + + const stripped = line.replace(/<\?php/gi, '').replace(/declare\s*\([^)]*\)\s*;?/gi, ''); + const nsMatch = stripped.match(NAMESPACE_RE); + if (nsMatch) { + return nsMatch[1]; + } + } + + return ''; +} + /** * Extract the declared namespace from a PHP file's source. * Uses the cached AST tree when available to avoid re-parsing. + * + * When no cached tree is available (worker-parsed files can't transfer + * native Tree objects across MessageChannels), uses a line scanner + * instead of re-parsing every file with tree-sitter. For 16K+ PHP files + * this eliminates ~16K tree-sitter re-parses during the namespace-siblings + * pass. See: https://github.com/abhigyanpatwari/GitNexus/issues/1741 */ -function extractPhpFileStructure(content: string, cachedTree: unknown): PhpFileStructure { - const tree = - (cachedTree as PhpTree | undefined) ?? - parseSourceSafe(getPhpParser(), content, undefined, { - bufferSize: getTreeSitterBufferSize(content), - }); +export function extractPhpFileStructure(content: string, cachedTree: unknown): PhpFileStructure { + if (!cachedTree) { + return { namespace: extractNamespaceViaScanner(content) }; + } // Walk top-level nodes looking for namespace_definition. // PHP files have at most one namespace declaration (PSR-4 convention). // `namespace_definition` has a `name:` field of type `namespace_name`. - const root = tree.rootNode; + const root = (cachedTree as PhpTree).rootNode; for (let i = 0; i < root.namedChildCount; i++) { const child = root.namedChild(i); if (child === null) continue; @@ -240,35 +311,28 @@ export function populatePhpNamespaceSiblings( } } - // Step 3b: Inject fully-qualified-name bindings into every PHP file's - // Module scope. PHP `\App\Models\User` (leading-backslash FQN) and - // `App\Models\User` (already-qualified relative) on a parameter or - // typed receiver must resolve to the exact namespace-qualified class - // regardless of which simple-name `User` the caller's `use` imports - // shadowed. The shared `findClassBindingInScope` scope-chain walk - // consumes these augmentations via `lookupBindingsAt`, so adding the - // qualified key on every file's module scope routes FQN-receivers to - // the right def. Codex PR #1497 review, finding 1. + // Step 3b: Register FQN bindings in a workspace-level map instead of + // per-scope augmentations. PHP `\App\Models\User` and `App\Models\User` + // must resolve regardless of which file the lookup originates from. + // `lookupBindingsAt` consults `workspaceFqnBindings` as a third source. // - // Cost: O(PHP files × class-like defs in the workspace) augmentation - // entries. Bounded and acceptable in practice — typical PHP projects - // have hundreds of files and classes, not tens of thousands. - for (const parsed of parsedFiles) { - const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); - if (moduleScope === undefined) continue; - const moduleScopeId = moduleScope.id; - - for (const [ns, bucket] of buckets) { - if (ns === '') continue; // global-namespace classes have no qualified form to register - for (const def of bucket.classDefs) { - const q = def.qualifiedName ?? ''; - const simpleName = q.includes('\\') ? q.slice(q.lastIndexOf('\\') + 1) : q; - if (simpleName === '') continue; - const fqn = `${ns}\\${simpleName}`; - const arr = getAugmentationBucket(augmentations, moduleScopeId, fqn); - if (arr.some((b) => b.def.nodeId === def.nodeId)) continue; - arr.push({ def, origin: 'namespace' }); + // Cost: O(class-like defs) entries — NOT O(files × classDefs). For 16K + // PHP files with 5K classes, this is 5K entries instead of 80M. + const fqnMap = indexes.workspaceFqnBindings as Map; + for (const [ns, bucket] of buckets) { + if (ns === '') continue; + for (const def of bucket.classDefs) { + const q = def.qualifiedName ?? ''; + const simpleName = q.includes('\\') ? q.slice(q.lastIndexOf('\\') + 1) : q; + if (simpleName === '') continue; + const fqn = `${ns}\\${simpleName}`; + let arr = fqnMap.get(fqn); + if (arr === undefined) { + arr = []; + fqnMap.set(fqn, arr); } + if (arr.some((b) => b.def.nodeId === def.nodeId)) continue; + arr.push({ def, origin: 'namespace' }); } } @@ -281,6 +345,9 @@ export function populatePhpNamespaceSiblings( // // Additionally, mirror from files that are imported via `use` (different // namespace) so return types from dependencies are chain-followable too. + const parsedByPath = new Map(); + for (const p of parsedFiles) parsedByPath.set(p.filePath, p); + for (const parsed of parsedFiles) { const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); if (moduleScope === undefined) continue; @@ -322,7 +389,7 @@ export function populatePhpNamespaceSiblings( // Mirror return-type bindings from accessible files. for (const srcFilePath of accessibleFiles) { - const srcParsed = parsedFiles.find((p) => p.filePath === srcFilePath); + const srcParsed = parsedByPath.get(srcFilePath); if (srcParsed === undefined) continue; const srcModuleScope = srcParsed.scopes.find((s) => s.kind === 'Module'); if (srcModuleScope === undefined) continue; diff --git a/gitnexus/src/core/ingestion/model/scope-resolution-indexes.ts b/gitnexus/src/core/ingestion/model/scope-resolution-indexes.ts index a3fba7b30..561595ac5 100644 --- a/gitnexus/src/core/ingestion/model/scope-resolution-indexes.ts +++ b/gitnexus/src/core/ingestion/model/scope-resolution-indexes.ts @@ -77,6 +77,12 @@ export interface ScopeResolutionIndexes { * are returned first and win duplicate `def.nodeId` metadata, with * unique augmentations appended after. See I8. */ readonly bindingAugmentations: ReadonlyMap>; + /** Workspace-level FQN binding lookup. Populated by PHP namespace- + * siblings Step 3b as a shared map instead of per-scope duplication. + * Consulted by `lookupBindingsAt` as a third source after finalized + * and per-scope augmented bindings. Keys are backslash-separated FQNs + * (e.g. `App\Models\User`). */ + readonly workspaceFqnBindings: ReadonlyMap; /** Pre-resolution usage facts; consumed by the resolution phase. */ readonly referenceSites: readonly ReferenceSite[]; /** SCC condensation of the file-level import graph — callers that want diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index bf954b5cf..4d06b8d58 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -46,6 +46,7 @@ import { import { createResolutionContext } from '../model/resolution-context.js'; import { ASTCache, createASTCache } from '../ast-cache.js'; import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared'; +import { isRegistryPrimary } from '../registry-primary-flag.js'; import { readFileContents } from '../filesystem-walker.js'; import { isLanguageAvailable } from '../../tree-sitter/parser-loader.js'; import { createWorkerPool, WorkerPoolInitializationError } from '../workers/worker-pool.js'; @@ -606,11 +607,31 @@ export async function runChunkedParseAndResolve( if (chunkNeedsSynthesis[chunkIdx]) { anyChunkNeedsWildcardSynth = true; } - for (const item of chunkWorkerData.imports) deferredWorkerImports.push(item); - for (const item of chunkWorkerData.calls) deferredWorkerCalls.push(item); - for (const item of chunkWorkerData.heritage) deferredWorkerHeritage.push(item); - for (const item of chunkWorkerData.constructorBindings) - deferredConstructorBindings.push(item); + const skipFile = new Set(); + const checkFile = new Set(); + const shouldAccumulate = (filePath: string): boolean => { + if (checkFile.has(filePath)) return true; + if (skipFile.has(filePath)) return false; + const lang = getLanguageFromFilename(filePath); + if (lang !== null && isRegistryPrimary(lang)) { + skipFile.add(filePath); + return false; + } + checkFile.add(filePath); + return true; + }; + for (const item of chunkWorkerData.imports) { + if (shouldAccumulate(item.filePath)) deferredWorkerImports.push(item); + } + for (const item of chunkWorkerData.calls) { + if (shouldAccumulate(item.filePath)) deferredWorkerCalls.push(item); + } + for (const item of chunkWorkerData.heritage) { + if (shouldAccumulate(item.filePath)) deferredWorkerHeritage.push(item); + } + for (const item of chunkWorkerData.constructorBindings) { + if (shouldAccumulate(item.filePath)) deferredConstructorBindings.push(item); + } // Aggregate worker-produced ParsedFile artifacts so scope- // resolution can use them as a re-extraction cache (skips its // own tree-sitter re-parse on warm runs). @@ -618,7 +639,9 @@ export async function runChunkedParseAndResolve( for (const item of chunkWorkerData.parsedFiles) allParsedFiles.push(item); } if (chunkWorkerData.assignments?.length) { - for (const item of chunkWorkerData.assignments) deferredAssignments.push(item); + for (const item of chunkWorkerData.assignments) { + if (shouldAccumulate(item.filePath)) deferredAssignments.push(item); + } } if (chunkWorkerData.fileScopeBindings?.length) { diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index 9016a68b5..56e39962a 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -77,6 +77,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet = new Set s.kind === 'Module'); if (moduleScope !== undefined) return moduleScope; - if (scopeDrafts.length === 0 && matchCount === 0) { - const range: Range = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 }; - const synthetic = makeDraft( - makeScopeId({ filePath, range, kind: 'Module' }), - null, - 'Module', - range, - filePath, - ); - scopeDrafts.push(synthetic); - return synthetic; + // Synthesize a Module scope spanning all captures in the file. + // Computed from ALL captures (scope, declaration, reference, etc.) + // so the range covers top-level references that appear after the + // last inner scope — not just inner Function/Class scopes. + let endLine = 0; + let endCol = 0; + for (const match of allMatches) { + for (const capture of Object.values(match)) { + if ( + capture.range.endLine > endLine || + (capture.range.endLine === endLine && capture.range.endCol > endCol) + ) { + endLine = capture.range.endLine; + endCol = capture.range.endCol; + } + } } - - throw new Error( - `ScopeExtractor: no Module scope found for '${filePath}'. ` + - `Provider must emit at least one @scope.module capture per file.`, + const range: Range = { startLine: 0, startCol: 0, endLine, endCol }; + const synthetic = makeDraft( + makeScopeId({ filePath, range, kind: 'Module' }), + null, + 'Module', + range, + filePath, ); + + scopeDrafts.push(synthetic); + return synthetic; } function draftToScope(draft: ScopeDraft): Scope { diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index cdfbe3d8e..2d0733684 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -705,6 +705,7 @@ export interface ScopeResolver { callerScope: ScopeId, scopes: ScopeResolutionIndexes, parsedFiles: readonly ParsedFile[], + callsite?: Callsite, ) => SymbolDefinition | 'ambiguous' | undefined; /** diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index fcfb6ef32..306bae73d 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -108,7 +108,7 @@ export function emitFreeCallFallback( if (site.callForm === 'constructor') { const classDef = findClassBindingInScope(site.inScope, site.name, scopes); if (classDef !== undefined) { - fnDef = pickConstructorOrClass(classDef, workspaceIndex); + fnDef = pickConstructorOrClass(classDef, workspaceIndex, scopes); } } // Implicit-this overload narrowing: an unqualified call inside @@ -578,12 +578,22 @@ function logicalCallableKey(def: SymbolDefinition): string { function pickConstructorOrClass( classDef: SymbolDefinition, workspaceIndex: WorkspaceResolutionIndex, + scopes?: ScopeResolutionIndexes, ): SymbolDefinition { const classScope = workspaceIndex.classScopeByDefId.get(classDef.nodeId); if (classScope === undefined) return classDef; for (const def of classScope.ownedDefs) { if (def.type === 'Constructor') return def; } + if (scopes !== undefined) { + for (const childId of scopes.scopeTree.getChildren(classScope.id)) { + const childScope = scopes.scopeTree.getScope(childId); + if (childScope === undefined || childScope.kind === 'Class') continue; + for (const def of childScope.ownedDefs) { + if (def.type === 'Constructor') return def; + } + } + } return classDef; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 59b6c323d..ef1d4a0eb 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -501,6 +501,7 @@ export function emitReceiverBoundCalls( site.inScope, scopes, parsedFiles, + site, ); if (memberDef === 'ambiguous') { // Same-name ambiguity across inline-namespace children (#1564): diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts index 31b712d78..686d70fc7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts @@ -173,6 +173,16 @@ export const scopeResolutionPhase: PipelinePhase = { provider, ); + // Release file contents and pre-extracted entries after each language + // to reduce memory pressure. For large codebases (16K+ PHP files), + // holding all source code simultaneously with scope trees causes OOM. + // See: https://github.com/abhigyanpatwari/GitNexus/issues/1741 + files.length = 0; + contents.clear(); + for (const fp of filePaths) { + preExtractedByPath.delete(fp); + } + anyRan = true; totalFiles += stats.filesProcessed; totalImports += stats.importsEmitted; diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index a9bb188d2..df6d259b5 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -55,20 +55,34 @@ export function lookupBindingsAt( ): readonly BindingRef[] { const finalized = scopes.bindings.get(scopeId)?.get(name); const augmented = scopes.bindingAugmentations.get(scopeId)?.get(name); + const workspace = scopes.workspaceFqnBindings?.get(name); const fLen = finalized?.length ?? 0; const aLen = augmented?.length ?? 0; - if (fLen === 0 && aLen === 0) return EMPTY_BINDINGS; - if (aLen === 0) return finalized!; - if (fLen === 0) return augmented!; + const wLen = workspace?.length ?? 0; + if (fLen === 0 && aLen === 0 && wLen === 0) return EMPTY_BINDINGS; + if (aLen === 0 && wLen === 0) return finalized!; + if (fLen === 0 && wLen === 0) return augmented!; + if (fLen === 0 && aLen === 0) return workspace!; const seen = new Set(); const out: BindingRef[] = []; - for (const r of finalized!) { - seen.add(r.def.nodeId); - out.push(r); + if (fLen > 0) { + for (const r of finalized!) { + seen.add(r.def.nodeId); + out.push(r); + } } - for (const r of augmented!) { - if (seen.has(r.def.nodeId)) continue; - out.push(r); + if (aLen > 0) { + for (const r of augmented!) { + if (seen.has(r.def.nodeId)) continue; + seen.add(r.def.nodeId); + out.push(r); + } + } + if (wLen > 0) { + for (const r of workspace!) { + if (seen.has(r.def.nodeId)) continue; + out.push(r); + } } return out; } diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-ambiguous-normalized/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-ambiguous-normalized/caller.cpp new file mode 100644 index 000000000..fcb416b39 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-ambiguous-normalized/caller.cpp @@ -0,0 +1,5 @@ +#include "lib.h" + +void run() { + outer::foo(42); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-ambiguous-normalized/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-ambiguous-normalized/lib.h new file mode 100644 index 000000000..703fb15e5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-ambiguous-normalized/lib.h @@ -0,0 +1,10 @@ +#pragma once + +namespace outer { + inline namespace v1 { + void foo(int x); + } + inline namespace v2 { + void foo(long y); + } +} diff --git a/gitnexus/test/integration/php-pipeline-benchmark.test.ts b/gitnexus/test/integration/php-pipeline-benchmark.test.ts new file mode 100644 index 000000000..9afd4c569 --- /dev/null +++ b/gitnexus/test/integration/php-pipeline-benchmark.test.ts @@ -0,0 +1,199 @@ +/** + * PHP ingestion pipeline benchmark. + * + * Generates synthetic PHP codebases at increasing scales and measures + * wall-clock time and peak heap through the full pipeline — parsing, + * scope extraction, namespace-siblings (Steps 1-4), and call resolution. + * + * Run: GITNEXUS_BENCH=1 npx vitest run test/integration/php-pipeline-benchmark.test.ts + * + * The benchmark uses workers (production path) by default. Set + * skipWorkers to test the sequential fallback path. + */ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; + +const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1'; + +interface BenchResult { + fileCount: number; + classCount: number; + namespaceCount: number; + elapsedMs: number; + peakHeapMB: number; + nodeCount: number; + edgeCount: number; +} + +function generatePhpFixture( + fileCount: number, + namespacesPerLevel: number, +): { dir: string; classCount: number; namespaceCount: number } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `php-bench-${fileCount}-`)); + const namespaces: string[] = []; + + for (let i = 0; i < namespacesPerLevel; i++) { + for (let j = 0; j < namespacesPerLevel; j++) { + namespaces.push(`App\\Module${i}\\Sub${j}`); + } + } + + const classCount = fileCount; + const namespaceCount = namespaces.length; + + for (let f = 0; f < fileCount; f++) { + const ns = namespaces[f % namespaces.length]; + const nsDir = ns.replace(/\\/g, '/'); + const className = `Class${f}`; + const targetDir = path.join(dir, nsDir); + fs.mkdirSync(targetDir, { recursive: true }); + + const siblingIdx = (f + 1) % fileCount; + const siblingClass = `Class${siblingIdx}`; + + const crossNsIdx = (f + Math.floor(fileCount / 3)) % fileCount; + const crossNs = namespaces[crossNsIdx % namespaces.length]; + const crossClass = `Class${crossNsIdx}`; + + const content = [ + 'id;', + ' }', + '', + ` public function process(): ${siblingClass}`, + ' {', + ` $sibling = new ${siblingClass}();`, + ' return $sibling;', + ' }', + '', + ns !== crossNs + ? [ + ` public function crossCall(): ${crossClass}`, + ' {', + ` $cross = new ${crossClass}();`, + ` $cross->getId();`, + ' return $cross;', + ' }', + ].join('\n') + : '', + '}', + '', + ] + .filter(Boolean) + .join('\n'); + + fs.writeFileSync(path.join(targetDir, `${className}.php`), content); + } + + const composerJson = { + name: 'bench/php-pipeline', + autoload: { 'psr-4': { 'App\\': 'App/' } }, + }; + fs.writeFileSync(path.join(dir, 'composer.json'), JSON.stringify(composerJson, null, 2)); + + return { dir, classCount, namespaceCount }; +} + +async function runBenchmark( + fileCount: number, + nsLevels: number, + budgetMs: number, +): Promise { + const { dir, classCount, namespaceCount } = generatePhpFixture(fileCount, nsLevels); + + let peakHeapMB = 0; + const heapSampler = setInterval(() => { + const heap = process.memoryUsage().heapUsed / 1024 / 1024; + if (heap > peakHeapMB) peakHeapMB = heap; + }, 50); + + try { + const start = Date.now(); + const result = await Promise.race([ + runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`Pipeline exceeded ${budgetMs}ms at ${fileCount} files`)), + budgetMs, + ), + ), + ]); + const elapsedMs = Date.now() - start; + + return { + fileCount, + classCount, + namespaceCount, + elapsedMs, + peakHeapMB: Math.round(peakHeapMB), + nodeCount: result.graph.nodeCount, + edgeCount: result.graph.relationshipCount, + }; + } finally { + clearInterval(heapSampler); + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function printResults(label: string, results: BenchResult[]) { + console.log(`\n${label}`); + console.log('┌──────────┬─────────┬──────────┬───────────┬──────────┬───────┬───────┐'); + console.log('│ Files │ Classes │ NS Count │ Time (ms) │ Heap MB │ Nodes │ Edges │'); + console.log('├──────────┼─────────┼──────────┼───────────┼──────────┼───────┼───────┤'); + for (const r of results) { + console.log( + `│ ${String(r.fileCount).padStart(8)} │ ${String(r.classCount).padStart(7)} │ ${String(r.namespaceCount).padStart(8)} │ ${String(r.elapsedMs).padStart(9)} │ ${String(r.peakHeapMB).padStart(8)} │ ${String(r.nodeCount).padStart(5)} │ ${String(r.edgeCount).padStart(5)} │`, + ); + } + console.log('└──────────┴─────────┴──────────┴───────────┴──────────┴───────┴───────┘'); + + if (results.length >= 2) { + console.log('\nScaling ratios (time_ratio / file_ratio):'); + for (let i = 1; i < results.length; i++) { + const fileRatio = results[i].fileCount / results[i - 1].fileCount; + const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs; + const scaling = timeRatio / fileRatio; + console.log( + ` ${results[i - 1].fileCount} → ${results[i].fileCount}: ${scaling.toFixed(2)}x (${scaling < 1.5 ? 'linear' : scaling < 3 ? 'superlinear' : 'WARNING: quadratic'})`, + ); + } + } +} + +describe.skipIf(!BENCH_ENABLED)('PHP pipeline benchmark', () => { + it('scales with file count (workers enabled)', async () => { + const scales = [100, 250, 500]; + const results: BenchResult[] = []; + + for (const fileCount of scales) { + const nsLevels = Math.max(2, Math.ceil(Math.sqrt(fileCount / 4))); + const result = await runBenchmark(fileCount, nsLevels, 180_000); + results.push(result); + console.log( + ` ${fileCount} files: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`, + ); + } + + printResults('PHP Pipeline — Workers Enabled', results); + + for (let i = 1; i < results.length; i++) { + const fileRatio = results[i].fileCount / results[i - 1].fileCount; + const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs; + expect(timeRatio / fileRatio).toBeLessThan(3); + } + }, 300_000); +}); diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index e33fa6632..0d31b3f18 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -3044,7 +3044,7 @@ describe('C++ inline namespace — ambiguous same-name across inline children (# }); }); -describe('C++ inline namespace — ambiguous distinct signatures (conservative suppress)', () => { +describe('C++ inline namespace — distinct signatures resolved via call-site types', () => { let result: PipelineResult; beforeAll(async () => { @@ -3054,14 +3054,35 @@ describe('C++ inline namespace — ambiguous distinct signatures (conservative s ); }, 60000); - it('outer::foo(42) emits zero CALLS edges when v1 declares foo(int) and v2 declares foo(double)', () => { + it('outer::foo(42) emits exactly 1 CALLS edge to v1::foo(int) when v1 declares foo(int) and v2 declares foo(double)', () => { const calls = getRelationships(result, 'CALLS'); const fooCalls = calls.filter((c) => c.source === 'run' && c.target === 'foo'); - // Even though the two overloads have distinct signatures and a compiler - // could disambiguate via argument types, the `resolveQualifiedReceiverMember` - // hook lacks call-site arity/argument-type information, so multi-hit cases - // are conservatively suppressed. Documents the limitation noted in - // inline-namespaces.ts (Finding 1 of Claude review on #1600). + // Call-site arity and argument types are now threaded through the + // resolveQualifiedReceiverMember contract (#1632). narrowOverloadCandidates + // matches the exact type 'int' against v1::foo(int), producing exactly 1 edge. + expect(fooCalls).toHaveLength(1); + // Verify it resolved to v1::foo(int) at line 4 (0-indexed), not v2::foo(double) at line 7 + const targetNode = result.graph.getNode(fooCalls[0].rel.targetId); + expect(targetNode?.properties.startLine).toBe(4); + }); +}); + +describe('C++ inline namespace — ambiguous normalized signatures', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-inline-namespace-ambiguous-normalized'), + () => {}, + ); + }, 60000); + + it('outer::foo(42) emits zero CALLS edges when v1 declares foo(int) and v2 declares foo(long) — both normalize to int', () => { + const calls = getRelationships(result, 'CALLS'); + const fooCalls = calls.filter((c) => c.source === 'run' && c.target === 'foo'); + // int and long both normalize to 'int' via normalizeCppParamType, making + // the two candidates indistinguishable after normalization. The resolver + // must suppress rather than pick arbitrarily (isOverloadAmbiguousAfterNormalization). expect(fooCalls.length).toBe(0); }); }); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index f599835b5..486449aa9 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -332,11 +332,14 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly { describe('processCalls — Phase P class lookup fallback', () => { let graph: ReturnType; let ctx: ResolutionContext; + let prevRegistryJava: string | undefined; beforeEach(() => { graph = createKnowledgeGraph(); ctx = createResolutionContext(); + prevRegistryJava = process.env['REGISTRY_PRIMARY_JAVA']; + process.env['REGISTRY_PRIMARY_JAVA'] = 'false'; + }); + + afterEach(() => { + if (prevRegistryJava === undefined) delete process.env['REGISTRY_PRIMARY_JAVA']; + else process.env['REGISTRY_PRIMARY_JAVA'] = prevRegistryJava; }); it('uses lookupClassByName to override interface receiver types for cross-file virtual dispatch', async () => { @@ -2147,10 +2155,13 @@ describe('processNextjsFetchRoutes', () => { describe('processCallsFromExtracted — interface dispatch', () => { let graph: ReturnType; let ctx: ResolutionContext; + let prevRegistryJava: string | undefined; beforeEach(() => { graph = createKnowledgeGraph(); ctx = createResolutionContext(); + prevRegistryJava = process.env['REGISTRY_PRIMARY_JAVA']; + process.env['REGISTRY_PRIMARY_JAVA'] = 'false'; const ifaceFile = 'contracts/Action.java'; const runnerFile = 'runner.java'; const implA = 'impl/A.java'; @@ -2195,6 +2206,11 @@ describe('processCallsFromExtracted — interface dispatch', () => { }); }); + afterEach(() => { + if (prevRegistryJava === undefined) delete process.env['REGISTRY_PRIMARY_JAVA']; + else process.env['REGISTRY_PRIMARY_JAVA'] = prevRegistryJava; + }); + it('adds CALLS to interface method plus lower-confidence edges to implementing methods', async () => { const heritage: ExtractedHeritage[] = [ { filePath: 'impl/A.java', className: 'A', parentName: 'Action', kind: 'implements' }, @@ -2241,21 +2257,26 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { let graph: ReturnType; let ctx: ResolutionContext; let prevRegistryPython: string | undefined; + let prevRegistryJava: string | undefined; beforeEach(() => { graph = createKnowledgeGraph(); ctx = createResolutionContext(); // These tests exercise the LEGACY call-resolution DAG directly - // using .py fixtures. Python defaults to registry-primary now - // (MIGRATED_LANGUAGES), which gates call-processor out for - // Python files. Force the flag off so the legacy DAG runs. + // using .py/.java fixtures. Python and Java default to registry- + // primary now (MIGRATED_LANGUAGES), which gates call-processor + // out for those files. Force the flags off so the legacy DAG runs. prevRegistryPython = process.env['REGISTRY_PRIMARY_PYTHON']; process.env['REGISTRY_PRIMARY_PYTHON'] = 'false'; + prevRegistryJava = process.env['REGISTRY_PRIMARY_JAVA']; + process.env['REGISTRY_PRIMARY_JAVA'] = 'false'; }); afterEach(() => { if (prevRegistryPython === undefined) delete process.env['REGISTRY_PRIMARY_PYTHON']; else process.env['REGISTRY_PRIMARY_PYTHON'] = prevRegistryPython; + if (prevRegistryJava === undefined) delete process.env['REGISTRY_PRIMARY_JAVA']; + else process.env['REGISTRY_PRIMARY_JAVA'] = prevRegistryJava; }); const setupChildParent = () => { diff --git a/gitnexus/test/unit/group/sync.test.ts b/gitnexus/test/unit/group/sync.test.ts index 9f14db231..4fc320076 100644 --- a/gitnexus/test/unit/group/sync.test.ts +++ b/gitnexus/test/unit/group/sync.test.ts @@ -980,6 +980,129 @@ service OrderService { expect(nodeLink).toBeDefined(); }); }); + + it('manifest symbol resolution runs before closeLbug (issue #1802)', async () => { + const links: GroupManifestLink[] = [ + { + from: 'svc/orders', + to: 'svc/payments', + type: 'http', + contract: 'GET::/api/checkout', + role: 'consumer', + }, + ]; + + const config: GroupConfig = { + version: 1, + name: 'test', + description: '', + repos: { 'svc/orders': 'orders-repo', 'svc/payments': 'payments-repo' }, + links, + packages: {}, + detect: { + http: true, + grpc: false, + thrift: false, + topics: false, + shared_libs: false, + embedding_fallback: false, + workspace_deps: false, + }, + matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + }; + + const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js'); + + let closeLbugCalled = false; + let manifestResolvedWhilePoolOpen = false; + + const initSpy = vi.spyOn(poolAdapter, 'initLbug').mockResolvedValue(undefined); + const closeSpy = vi.spyOn(poolAdapter, 'closeLbug').mockImplementation(async () => { + closeLbugCalled = true; + }); + const execSpy = vi + .spyOn(poolAdapter, 'executeParameterized') + .mockImplementation( + async (_poolId: string, query: string, _params: Record) => { + if (query.includes('HANDLES_ROUTE')) { + manifestResolvedWhilePoolOpen = !closeLbugCalled; + } + return [ + { uid: 'real-uid-checkout', name: 'CheckoutHandler', filePath: 'src/checkout.ts' }, + ]; + }, + ); + + try { + const result = await syncGroup(config, { + resolveRepoHandle: async (_name, groupPath) => ({ + id: groupPath.replace(/\//g, '-'), + path: groupPath, + repoPath: '/tmp/' + groupPath, + storagePath: '/tmp/' + groupPath + '/.gitnexus', + }), + skipWrite: true, + }); + + // Manifest symbol resolution must run while pools are still open + expect(manifestResolvedWhilePoolOpen).toBe(true); + expect(closeLbugCalled).toBe(true); + + // The manifest cross-link must use the real UID from the DB, not synthetic + const manifestLinks = result.crossLinks.filter((cl) => cl.matchType === 'manifest'); + expect(manifestLinks).toHaveLength(1); + expect(manifestLinks[0].to.symbolUid).toBe('real-uid-checkout'); + expect(manifestLinks[0].to.symbolUid).not.toContain('manifest::'); + + // closeLbug must fire exactly twice (one per repo) + expect(closeSpy).toHaveBeenCalledTimes(2); + } finally { + initSpy.mockRestore(); + closeSpy.mockRestore(); + execSpy.mockRestore(); + } + }); + + it('extractorOverride no-DB path still produces synthetic manifest UIDs', async () => { + const links: GroupManifestLink[] = [ + { + from: 'svc/orders', + to: 'svc/payments', + type: 'http', + contract: 'GET::/api/checkout', + role: 'consumer', + }, + ]; + + const config: GroupConfig = { + version: 1, + name: 'test', + description: '', + repos: { 'svc/orders': 'orders-repo', 'svc/payments': 'payments-repo' }, + links, + packages: {}, + detect: { + http: true, + grpc: false, + thrift: false, + topics: false, + shared_libs: false, + embedding_fallback: false, + workspace_deps: false, + }, + matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + }; + + const result = await syncGroup(config, { + extractorOverride: async () => [], + skipWrite: true, + }); + + const manifestLinks = result.crossLinks.filter((cl) => cl.matchType === 'manifest'); + expect(manifestLinks).toHaveLength(1); + expect(manifestLinks[0].from.symbolUid).toBe('manifest::svc/orders::http::GET::/api/checkout'); + expect(manifestLinks[0].to.symbolUid).toBe('manifest::svc/payments::http::GET::/api/checkout'); + }); }); describe('stableRepoPoolId', () => { diff --git a/gitnexus/test/unit/php-namespace-extraction.test.ts b/gitnexus/test/unit/php-namespace-extraction.test.ts new file mode 100644 index 000000000..589a4628a --- /dev/null +++ b/gitnexus/test/unit/php-namespace-extraction.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'vitest'; +import { extractNamespaceViaScanner } from '../../src/core/ingestion/languages/php/namespace-siblings.js'; + +describe('extractNamespaceViaScanner', () => { + it('extracts standard namespace declaration', () => { + const src = ` { + const src = ` { + const src = ` { + const src = ` { + const src = ` { + const src = ` { + expect(extractNamespaceViaScanner('')).toBe(''); + }); + + it('handles uppercase NAMESPACE keyword (case-insensitive)', () => { + const src = ` { + const src = ` { + const src = [ + ' { + const src = [ + ' { + const src = [' { + const src = ` { + const src = ` { + const src = ` { + const src = ` { + const src = [' { + const src = ` { + const src = [ + ' { + const src = [ + ' { it('isolates flags per-language (one on does not affect others)', () => { process.env['REGISTRY_PRIMARY_PYTHON'] = 'true'; expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); - // Java is not in MIGRATED_LANGUAGES — default false stays + // Ruby is not in MIGRATED_LANGUAGES — default false stays // false regardless of Python's flag. - expect(isRegistryPrimary(SupportedLanguages.Java)).toBe(false); + expect(isRegistryPrimary(SupportedLanguages.Ruby)).toBe(false); }); it('respects a mid-process env-var mutation (no stale cache)', () => { - // Use Java — not in MIGRATED_LANGUAGES — so the unset default is + // Use Ruby — not in MIGRATED_LANGUAGES — so the unset default is // deterministically `false`, independent of which languages have // been flipped to registry-primary. - expect(isRegistryPrimary(SupportedLanguages.Java)).toBe(false); - process.env['REGISTRY_PRIMARY_JAVA'] = 'true'; - expect(isRegistryPrimary(SupportedLanguages.Java)).toBe(true); - delete process.env['REGISTRY_PRIMARY_JAVA']; - expect(isRegistryPrimary(SupportedLanguages.Java)).toBe(false); + expect(isRegistryPrimary(SupportedLanguages.Ruby)).toBe(false); + process.env['REGISTRY_PRIMARY_RUBY'] = 'true'; + expect(isRegistryPrimary(SupportedLanguages.Ruby)).toBe(true); + delete process.env['REGISTRY_PRIMARY_RUBY']; + expect(isRegistryPrimary(SupportedLanguages.Ruby)).toBe(false); }); it('handles the CPlusPlus → REGISTRY_PRIMARY_CPP mapping correctly', () => { @@ -150,7 +150,7 @@ describe('primaryLanguages', () => { it('returns exactly the flipped languages (env opts in unmigrated, opts out migrated)', () => { // Migrated languages are default-on; each must be opted out here when - // testing explicit env overrides. Java (unmigrated) opts in. + // testing explicit env overrides. Ruby (unmigrated) opts in. // Opt out every member of MIGRATED_LANGUAGES dynamically so this test // does not have to be updated each time a new language ships its // Ring 3 migration (C++ and PHP joined the set in their respective @@ -158,15 +158,15 @@ describe('primaryLanguages', () => { for (const lang of MIGRATED_LANGUAGES) { process.env[envVarNameFor(lang)] = 'false'; } - process.env['REGISTRY_PRIMARY_JAVA'] = '1'; + process.env['REGISTRY_PRIMARY_RUBY'] = '1'; const enabled = primaryLanguages(); expect(enabled.has(SupportedLanguages.Python)).toBe(false); expect(enabled.has(SupportedLanguages.CSharp)).toBe(false); expect(enabled.has(SupportedLanguages.Go)).toBe(false); expect(enabled.has(SupportedLanguages.CPlusPlus)).toBe(false); expect(enabled.has(SupportedLanguages.PHP)).toBe(false); - expect(enabled.has(SupportedLanguages.Java)).toBe(true); - // Only Java is on: migrated defaults overridden off, Java explicitly on. + expect(enabled.has(SupportedLanguages.Ruby)).toBe(true); + // Only Ruby is on: migrated defaults overridden off, Ruby explicitly on. expect(enabled.size).toBe(1); }); diff --git a/gitnexus/test/unit/scope-resolution/parse-worker-scope-integration.test.ts b/gitnexus/test/unit/scope-resolution/parse-worker-scope-integration.test.ts index b34ef33be..36e4f0ead 100644 --- a/gitnexus/test/unit/scope-resolution/parse-worker-scope-integration.test.ts +++ b/gitnexus/test/unit/scope-resolution/parse-worker-scope-integration.test.ts @@ -146,15 +146,17 @@ describe('extractParsedFile', () => { expect(warnings[0]).toContain('provider boom'); }); - it('returns undefined when ScopeExtractor throws (missing Module scope)', () => { - // Emits a Class scope but no Module — extractor throws; helper - // swallows and returns undefined. Legacy parsing on the same file - // continues unaffected by this failure. + it('synthesizes Module scope and re-parents orphan Class when no Module is emitted', () => { const provider = fakeProvider({ emitScopeCaptures: () => [{ '@scope.class': cap('@scope.class', 5, 0, 10, 0) }], }); const result = extractParsedFile(provider, 'src', 'a.ts'); - expect(result).toBeUndefined(); + expect(result).toBeDefined(); + const moduleScope = result!.scopes.find((s) => s.kind === 'Module'); + expect(moduleScope).toBeDefined(); + const classScope = result!.scopes.find((s) => s.kind === 'Class'); + expect(classScope).toBeDefined(); + expect(classScope!.parent).toBe(moduleScope!.id); }); it('returns undefined when ScopeExtractor throws on malformed captures (overlap)', () => { diff --git a/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts b/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts index a13d57e2f..5143bce41 100644 --- a/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts +++ b/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts @@ -195,10 +195,13 @@ describe('Pass 1: scope tree', () => { ).toThrow(/overlap/i); }); - it('throws when no Module scope is present', () => { - expect(() => extract([scopeMatch('function', 1, 0, 10, 0)], 'a.ts', mockProvider())).toThrow( - /Module/, - ); + it('synthesizes a Module scope and re-parents orphan Function when no Module is present', () => { + const result = extract([scopeMatch('function', 1, 0, 10, 0)], 'a.ts', mockProvider()); + const moduleScope = result.scopes.find((s) => s.kind === 'Module'); + expect(moduleScope).toBeDefined(); + const fnScope = result.scopes.find((s) => s.kind === 'Function'); + expect(fnScope).toBeDefined(); + expect(fnScope!.parent).toBe(moduleScope!.id); }); }); diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 862357668..34ef25467 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -66,6 +66,9 @@ export default defineConfig({ 'test/integration/shape-check-regression.test.ts', 'test/integration/java-class-impact.test.ts', 'test/integration/class-impact-all-languages.test.ts', + 'test/integration/lbug-orphan-sidecar-recovery.test.ts', + 'test/integration/lbug-readonly-init.test.ts', + 'test/integration/analyze-wal-checkpoint-failure.test.ts', ], fileParallelism: false, sequence: { groupOrder: 1 }, @@ -95,6 +98,9 @@ export default defineConfig({ 'test/integration/shape-check-regression.test.ts', 'test/integration/java-class-impact.test.ts', 'test/integration/class-impact-all-languages.test.ts', + 'test/integration/lbug-orphan-sidecar-recovery.test.ts', + 'test/integration/lbug-readonly-init.test.ts', + 'test/integration/analyze-wal-checkpoint-failure.test.ts', 'test/integration/skills-e2e.test.ts', ], },