diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ece3900..69e4bed4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,6 +134,76 @@ jobs: STRIP_FLAG=$(node -e "const [M]=process.versions.node.split('.').map(Number); console.log(M>=23?'--strip-types':'--experimental-strip-types')") node $STRIP_FLAG scripts/verify-imports.ts + parity: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + runs-on: ${{ matrix.os }} + name: Engine parity (${{ matrix.os }}) + + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Install dependencies + shell: bash + run: | + for attempt in 1 2 3; do + npm install && break + if [ "$attempt" -lt 3 ]; then + echo "::warning::npm install attempt $attempt failed, retrying in 15s..." + sleep 15 + else + echo "::error::npm install failed after 3 attempts" + exit 1 + fi + done + + - name: Verify native addon is available + shell: bash + run: | + node -e " + const { createRequire } = require('node:module'); + const r = createRequire(require.resolve('./package.json')); + const os = require('os'); + const fs = require('fs'); + const plat = os.platform(); + const arch = os.arch(); + let libc = ''; + if (plat === 'linux') { + try { + const files = fs.readdirSync('/lib'); + libc = files.some(f => f.startsWith('ld-musl-') && f.endsWith('.so.1')) ? 'musl' : 'gnu'; + } catch { libc = 'gnu'; } + } + const pkgs = { + 'linux-x64-gnu': '@optave/codegraph-linux-x64-gnu', + 'linux-x64-musl': '@optave/codegraph-linux-x64-musl', + 'linux-arm64-gnu': '@optave/codegraph-linux-arm64-gnu', + 'linux-arm64-musl': '@optave/codegraph-linux-arm64-musl', + 'darwin-arm64': '@optave/codegraph-darwin-arm64', + 'darwin-x64': '@optave/codegraph-darwin-x64', + 'win32-x64': '@optave/codegraph-win32-x64-msvc', + }; + const key = libc ? plat + '-' + arch + '-' + libc : plat + '-' + arch; + const pkg = pkgs[key]; + if (!pkg) { console.error('No native package for ' + key); process.exit(1); } + try { r(pkg); console.log('Native addon loaded: ' + pkg); } + catch (e) { console.error('Failed to load ' + pkg + ': ' + e.message); process.exit(1); } + " + + - name: Run parity tests + shell: bash + env: + CODEGRAPH_PARITY: '1' + run: npx vitest run tests/engines/ tests/integration/build-parity.test.ts --reporter=verbose + rust-check: runs-on: ubuntu-latest name: Rust compile check @@ -154,7 +224,7 @@ jobs: ci-pipeline: if: always() - needs: [lint, test, typecheck, audit, verify-imports, rust-check] + needs: [lint, test, typecheck, audit, verify-imports, rust-check, parity] runs-on: ubuntu-latest name: CI Testing Pipeline steps: diff --git a/tests/engines/ast-parity.test.ts b/tests/engines/ast-parity.test.ts index ee298a44..e5ba990d 100644 --- a/tests/engines/ast-parity.test.ts +++ b/tests/engines/ast-parity.test.ts @@ -88,7 +88,12 @@ function processItems(items: string[]): void { } `; -describe('AST node parity (native vs WASM)', () => { +// In the dedicated parity CI job (CODEGRAPH_PARITY=1), never silently skip — +// fail hard so a missing native addon is immediately visible. +const requireParity = !!process.env.CODEGRAPH_PARITY; +const describeOrSkip = requireParity || isNativeAvailable() ? describe : describe.skip; + +describeOrSkip('AST node parity (native vs WASM)', () => { beforeAll(async () => { if (!isNativeAvailable()) return; native = getNative(); diff --git a/tests/engines/dataflow-parity.test.ts b/tests/engines/dataflow-parity.test.ts index ce1c314d..85072505 100644 --- a/tests/engines/dataflow-parity.test.ts +++ b/tests/engines/dataflow-parity.test.ts @@ -131,7 +131,10 @@ function detectNativeDataflow() { return !!r?.dataflow; } -const describeOrSkip = hasNative ? describe : describe.skip; +// In the dedicated parity CI job (CODEGRAPH_PARITY=1), never silently skip — +// fail hard so a missing native addon is immediately visible. +const requireParity = !!process.env.CODEGRAPH_PARITY; +const describeOrSkip = requireParity || hasNative ? describe : describe.skip; describeOrSkip('Cross-engine dataflow parity', () => { beforeAll(async () => { diff --git a/tests/engines/parity.test.ts b/tests/engines/parity.test.ts index d90fd42d..42dc31c9 100644 --- a/tests/engines/parity.test.ts +++ b/tests/engines/parity.test.ts @@ -110,8 +110,10 @@ function normalize(symbols) { } const hasNative = isNativeAvailable(); - -const describeOrSkip = hasNative ? describe : describe.skip; +// In the dedicated parity CI job (CODEGRAPH_PARITY=1), never silently skip — +// fail hard so a missing native addon is immediately visible. +const requireParity = !!process.env.CODEGRAPH_PARITY; +const describeOrSkip = requireParity || hasNative ? describe : describe.skip; describeOrSkip('Cross-engine parity', () => { beforeAll(async () => { diff --git a/tests/integration/build-parity.test.ts b/tests/integration/build-parity.test.ts index 8ed1695b..1893e09a 100644 --- a/tests/integration/build-parity.test.ts +++ b/tests/integration/build-parity.test.ts @@ -22,7 +22,10 @@ import { isNativeAvailable } from '../../src/infrastructure/native.js'; const FIXTURE_DIR = path.join(import.meta.dirname, '..', 'fixtures', 'sample-project'); const hasNative = isNativeAvailable(); -const describeOrSkip = hasNative ? describe : describe.skip; +// In the dedicated parity CI job (CODEGRAPH_PARITY=1), never silently skip — +// fail hard so a missing native addon is immediately visible. +const requireParity = !!process.env.CODEGRAPH_PARITY; +const describeOrSkip = requireParity || hasNative ? describe : describe.skip; function copyDirSync(src, dest) { fs.mkdirSync(dest, { recursive: true }); @@ -102,73 +105,18 @@ describeOrSkip('Build parity: native vs WASM', () => { it('produces identical edges', () => { const wasmGraph = readGraph(path.join(wasmDir, '.codegraph', 'graph.db')); const nativeGraph = readGraph(path.join(nativeDir, '.codegraph', 'graph.db')); - - // Transitional: the published native binary (v3.9.0) does not yet extract - // new_expression as a call site. The Rust code is fixed in this PR but the - // binary used by CI is the npm-published one. If the native engine is missing - // the new_expression calls edge, compare after filtering it from WASM output. - // Remove this filter once the next native binary is published. - type Edge = { source_name: string; target_name: string; kind: string }; - const nativeHasNewExprEdge = (nativeGraph.edges as Edge[]).some( - (e) => e.kind === 'calls' && e.target_name === 'Calculator' && e.source_name === 'main', - ); - if (nativeHasNewExprEdge) { - // Native binary supports new_expression — compare directly - expect(nativeGraph.edges).toEqual(wasmGraph.edges); - } else { - // Filter the new_expression calls edge from WASM output for comparison - const wasmFiltered = (wasmGraph.edges as Edge[]).filter( - (e) => !(e.kind === 'calls' && e.target_name === 'Calculator' && e.source_name === 'main'), - ); - expect(nativeGraph.edges).toEqual(wasmFiltered); - } + expect(nativeGraph.edges).toEqual(wasmGraph.edges); }); it('produces identical roles', () => { const wasmGraph = readGraph(path.join(wasmDir, '.codegraph', 'graph.db')); const nativeGraph = readGraph(path.join(nativeDir, '.codegraph', 'graph.db')); - - // Transitional: without the new_expression calls edge, the native engine - // classifies Calculator as dead-unresolved instead of core. Filter this - // known divergence when the installed native binary is older. - // Remove this filter once the next native binary is published. - type Role = { name: string; role: string }; - const nativeCalcRole = (nativeGraph.roles as Role[]).find((r) => r.name === 'Calculator'); - const wasmCalcRole = (wasmGraph.roles as Role[]).find((r) => r.name === 'Calculator'); - if (nativeCalcRole?.role === wasmCalcRole?.role) { - expect(nativeGraph.roles).toEqual(wasmGraph.roles); - } else { - // Normalize the Calculator role divergence for comparison - const normalizeRoles = (roles: Role[], targetRole: string) => - roles.map((r) => (r.name === 'Calculator' ? { ...r, role: targetRole } : r)); - expect(normalizeRoles(nativeGraph.roles as Role[], 'core')).toEqual( - normalizeRoles(wasmGraph.roles as Role[], 'core'), - ); - } + expect(nativeGraph.roles).toEqual(wasmGraph.roles); }); it('produces identical ast_nodes', () => { const wasmGraph = readGraph(path.join(wasmDir, '.codegraph', 'graph.db')); const nativeGraph = readGraph(path.join(nativeDir, '.codegraph', 'graph.db')); - // Filter out 'call' kind transitionally: the WASM side no longer emits call AST - // nodes, but the published native binary (v3.7.0) still does. Once the next native - // binary is published with call removal, this filter can be dropped. - const filterCall = (nodes: unknown[]) => - (nodes as { kind: string }[]).filter((n) => n.kind !== 'call'); - const wasmFiltered = filterCall(wasmGraph.astNodes); - const nativeFiltered = filterCall(nativeGraph.astNodes); - // Diagnostic: log counts to help debug CI-only parity failures - if (nativeFiltered.length !== wasmFiltered.length) { - console.error( - `[parity-diag] native astNodes: ${nativeFiltered.length}, wasm astNodes: ${wasmFiltered.length}`, - ); - console.error( - `[parity-diag] native kinds: ${JSON.stringify([...new Set(nativeFiltered.map((n) => n.kind))])}`, - ); - console.error( - `[parity-diag] wasm kinds: ${JSON.stringify([...new Set(wasmFiltered.map((n) => n.kind))])}`, - ); - } - expect(nativeFiltered).toEqual(wasmFiltered); + expect(nativeGraph.astNodes).toEqual(wasmGraph.astNodes); }); });