Merge branch 'main' into fix/fts-non-fatal-in-analyze

This commit is contained in:
henry201605 2026-05-25 18:18:59 +08:00 • committed by GitHub
commit fbf7bb0b0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1832 additions and 348 deletions

View file

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

View file

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

View file

@ -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_<LANG>=0` — legacy DAG path
2. `REGISTRY_PRIMARY_<LANG>=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/<slug>.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.

View file

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

View file

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

View file

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

View file

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

View file

@ -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_<LANG>=0 (legacy DAG)
* 2. Run its resolver test with REGISTRY_PRIMARY_<LANG>=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<string, string>): 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.');

View file

@ -91,22 +91,23 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
let dbExecutors: Map<string, CypherExecutor> | undefined;
let registryEntries: RegistryEntry[] | undefined;
const eo = opts?.extractorOverride;
if (eo && eo.length === 0) {
autoContracts = await (eo as () => Promise<StoredContract[]>)();
} 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<string, CypherExecutor>();
const openPoolIds: string[] = [];
const openPoolIds: string[] = [];
try {
const eo = opts?.extractorOverride;
if (eo && eo.length === 0) {
autoContracts = await (eo as () => Promise<StoredContract[]>)();
} 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<string, CypherExecutor>();
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<string, string>();
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<string, string>();
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(() => {});
}
}

View file

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

View file

@ -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<string>();
@ -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';

View file

@ -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),
};

View file

@ -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<string, string>();
const varTypes = new Map<string, string>();
const ambiguousReturns = new Set<string>();
const ambiguousVars = new Set<string>();
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<string, Capture> = { ...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<string, Capture> = { ...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<string, Capture> = { ...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<ReturnType<typeof getJavaParser>['parse']>['rootNode'];

View file

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

View file

@ -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<ReturnType<typeof getJavaParser>['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<string, string>;
readonly treeCache?: { get(filePath: string): unknown };
},
): void {
const buckets = new Map<string, PackageBucket>();
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<ScopeId, Map<string, BindingRef[]>>;
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<string, number>();
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<string>();
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<string, TypeRef>;
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;
}

View file

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

View file

@ -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<T>` 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<T>`) — 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<string, ParsedFile['scopes'][number]>();
const classScopesByFile = new Map<string, ParsedFile['scopes'][number][]>();
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<string>();
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<string, TypeRef>;
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<string, TypeRef>).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<string, TypeRef>).set(name, resolved);
}
}
}
}
}
function buildJavaMro(
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
): Map<string, string[]> {
const mro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize);
const defIdByGraphId = new Map<string, string>();
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<string, string[]>();
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, readonly string[]>,
): string[] {
const out: string[] = [];
const seen = new Set<string>();
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;
}

View file

@ -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<ReturnType<typeof getPhpParser>['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<string, BindingRef[]>;
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<string, (typeof parsedFiles)[number]>();
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;

View file

@ -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<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>;
/** 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<string, readonly BindingRef[]>;
/** Pre-resolution usage facts; consumed by the resolution phase. */
readonly referenceSites: readonly ReferenceSite[];
/** SCC condensation of the file-level import graph — callers that want

View file

@ -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<string>();
const checkFile = new Set<string>();
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) {

View file

@ -77,6 +77,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
SupportedLanguages.PHP,
SupportedLanguages.JavaScript,
SupportedLanguages.Kotlin,
SupportedLanguages.Java,
]);
/**

View file

@ -109,10 +109,12 @@ export type ScopeExtractorHooks = Pick<
* Drive the five extraction passes and return a `ParsedFile`.
*
* Throws `ScopeTreeInvariantError` (from #912) when the provider emits
* captures that violate structural scope invariants. The error surfaces
* upward rather than being silently corrected — a malformed capture set
* is a bug in the provider's `emitScopeCaptures`, not a data condition
* to tolerate.
* captures that violate structural scope invariants (e.g., overlapping
* sibling scopes). When no `@scope.module` capture is present, a
* synthetic Module scope is created spanning all captures, and orphan
* non-Module scopes are re-parented under it. This enables indexing of
* files where tree-sitter produces an ERROR root (e.g., complex .phtml
* templates with mixed PHP/HTML/JS).
*/
export function extract(
matches: readonly CaptureMatch[],
@ -124,7 +126,16 @@ export function extract(
// ── Pass 1: build the scope tree ─────────────────────────────────────
const scopeDrafts = pass1BuildScopes(partitioned.scope, filePath, provider);
const moduleScope = ensureModuleScope(scopeDrafts, matches.length, filePath);
const moduleScope = ensureModuleScope(scopeDrafts, filePath, matches);
// Re-parent orphan drafts (parent === null, non-Module) under the
// Module scope. Replaces drafts with new ones carrying the correct
// parent — runs before content passes so bindings/ownedDefs are empty.
for (let i = 0; i < scopeDrafts.length; i++) {
const d = scopeDrafts[i];
if (d.parent === null && d.kind !== 'Module') {
scopeDrafts[i] = makeDraft(d.id, moduleScope.id, d.kind, d.range, d.filePath);
}
}
const scopes = scopeDrafts.map(draftToScope);
// buildScopeTree validates invariants (throws on violation) and exposes
// the lookup contract consumed by Passes 2-5.
@ -280,29 +291,40 @@ interface ScopeDraft {
function ensureModuleScope(
scopeDrafts: ScopeDraft[],
matchCount: number,
filePath: string,
allMatches: readonly CaptureMatch[],
): ScopeDraft {
const moduleScope = scopeDrafts.find((s) => 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 {

View file

@ -705,6 +705,7 @@ export interface ScopeResolver {
callerScope: ScopeId,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
callsite?: Callsite,
) => SymbolDefinition | 'ambiguous' | undefined;
/**

View file

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

View file

@ -501,6 +501,7 @@ export function emitReceiverBoundCalls(
site.inScope,
scopes,
parsedFiles,
site,
);
if (memberDef === 'ambiguous') {
// Same-name ambiguity across inline-namespace children (#1564):

View file

@ -173,6 +173,16 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
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;

View file

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

View file

@ -0,0 +1,5 @@
#include "lib.h"
void run() {
outer::foo(42);
}

View file

@ -0,0 +1,10 @@
#pragma once
namespace outer {
inline namespace v1 {
void foo(int x);
}
inline namespace v2 {
void foo(long y);
}
}

View file

@ -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 = [
'<?php',
`namespace ${ns};`,
'',
ns !== crossNs ? `use ${crossNs}\\${crossClass};` : '',
'',
`class ${className}`,
'{',
` private int $id;`,
` private string $name;`,
'',
` public function getId(): int`,
' {',
' return $this->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<BenchResult> {
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<never>((_, 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);
});

View file

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

View file

@ -332,11 +332,14 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
'outer::foo() emits zero CALLS edges when v1 and v2 both declare foo',
'records a structured suppression reason for inline namespace ambiguity',
// Distinct-signature inline-namespace ambiguity: `foo(int)` in v1 and
// `foo(double)` in v2. The scope-resolver conservatively suppresses
// because `resolveQualifiedReceiverMember` lacks call-site argument
// types. Legacy DAG has no inline-namespace resolver. Scope-resolver-
// only correctness win (#1600 / Claude review Finding 1).
'outer::foo(42) emits zero CALLS edges when v1 declares foo(int) and v2 declares foo(double)',
// `foo(double)` in v2. PR #1810 threads call-site types through the
// resolveQualifiedReceiverMember contract — resolved in both mode paths.
// Legacy DAG emits an edge via the global callable fallback; the test
// now expects 1 edge, so the old expected-failure entry is removed.
// Normalized-signature ambiguity: `foo(int)` vs `foo(long)` both map to
// `int` via normalizeCppParamType. Scope-resolver suppresses via
// isOverloadAmbiguousAfterNormalization; legacy path picks arbitrarily.
'outer::foo(42) emits zero CALLS edges when v1 declares foo(int) and v2 declares foo(long) — both normalize to int',
// PR #1598: ADL free-function reference arg negative fixtures rely on
// scope-resolver-only correctness. The legacy DAG falls back to
// `pickUniqueGlobalCallable` which resolves the callee by simple-name

View file

@ -1383,10 +1383,18 @@ describe('processCallsFromExtracted', () => {
describe('processCalls — Phase P class lookup fallback', () => {
let graph: ReturnType<typeof createKnowledgeGraph>;
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<typeof createKnowledgeGraph>;
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<typeof createKnowledgeGraph>;
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 = () => {

View file

@ -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<string, unknown>) => {
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', () => {

View file

@ -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 = `<?php\nnamespace App\\Models;\n\nclass User {}`;
expect(extractNamespaceViaScanner(src)).toBe('App\\Models');
});
it('extracts braced namespace declaration', () => {
const src = `<?php\nnamespace App\\Models {\n class User {}\n}`;
expect(extractNamespaceViaScanner(src)).toBe('App\\Models');
});
it('extracts namespace on same line as opening tag', () => {
const src = `<?php namespace App\\Services;`;
expect(extractNamespaceViaScanner(src)).toBe('App\\Services');
});
it('extracts namespace after declare(strict_types=1)', () => {
const src = `<?php\ndeclare(strict_types=1);\nnamespace App\\Http\\Controllers;\n\nclass FooController {}`;
expect(extractNamespaceViaScanner(src)).toBe('App\\Http\\Controllers');
});
it('extracts namespace after docblock', () => {
const src = `<?php\n/**\n * This is a docblock.\n */\nnamespace Vendor\\Package;`;
expect(extractNamespaceViaScanner(src)).toBe('Vendor\\Package');
});
it('returns empty string for no namespace declaration', () => {
const src = `<?php\nfunction foo() { return 42; }`;
expect(extractNamespaceViaScanner(src)).toBe('');
});
it('returns empty string for empty file', () => {
expect(extractNamespaceViaScanner('')).toBe('');
});
it('handles uppercase NAMESPACE keyword (case-insensitive)', () => {
const src = `<?php\nNAMESPACE App\\Legacy;`;
expect(extractNamespaceViaScanner(src)).toBe('App\\Legacy');
});
it('handles mixed-case Namespace keyword', () => {
const src = `<?php\nNamespace App\\Mixed;`;
expect(extractNamespaceViaScanner(src)).toBe('App\\Mixed');
});
it('ignores namespace inside heredoc body', () => {
const src = [
'<?php',
'$code = <<<EOT',
'namespace Fake\\Vendor;',
'EOT;',
'namespace App\\Real;',
].join('\n');
expect(extractNamespaceViaScanner(src)).toBe('App\\Real');
});
it('ignores namespace inside nowdoc body', () => {
const src = [
'<?php',
"$code = <<<'EOT'",
'namespace Fake\\Vendor;',
'EOT;',
'namespace App\\Real;',
].join('\n');
expect(extractNamespaceViaScanner(src)).toBe('App\\Real');
});
it('ignores namespace inside block comment', () => {
const src = ['<?php', '/*', ' * namespace Fake\\Comment;', ' */', 'namespace App\\Real;'].join(
'\n',
);
expect(extractNamespaceViaScanner(src)).toBe('App\\Real');
});
it('ignores namespace after // single-line comment marker', () => {
const src = `<?php\n// namespace Fake\\Comment;\nnamespace App\\Real;`;
expect(extractNamespaceViaScanner(src)).toBe('App\\Real');
});
it('ignores namespace after # single-line comment marker', () => {
const src = `<?php\n# namespace Fake\\Comment;\nnamespace App\\Real;`;
expect(extractNamespaceViaScanner(src)).toBe('App\\Real');
});
it('does not match namespace inside a string on the same line as other code', () => {
const src = `<?php\n$x = "namespace Fake\\Str;";\nnamespace App\\Real;`;
expect(extractNamespaceViaScanner(src)).toBe('App\\Real');
});
it('handles declare on same line as opening tag and namespace', () => {
const src = `<?php declare(strict_types=1); namespace App\\Inline;`;
expect(extractNamespaceViaScanner(src)).toBe('App\\Inline');
});
it('ignores namespace inside mid-line block comment', () => {
const src = ['<?php /*', 'namespace Fake\\Comment;', '*/', 'namespace App\\Real;'].join('\n');
expect(extractNamespaceViaScanner(src)).toBe('App\\Real');
});
it('handles single-line block comment before namespace on same line', () => {
const src = `<?php /* comment */ namespace App\\After;`;
expect(extractNamespaceViaScanner(src)).toBe('App\\After');
});
it('handles PHP 7.3 flexible heredoc with indented closing delimiter', () => {
const src = [
'<?php',
'$code = <<<EOT',
' namespace Fake\\Vendor;',
' EOT;',
'namespace App\\Real;',
].join('\n');
expect(extractNamespaceViaScanner(src)).toBe('App\\Real');
});
it('handles nowdoc with indented closing delimiter', () => {
const src = [
'<?php',
"$code = <<<'EOT'",
' namespace Fake\\Vendor;',
' EOT;',
'namespace App\\Real;',
].join('\n');
expect(extractNamespaceViaScanner(src)).toBe('App\\Real');
});
});

View file

@ -108,20 +108,20 @@ describe('isRegistryPrimary', () => {
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);
});

View file

@ -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)', () => {

View file

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

View file

@ -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',
],
},