diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index af0e2758d..96b0a4b26 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -3,6 +3,9 @@ name: E2E Tests on: workflow_call: +permissions: + contents: read + jobs: check-changes: name: Check web module changes diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index cc334fcaa..a81876d9d 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -3,6 +3,9 @@ name: Quality Checks on: workflow_call: +permissions: + contents: read + jobs: format: runs-on: ubuntu-latest diff --git a/.github/workflows/ci-scope-parity.yml b/.github/workflows/ci-scope-parity.yml index cffdeb341..8e2926ba8 100644 --- a/.github/workflows/ci-scope-parity.yml +++ b/.github/workflows/ci-scope-parity.yml @@ -28,6 +28,9 @@ name: Scope Resolution Parity on: workflow_call: +permissions: + contents: read + jobs: discover: name: Discover migrated languages diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 7010ee6f3..b044d9318 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -3,6 +3,9 @@ name: Tests on: workflow_call: +permissions: + contents: read + jobs: tests: name: ubuntu / coverage diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3059a34dd..dd07337b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: paths-ignore: ['**.md', 'docs/**', 'LICENSE'] workflow_call: +permissions: + contents: read + # Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". # Hardcoded `CI-` prefix (not `${{ github.workflow }}`) because this workflow is # invoked as a reusable workflow from publish.yml and release-candidate.yml. In diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index cfba3ecbc..021a56930 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -19,6 +19,9 @@ on: pull_request_review: types: [submitted] +permissions: + contents: read + # Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". # Serialize per-PR/issue to avoid racing comments. concurrency: @@ -155,6 +158,8 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} allowed_non_write_users: '*' show_full_output: true + # Review posts use Bash (`gh`, etc.); default mode asks for approval — impossible in CI. + claude_args: '--dangerously-skip-permissions' plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ steps.pr.outputs.number }}' + prompt: '/code-review:code-review https://github.com/${{ github.repository }}/pull/${{ steps.pr.outputs.number }} --comment' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d1dda8fb0..41f964cbe 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -17,6 +17,9 @@ on: # already-merged code without waiting for the next PR. - cron: '0 6 * * 1' +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index ad06267c2..1e2ba1f19 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -10,6 +10,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9e5750210..0cd526768 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -26,6 +26,9 @@ on: required: true type: string +permissions: + contents: read + # Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". # Tag refs are unique per release, so distinct tags run in parallel. # Re-pushes of the same tag serialize. cancel-in-progress: false — never cancel a publish mid-flight. @@ -132,7 +135,7 @@ jobs: uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Install Cosign - uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - name: Log in to GitHub Container Registry if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }} diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml index cfe4d0856..7cfc3bc52 100644 --- a/.github/workflows/gitleaks.yml +++ b/.github/workflows/gitleaks.yml @@ -12,6 +12,9 @@ on: push: branches: [main] +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} diff --git a/.github/workflows/pr-autofix-apply.yml b/.github/workflows/pr-autofix-apply.yml index b2ec8495f..73bf8ceb0 100644 --- a/.github/workflows/pr-autofix-apply.yml +++ b/.github/workflows/pr-autofix-apply.yml @@ -336,7 +336,7 @@ jobs: # Push auth is provided inline at push time via the URL. - name: Checkout PR head if: steps.locate.outputs.found == 'true' - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5.0.4 with: repository: ${{ steps.locate.outputs.head_repo }} ref: ${{ steps.locate.outputs.head_sha }} diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 51664bba8..4a219bfab 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -35,6 +35,9 @@ on: pull_request_target: types: [opened, edited, reopened] +permissions: + contents: read + # Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". # Include `github.event_name` so `pull_request` (validate-title) and # `pull_request_target` (autolabel) runs for the same PR do NOT share a slot diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d5af63205..0694b5e4c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,6 +6,7 @@ on: - 'v*' # No workflow-level permissions — scoped per job below. +permissions: {} # Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". # Tag refs are unique per release, so distinct tags run in parallel. Re-pushes of the diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index ff75bca26..2129e765a 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -58,6 +58,7 @@ jobs: timeout-minutes: 5 permissions: contents: read + pull-requests: read # read PR labels on the merge commit outputs: should_run: ${{ steps.decide.outputs.should_run }} head_sha: ${{ steps.decide.outputs.head_sha }} @@ -74,6 +75,8 @@ jobs: FORCE: ${{ inputs.force }} BUMP_INPUT: ${{ inputs.bump }} EVENT_NAME: ${{ github.event_name }} + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} run: | set -euo pipefail HEAD_SHA=$(git rev-parse HEAD) @@ -96,6 +99,49 @@ jobs: exit 0 fi + # ── Skip when the merge commit corresponds to a release ───────── + # Two complementary checks (belt-and-suspenders): + # 1. The HEAD commit subject matches `chore: release vX.Y.Z` + # (the canonical release-PR title in this repo). Anchored + # at both ends to require the bare title or the squash-merge + # `(#NNNN)` suffix exactly — rejects noisy variants like + # `chore: release v1.0.0 (something unrelated)`. + # 2. The squash-merged PR carries the `release` label. + # Either match suppresses the rc build — stable releases publish + # via publish.yml on the v-tag, so the rc cycle should pause for + # them rather than racing the npm publish. + HEAD_SUBJECT="$(git log -1 --pretty=%s HEAD)" + # Sanitise GitHub-Actions annotation prefixes before logging the + # raw subject — defence-in-depth so a hypothetical commit subject + # containing `::error::` or `::set-output::` cannot forge log + # annotations even though %s strips newlines. + HEAD_SUBJECT_SAFE="${HEAD_SUBJECT//::/__}" + RELEASE_SUBJECT_RE='^chore:[[:space:]]*release[[:space:]]+v[0-9]+\.[0-9]+\.[0-9]+([[:space:]]+\(#[0-9]+\))?$' + if [[ "$HEAD_SUBJECT" =~ $RELEASE_SUBJECT_RE ]]; then + echo "HEAD commit subject matches a release commit — skipping rc." + echo " subject (sanitised): $HEAD_SUBJECT_SAFE" + echo "should_run=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Squash-merge commits include `(#NNNN)` at the end of the subject. + if [[ "$HEAD_SUBJECT" =~ \(#([0-9]+)\)[[:space:]]*$ ]]; then + PR_NUM="${BASH_REMATCH[1]}" + echo "Detected squash-merge of PR #$PR_NUM — checking labels." + if LABELS_JSON="$(gh pr view "$PR_NUM" --repo "$REPO" --json labels 2>/dev/null)"; then + if printf '%s' "$LABELS_JSON" | jq -e '.labels[] | select(.name == "release")' >/dev/null; then + echo "PR #$PR_NUM has the 'release' label — skipping rc." + echo "should_run=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "PR #$PR_NUM has no 'release' label — proceeding." + else + # Lookup failure is not fatal — fall through to the dedup check + # so a transient GH API hiccup doesn't silently suppress rc builds. + echo "::warning::Could not read labels for PR #${PR_NUM} — falling through." + fi + fi + # Dedup: is there already an rc/ marker pointing at HEAD? MARKER="rc/${HEAD_SHA}" if git rev-parse "refs/tags/$MARKER" >/dev/null 2>&1; then diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index f7fba75e9..1251fa4dd 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -20,6 +20,9 @@ on: - cron: '0 8 * * 1' workflow_dispatch: +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index 7b38b8ddd..803c4d0bd 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -15,6 +15,9 @@ on: paths: - '.github/**' +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/AGENTS.md b/AGENTS.md index 60317d73d..1346facc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,11 +149,16 @@ Indexed as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows) ## Keeping the Index Fresh ```bash -npx gitnexus analyze # basic refresh; preserves any existing embeddings +npx gitnexus analyze # incremental by default; preserves embeddings +npx gitnexus analyze --force # full rebuild from scratch (opt out of incremental) npx gitnexus analyze --embeddings # also generate embeddings for new/changed nodes npx gitnexus analyze --drop-embeddings # explicit opt-in to wipe existing embeddings ``` +`analyze` runs **incrementally by default**. The pipeline still parses every file every run (cross-file resolution requires it), but tree-sitter parsing is **served from a content-addressed cache** at `.gitnexus/parse-cache.json` for chunks whose file contents haven't changed since the last run. Only changed-file rows (and their importers) are rewritten in LadybugDB; unchanged-file rows are preserved. Output is byte-equivalent to a full rebuild. Pass `--force` to wipe and re-index from scratch (e.g., to recover from a corrupt index, or after upgrading GitNexus). + +The parse cache key is **content-addressed and version-tagged**: it survives `--force` runs, and is automatically invalidated by a `gitnexus` package upgrade (so a new tree-sitter grammar doesn't silently replay stale parse output). Safe to delete `.gitnexus/parse-cache.json` at any time — it'll be rebuilt on the next analyze. + Check `.gitnexus/meta.json` `stats.embeddings` (0 = none). A plain `analyze` no longer drops existing vectors — pass `--drop-embeddings` to wipe. > Claude Code: PostToolUse hook detects a stale index after `git commit` and `git merge` and prompts the agent to run `analyze`. The hook does not invoke `analyze` itself. diff --git a/Dockerfile.cli b/Dockerfile.cli index 925f295f0..3275c8f7e 100644 --- a/Dockerfile.cli +++ b/Dockerfile.cli @@ -40,8 +40,8 @@ RUN npm prune --omit=dev --prefix gitnexus # node:22-bookworm-slim FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime -# curl for the healthcheck; git so `gitnexus` can clone repos at runtime. -RUN apt-get update && apt-get install -y --no-install-recommends curl git && rm -rf /var/lib/apt/lists/* \ +# curl for the healthcheck; git for cloning; ca-certificates for TLS verification. +RUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates && rm -rf /var/lib/apt/lists/* \ && rm -rf /usr/local/lib/node_modules/npm \ && rm -rf /usr/local/lib/node_modules/corepack \ && rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack @@ -55,8 +55,18 @@ RUN mkdir -p /data/gitnexus && chown -R node:node /data COPY --from=builder --chown=node:node /app/gitnexus/dist ./gitnexus/dist COPY --from=builder --chown=node:node /app/gitnexus/node_modules ./gitnexus/node_modules COPY --from=builder --chown=node:node /app/gitnexus/package.json ./gitnexus/package.json +COPY --from=builder --chown=node:node /app/gitnexus/scripts/install-duckdb-extension.mjs ./gitnexus/scripts/install-duckdb-extension.mjs COPY --from=builder --chown=node:node /app/gitnexus/vendor ./gitnexus/vendor +# Expose the `gitnexus` binary on PATH so the documented Docker workflow +# (`docker compose exec gitnexus-server gitnexus index /workspace/`) +# works without users having to invoke `node /app/gitnexus/dist/cli/index.js`. +# `npm prune --omit=dev` in the builder stage strips `node_modules/.bin/` +# entries, so the `gitnexus` bin declared in package.json (`dist/cli/index.js`, +# which already carries `#!/usr/bin/env node` and 755 perms) is otherwise +# unreachable from $PATH. +RUN ln -s /app/gitnexus/dist/cli/index.js /usr/local/bin/gitnexus + USER node # The web UI defaults to http://localhost:4747 - keep that contract. diff --git a/GUARDRAILS.md b/GUARDRAILS.md index 1cc032759..c09f0319a 100644 --- a/GUARDRAILS.md +++ b/GUARDRAILS.md @@ -30,9 +30,15 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m ### Stale graph after edits - **Trigger:** MCP warns index is behind `HEAD`, or search doesn't match latest commit. -- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used). +- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used). Runs incrementally by default — the pipeline parses every file every run (cross-file resolution requires it), but tree-sitter dispatch is skipped for unchanged file chunks via the content-addressed cache, and only changed-file rows (plus their importers, transitively) are rewritten in LadybugDB. - **Why:** Tools query LadybugDB from last analyze; git changes are invisible until re-indexed. +### Index seems corrupt or "incremental" is misbehaving + +- **Trigger:** `analyze` produces unexpected results, or `meta.json.incrementalInProgress` is set, or the index is in a half-state after a crash. +- **Do:** `npx gitnexus analyze --force` to rebuild from scratch. The dirty-flag check forces this automatically when a previous incremental run didn't complete cleanly, but `--force` is the manual escape hatch. Safe to delete `.gitnexus/parse-cache.json` at any time — content-addressed, will be regenerated. +- **Why:** Incremental writeback is selective DB row replacement; if the on-disk state is inconsistent for any reason, a full rebuild is the cheapest path back to a known-good index. + ### Embeddings vanished after analyze - **Trigger:** Semantic search quality drops; `stats.embeddings` in `meta.json` is 0 after refresh. diff --git a/README.md b/README.md index 6beadb63d..e08c0eb7d 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up | Editor | MCP | Skills | Hooks (auto-augment) | Support | | --------------------- | --- | ------ | -------------------- | -------------- | | **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** | -| **Cursor** | Yes | Yes | — | MCP + Skills | +| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](gitnexus-cursor-integration/README.md#hook-install)) | **Full** | | **Codex** | Yes | Yes | — | MCP + Skills | | **Windsurf** | Yes | — | — | MCP | | **OpenCode** | Yes | Yes | — | MCP + Skills | @@ -722,6 +722,12 @@ gitnexus wiki --base-url https://api.anthropic.com/v1 # Force full regeneration gitnexus wiki --force + + +# Increase the timeout or retries for large codebase or slow LLM providers +gitnexus wiki --timeout # Per-attempt LLM request timeout in seconds (default: 60) +gitnexus wiki --retries # Max LLM retry attempts per request (default: 3) + ``` The wiki generator reads the indexed graph structure, groups files into modules via LLM, generates per-module documentation pages, and creates an overview page — all with cross-references to the knowledge graph. diff --git a/eslint-rules/require-safe-parse.mjs b/eslint-rules/require-safe-parse.mjs new file mode 100644 index 000000000..4ab9dbd8a --- /dev/null +++ b/eslint-rules/require-safe-parse.mjs @@ -0,0 +1,95 @@ +/** + * Custom ESLint rule: require `parseSourceSafe(parser, content, ...)` instead + * of direct `.parse(, ...)` calls. + * + * Background: tree-sitter's Node.js native binding crashes with SIGSEGV on + * Windows when handed a JS string longer than 32 767 chars. The crash happens + * inside the binding's V8 string-to-buffer conversion and cannot be intercepted + * by JavaScript `try/catch`. `parseSourceSafe` (in + * `gitnexus/src/core/tree-sitter/safe-parse.ts`) routes large inputs through + * the chunked-callback overload of `parser.parse(input, ...)` which bypasses + * the broken conversion path. PR #1433 fixed every direct call site at the + * time; this rule prevents new direct calls from creeping in. + * + * The rule is auto-fixable for the call-site rewrite. It does NOT auto-add the + * import (computing the correct relative path per file is brittle); after the + * call rewrite runs, the consumer file's `tsc` will complain about an + * undefined identifier and the developer adds the import. This is the same + * tradeoff `unused-imports/no-unused-imports` makes in the opposite direction. + * + * False-positive suppression: + * - Skips calls whose receiver is a known non-tree-sitter library (`JSON`, + * `URL`, `marked`, `Number`). + * - Skips calls whose first argument is a string-literal (grammar-load smoke + * tests like `_testParser.parse('service X { rpc Y (R) returns (R); }')`). + * - Skips test files (`.test.ts`/`.test.tsx`/`.spec.ts`). + * - Skips the `safe-parse.ts` helper itself. + */ + +const SKIPPED_RECEIVERS = new Set(['JSON', 'URL', 'marked', 'Number', 'Math']); + +export default { + meta: { + type: 'problem', + docs: { + description: + 'Require parseSourceSafe instead of direct tree-sitter `.parse(content, ...)` calls (Windows SIGSEGV protection)', + recommended: true, + }, + fixable: 'code', + schema: [], + messages: { + useSafeParse: + 'Direct `{{receiver}}.parse(...)` can SIGSEGV on Windows for inputs > 32 767 chars (uncatchable from JS). Use `parseSourceSafe({{receiver}}, ...)` from `core/tree-sitter/safe-parse.js`. Auto-fix rewrites the call; add the missing import yourself.', + }, + }, + create(context) { + const filename = context.filename ?? context.getFilename(); + // Don't lint the helper itself or test files. + if (filename.includes('safe-parse')) return {}; + if (/[.](?:test|spec)\.tsx?$/.test(filename)) return {}; + + const sourceCode = context.sourceCode ?? context.getSourceCode(); + + return { + CallExpression(node) { + const callee = node.callee; + if (callee.type !== 'MemberExpression') return; + if (callee.computed) return; + if (callee.property.type !== 'Identifier') return; + if (callee.property.name !== 'parse') return; + + // Skip known non-tree-sitter receivers. + if (callee.object.type === 'Identifier' && SKIPPED_RECEIVERS.has(callee.object.name)) { + return; + } + + // Smoke tests pass a string literal directly; those are trivially safe. + const firstArg = node.arguments[0]; + if (!firstArg) return; + if (firstArg.type === 'Literal' && typeof firstArg.value === 'string') return; + if (firstArg.type === 'TemplateLiteral' && firstArg.expressions.length === 0) return; + + const receiverText = sourceCode.getText(callee.object); + // Receiver-text-shape skip: anything matching well-known JS APIs that + // happen to have a `.parse()` shape but aren't tree-sitter. + if ( + /^(JSON|URL|marked|Number|Math|Date|globalThis\.JSON)\b/.test(receiverText) || + /\bjson\.parse\b/i.test(receiverText) + ) { + return; + } + + context.report({ + node, + messageId: 'useSafeParse', + data: { receiver: receiverText }, + fix(fixer) { + const argsText = node.arguments.map((arg) => sourceCode.getText(arg)).join(', '); + return fixer.replaceText(node, `parseSourceSafe(${receiverText}, ${argsText})`); + }, + }); + }, + }; + }, +}; diff --git a/eslint.config.mjs b/eslint.config.mjs index f377cba6c..e0779b71c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,6 +3,15 @@ import tsParser from '@typescript-eslint/parser'; import unusedImports from 'eslint-plugin-unused-imports'; import reactHooks from 'eslint-plugin-react-hooks'; import prettierConfig from 'eslint-config-prettier'; +import requireSafeParse from './eslint-rules/require-safe-parse.mjs'; + +// Local plugin hosting custom rules that enforce GitNexus-specific invariants +// (currently: the Windows-SIGSEGV-safe parser entrypoint). +const gitnexusLocalPlugin = { + rules: { + 'require-safe-parse': requireSafeParse, + }, +}; // Selectors that protect MCP-reachable code from corrupting the JSON-RPC // stdio frame stream. The MCP-reachable block below uses these directly; @@ -135,6 +144,23 @@ export default [ }, }, + // Windows SIGSEGV protection: every tree-sitter parse in `core/` must route + // through parseSourceSafe. Direct `.parse(content, ...)` crashes on + // Windows for inputs > 32 767 chars (V8 string-conversion bug, uncatchable + // from JS). The rule auto-fixes the call site; the developer adds the + // missing import after the fix runs. Out of scope: tests (skipped by the + // rule), the helper itself (`safe-parse.ts`), and the `grpc-patterns/proto.ts` + // grammar-load smoke test (filtered by string-literal-arg skip in the rule). + { + files: ['gitnexus/src/core/**/*.ts'], + plugins: { + gitnexus: gitnexusLocalPlugin, + }, + rules: { + 'gitnexus/require-safe-parse': 'error', + }, + }, + // React-specific rules for gitnexus-web { files: ['gitnexus-web/src/**/*.{ts,tsx}'], diff --git a/eval/uv.lock b/eval/uv.lock index fcd666e75..04b89f336 100644 --- a/eval/uv.lock +++ b/eval/uv.lock @@ -2278,11 +2278,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] diff --git a/gitnexus-claude-plugin/hooks/gitnexus-hook.js b/gitnexus-claude-plugin/hooks/gitnexus-hook.js index 7d8fbfda4..245d34043 100644 --- a/gitnexus-claude-plugin/hooks/gitnexus-hook.js +++ b/gitnexus-claude-plugin/hooks/gitnexus-hook.js @@ -14,6 +14,7 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); +const { acquireHookSlot } = require('./hook-lock.js'); /** * Read JSON input from stdin synchronously. @@ -217,7 +218,8 @@ function sendHookResponse(hookEventName, message) { function handlePreToolUse(input) { const cwd = input.cwd || process.cwd(); if (!path.isAbsolute(cwd)) return; - if (!findGitNexusDir(cwd)) return; + const gitNexusDir = findGitNexusDir(cwd); + if (!gitNexusDir) return; const toolName = input.tool_name || ''; const toolInput = input.tool_input || {}; @@ -227,6 +229,9 @@ function handlePreToolUse(input) { const pattern = extractPattern(toolName, toolInput); if (!pattern || pattern.length < 3) return; + const release = acquireHookSlot(gitNexusDir); + if (!release) return; + let result = ''; try { const child = runGitNexusCli(['augment', '--', pattern], cwd, 7000); @@ -235,6 +240,8 @@ function handlePreToolUse(input) { } } catch { /* graceful failure */ + } finally { + release(); } if (result && result.trim()) { diff --git a/gitnexus-claude-plugin/hooks/hook-lock.js b/gitnexus-claude-plugin/hooks/hook-lock.js new file mode 100644 index 000000000..759856384 --- /dev/null +++ b/gitnexus-claude-plugin/hooks/hook-lock.js @@ -0,0 +1,119 @@ +const fs = require('fs'); +const path = require('path'); + +const HOOK_LOCK_SUBDIR = '.hook-locks'; +const HOOK_LOCK_MAX_INFLIGHT = 3; +const HOOK_LOCK_STALE_MS = 30000; + +function acquireHookSlot(gitNexusDir) { + const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR); + try { + fs.mkdirSync(lockDir, { recursive: true }); + } catch { + // Cannot create lock dir (read-only fs, cross-user perm denial, out of + // inodes, etc.) — fail closed by returning null. Caller skips augment. + // Fail-open here would let N concurrent hooks all proceed unguarded and + // reintroduce the #1486 fan-out the guard exists to prevent. + return null; + } + + const myPidStr = String(process.pid); + + for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) { + const slotPath = path.join(lockDir, `slot-${slot}.lock`); + for (let attempt = 0; attempt < 2; attempt++) { + try { + fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' }); + let released = false; + const release = () => { + if (released) return; + released = true; + try { + // Only unlink if we still own the slot. If we appeared stale and + // another hook took over, the file now belongs to it — leave alone. + const content = fs.readFileSync(slotPath, 'utf-8').trim(); + if (content === myPidStr) fs.unlinkSync(slotPath); + } catch { + /* already removed or unreadable */ + } + }; + process.on('exit', release); + return release; + } catch { + // Slot exists. Decide whether to take it over. + // Open once and inspect mtime + content via the same fd so there's + // no TOCTOU between the metadata check and the content read + // (codeql js/file-system-race). + let fd; + try { + fd = fs.openSync(slotPath, 'r'); + } catch { + continue; // Vanished between EEXIST and open — retry this slot. + } + let isLive = false; + let mtimeMs = Date.now(); + try { + mtimeMs = fs.fstatSync(fd).mtimeMs; + const buf = Buffer.alloc(32); + const n = fs.readSync(fd, buf, 0, 32, 0); + const ownerStr = buf.slice(0, n).toString('utf-8').trim(); + if (ownerStr === '') { + // Owner created the file but hasn't written its PID yet. The + // wx open+write window is microseconds; give it the benefit + // of the doubt and treat as live. + isLive = true; + } else { + const owner = Number.parseInt(ownerStr, 10); + if (Number.isFinite(owner) && owner > 0) { + try { + process.kill(owner, 0); + isLive = true; + } catch (e) { + // ESRCH = process gone → treat as dead. EPERM = process exists + // but owned by another user (cross-user lock dir) → still alive, + // keep the slot. Anything else: be conservative, assume alive. + if (e && e.code === 'ESRCH') { + isLive = false; + } else { + isLive = true; + } + } + } + } + } catch { + /* unreadable — treat as dead */ + } finally { + try { + fs.closeSync(fd); + } catch { + /* already closed */ + } + } + // For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins — + // a slow-but-alive hook is never wrongly evicted. For older slots, + // age is the final arbiter as a defense against PID reuse on long- + // abandoned slots. 30s >> the 7s augment timeout, so a healthy run + // never crosses this threshold. + if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) { + isLive = false; + } + if (isLive) break; // Try the next slot. + try { + fs.unlinkSync(slotPath); + } catch { + /* another hook beat us to it — retry will hit EEXIST */ + } + // Loop and retry this slot. + } + } + } + + return null; +} + +module.exports = { + HOOK_LOCK_SUBDIR, + HOOK_LOCK_MAX_INFLIGHT, + HOOK_LOCK_STALE_MS, + acquireHookSlot, +}; diff --git a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md index 1c38face4..11945b8cc 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md @@ -62,6 +62,8 @@ Generates repository documentation from the knowledge graph using an LLM. Requir | `--api-key ` | LLM API key | | `--concurrency ` | Parallel LLM calls (default: 3) | | `--gist` | Publish wiki as a public GitHub Gist | +| `--timeout ` | Per-attempt LLM request timeout in seconds (default: 60) | +| `--retries ` | Max LLM retry attempts per request (default: 3) | ### list — Show all indexed repos diff --git a/gitnexus-cursor-integration/README.md b/gitnexus-cursor-integration/README.md new file mode 100644 index 000000000..6545bacec --- /dev/null +++ b/gitnexus-cursor-integration/README.md @@ -0,0 +1,91 @@ +# GitNexus — Cursor integration + +Static config that adds GitNexus knowledge-graph augmentation and skill files to Cursor. + +> **Hooks require Cursor 2.4+.** Earlier versions don't expose `postToolUse` and the hook will silently no-op. + +## What you get + +| Layer | What it does | How it's installed | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| **MCP** | `gitnexus` MCP server with 16 tools (`query`, `context`, `impact`, `detect_changes`, `rename`, …) | `npx gitnexus setup` writes `~/.cursor/mcp.json` automatically. | +| **Skills** | `/gitnexus-exploring`, `/gitnexus-debugging`, `/gitnexus-impact-analysis`, `/gitnexus-refactoring`, `/gitnexus-pr-review` markdown skills | `npx gitnexus setup` copies them to `~/.cursor/skills/gitnexus/`. | +| **Hooks** _(this README)_ | `postToolUse` hook that enriches `Shell` / `Read` / `Grep` tool calls with graph context — same augmentation Claude Code gets | **Manual** — copy the files described below into your project's `.cursor/`. | + +## Hook install + +Cursor 2.4+ reads `.cursor/hooks.json` from the project root and runs hook commands with the project root as the working directory ([docs](https://cursor.com/docs/agent/hooks)). + +From this repo's `gitnexus-cursor-integration/hooks/`, copy the files below into your **project root**: + +```text +/ +├── .cursor/ +│ └── hooks.json ← from gitnexus-cursor-integration/hooks/hooks.json +└── hooks/ + ├── gitnexus-hook.cjs ← from gitnexus-cursor-integration/hooks/gitnexus-hook.cjs + └── hook-lock.cjs ← from gitnexus-cursor-integration/hooks/hook-lock.cjs +``` + +Equivalent shell commands (run from your project root, with `$GITNEXUS_REPO` pointing at a clone of this repo): + +```bash +mkdir -p .cursor hooks +cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/hooks.json" .cursor/hooks.json +cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs" hooks/gitnexus-hook.cjs +cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/hook-lock.cjs" hooks/hook-lock.cjs +``` + +If you already have a `.cursor/hooks.json`, merge the `hooks.postToolUse` array rather than overwriting. + +### Verify + +1. Index the project: `npx gitnexus analyze` +2. Reload the Cursor window so it picks up the new hook config. +3. Ask the agent something that triggers `Read` / `Grep` / `Shell rg`. You should see a `[GitNexus]` block appended to the tool result. +4. Diagnose silent no-ops by setting `GITNEXUS_DEBUG=1` in your shell environment — the hook will write Cursor's raw event payload to stderr so you can verify field names. + +### What's installed manually vs. automated + +| Step | Automated by `gitnexus setup`? | +| -------------------------------------------------------------------- | ------------------------------ | +| `~/.cursor/mcp.json` | ✅ | +| `~/.cursor/skills/gitnexus/*` | ✅ | +| `/.cursor/hooks.json` + `/hooks/gitnexus-hook.cjs` + `/hooks/hook-lock.cjs` | ❌ — copy manually (see above) | + +Hook install is per-project (Cursor scopes hooks to a project root); skills and MCP config are global. + +## Hook contract + +The hook receives a JSON event on stdin matching Cursor 2.4's `postToolUse` shape: + +```json +{ + "tool_name": "Grep" | "Read" | "Shell", + "tool_input": { /* tool-specific */ }, + "tool_output": { /* optional */ }, + "cwd": "/absolute/path/to/project" +} +``` + +It writes augmentation context to stdout as: + +```json +{ "additional_context": "[GitNexus] …" } +``` + +Empty stdout means "no augmentation, continue normally" — the hook never blocks the tool. + +### Pattern extraction per tool + +| Tool | Pattern source | Notes | +| ------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `Grep` | `tool_input.query` (also `pattern`, `regex`, `q`, `search`, `searchQuery`) | Last-resort fallback: longest string value in `tool_input` (≥ 3 chars). | +| `Read` | basename of `tool_input.target_file` (also `file_path`, `filePath`, `path`, `file`), stripped to identifier characters | `auth/handler.ts` → `handler`. | +| `Shell` | First positional argument after `rg` / `grep` in `tool_input.command` | Best-effort tokenizer; quoted multi-word patterns (`rg "User Service"`) extract the first word only. | + +## Troubleshooting + +- **Nothing happens** — Confirm Cursor is on 2.4+ and the project root has `.cursor/hooks.json` plus both hook files at `hooks/gitnexus-hook.cjs` and `hooks/hook-lock.cjs`. Then `npx gitnexus list` to confirm the project is indexed. +- **`gitnexus` not found** — The hook prefers a locally-resolvable `gitnexus/dist/cli/index.js` and falls back to `npx -y gitnexus`. Install globally with `npm i -g gitnexus` to skip the npx cold-start latency. +- **Wrong pattern extracted** — Set `GITNEXUS_DEBUG=1` and run a tool call. The raw stdin payload is logged to stderr; use it to confirm Cursor's actual `tool_input` field names against the table above. If they differ, file an issue with the captured payload. diff --git a/gitnexus-cursor-integration/hooks/augment-shell.sh b/gitnexus-cursor-integration/hooks/augment-shell.sh deleted file mode 100644 index 48ea185b0..000000000 --- a/gitnexus-cursor-integration/hooks/augment-shell.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/bin/bash -# GitNexus beforeShellExecution hook for Cursor -# Receives JSON on stdin with { command, cwd, timeout } -# Returns JSON on stdout with { permission, agent_message } -# -# Extracts search pattern from grep/rg commands, runs gitnexus augment, -# and injects the enriched context via agent_message. - -INPUT=$(cat) - -COMMAND=$(echo "$INPUT" | jq -r '.command // empty' 2>/dev/null) - -if [ -z "$COMMAND" ]; then - echo '{"permission":"allow"}' - exit 0 -fi - -# Skip non-search commands -case "$COMMAND" in - cd\ *|npm\ *|yarn\ *|pnpm\ *|git\ commit*|git\ push*|git\ pull*|mkdir\ *|rm\ *|cp\ *|mv\ *|echo\ *|cat\ *) - echo '{"permission":"allow"}' - exit 0 - ;; -esac - -# Extract search pattern from rg/grep commands -PATTERN="" -if echo "$COMMAND" | grep -qE '\brg\b'; then - PATTERN=$(echo "$COMMAND" | sed -n "s/.*\brg\s\+\(--[^ ]*\s\+\)*['\"]\\?\([^'\";\| >]*\\).*/\2/p") -elif echo "$COMMAND" | grep -qE '\bgrep\b'; then - PATTERN=$(echo "$COMMAND" | sed -n "s/.*\bgrep\s\+\(-[^ ]*\s\+\)*['\"]\\?\([^'\";\| >]*\\).*/\2/p") -fi - -if [ -z "$PATTERN" ] || [ ${#PATTERN} -lt 3 ]; then - echo '{"permission":"allow"}' - exit 0 -fi - -# Run gitnexus augment -RESULT=$(npx -y gitnexus augment "$PATTERN" 2>/dev/null) - -if [ -n "$RESULT" ]; then - # Escape for JSON - ESCAPED=$(echo "$RESULT" | jq -Rs .) - echo "{\"permission\":\"allow\",\"agent_message\":$ESCAPED}" -else - echo '{"permission":"allow"}' -fi - -exit 0 diff --git a/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs new file mode 100644 index 000000000..74c5587b3 --- /dev/null +++ b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs @@ -0,0 +1,266 @@ +#!/usr/bin/env node +/** + * GitNexus Cursor postToolUse Hook + * + * Receives a JSON event on stdin describing a finished tool call, derives a + * search pattern (Grep query, Read file basename, or rg/grep arg from a Shell + * command), runs `gitnexus augment `, and emits the enriched context + * back as `{ additional_context: "..." }` so the agent sees it alongside the + * tool result. + * + * Replaces the legacy beforeShellExecution / augment-shell.sh pipeline: + * - Cross-platform (no bash, no jq — runs on Windows out of the box) + * - Covers Read and Grep, not just Shell rg/grep + * + * Cursor 2.4+ generic hooks: https://cursor.com/docs/agent/hooks + */ + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { acquireHookSlot } = require('./hook-lock.cjs'); + +function readInput() { + try { + const data = fs.readFileSync(0, 'utf-8'); + return JSON.parse(data); + } catch { + return {}; + } +} + +function isGlobalRegistryDir(candidate) { + if (fs.existsSync(path.join(candidate, 'meta.json'))) return false; + return ( + fs.existsSync(path.join(candidate, 'registry.json')) || + fs.existsSync(path.join(candidate, 'repos')) + ); +} + +function walkForGitNexusDir(startDir) { + let dir = startDir; + for (let i = 0; i < 5; i++) { + const candidate = path.join(dir, '.gitnexus'); + if (fs.existsSync(candidate)) { + if (!isGlobalRegistryDir(candidate)) return candidate; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +function findCanonicalRepoRoot(cwd) { + try { + const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { + encoding: 'utf-8', + timeout: 2000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); + if (result.error || result.status !== 0) return null; + const commonDir = (result.stdout || '').trim(); + if (!commonDir || !path.isAbsolute(commonDir)) return null; + return path.dirname(commonDir); + } catch { + return null; + } +} + +function findGitNexusDir(startDir) { + const cwd = startDir || process.cwd(); + const fromCwd = walkForGitNexusDir(cwd); + if (fromCwd) return fromCwd; + const canonicalRoot = findCanonicalRepoRoot(cwd); + if (canonicalRoot && canonicalRoot !== cwd) { + return walkForGitNexusDir(canonicalRoot); + } + return null; +} + +function parseRgGrepPattern(cmd) { + const tokens = cmd.split(/\s+/); + let foundCmd = false; + let skipNext = false; + const flagsWithValues = new Set([ + '-e', + '-f', + '-m', + '-A', + '-B', + '-C', + '-g', + '--glob', + '-t', + '--type', + '--include', + '--exclude', + ]); + + for (const token of tokens) { + if (skipNext) { + skipNext = false; + continue; + } + if (!foundCmd) { + if (/\brg$|\bgrep$/.test(token)) foundCmd = true; + continue; + } + if (token.startsWith('-')) { + if (flagsWithValues.has(token)) skipNext = true; + continue; + } + const cleaned = token.replace(/['"]/g, ''); + return cleaned.length >= 3 ? cleaned : null; + } + return null; +} + +/** + * Extract a search pattern from the tool input. Cursor 2.4 docs at + * https://cursor.com/docs/agent/hooks list the tool *matchers* but do not + * formally specify the per-tool tool_input field names, so we probe a + * generous set of MCP-style aliases. As a last-resort fallback for Grep + * (the highest-frequency search path) we also accept the longest plausible + * string value in tool_input. Set GITNEXUS_DEBUG=1 to log the raw payload + * to stderr if Cursor changes the contract and aliases stop matching. + */ +function pickLongestStringValue(obj) { + let best = null; + if (!obj || typeof obj !== 'object') return null; + for (const v of Object.values(obj)) { + if (typeof v === 'string' && v.length >= 3 && (!best || v.length > best.length)) { + best = v; + } + } + return best; +} + +function extractPattern(toolName, toolInput) { + const t = (toolName || '').toLowerCase(); + + if (t === 'grep') { + const aliases = [ + toolInput.query, + toolInput.pattern, + toolInput.regex, + toolInput.q, + toolInput.search, + toolInput.searchQuery, + ]; + for (const a of aliases) { + if (typeof a === 'string' && a.length >= 3) return a; + } + // Last resort: scan tool_input for any reasonable-looking string value. + return pickLongestStringValue(toolInput); + } + + if (t === 'read') { + const filePath = + toolInput.target_file || + toolInput.file_path || + toolInput.filePath || + toolInput.path || + toolInput.file || + ''; + if (!filePath) return null; + const base = path.basename(String(filePath), path.extname(String(filePath))); + const cleaned = base.replace(/[^a-zA-Z0-9_]/g, ''); + return cleaned.length >= 3 ? cleaned : null; + } + + if (t === 'shell') { + const cmd = toolInput.command || ''; + if (!/\brg\b|\bgrep\b/.test(cmd)) return null; + // NOTE: parseRgGrepPattern uses split(/\s+/) and cannot handle shell + // quoting. `rg "User Service" src/` returns "User" (the first token + // after the rg/grep arg, with surrounding quotes stripped) — the + // multi-word pattern is intentionally not reconstructed since BM25 is + // already token-tolerant. Quoted single tokens (`rg "validateUser"`) + // work fine. + return parseRgGrepPattern(cmd); + } + + return null; +} + +function resolveCliPath() { + try { + return require.resolve('gitnexus/dist/cli/index.js'); + } catch { + return ''; + } +} + +function runGitNexusCli(cliPath, args, cwd, timeout) { + const isWin = process.platform === 'win32'; + if (cliPath) { + return spawnSync(process.execPath, [cliPath, ...args], { + encoding: 'utf-8', + timeout, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } + return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], { + encoding: 'utf-8', + timeout: timeout + 5000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); +} + +function main() { + try { + const input = readInput(); + if (process.env.GITNEXUS_DEBUG) { + // Echo the payload so users can capture Cursor's actual contract when + // diagnosing why augmentation isn't firing. Stderr only — stdout is + // reserved for the JSON response Cursor consumes. + try { + process.stderr.write( + `GitNexus Cursor hook stdin: ${JSON.stringify(input).slice(0, 500)}\n`, + ); + } catch { + /* never let debug logging break the hook */ + } + } + const cwd = input.cwd || process.cwd(); + if (!path.isAbsolute(cwd)) return; + const gitNexusDir = findGitNexusDir(cwd); + if (!gitNexusDir) return; + + const toolName = input.tool_name || ''; + const toolInput = input.tool_input || {}; + + const pattern = extractPattern(toolName, toolInput); + if (!pattern || pattern.length < 3) return; + + const release = acquireHookSlot(gitNexusDir); + if (!release) return; + + const cliPath = resolveCliPath(); + let result = ''; + try { + const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000); + if (!child.error && child.status === 0) { + result = child.stderr || ''; + } + } catch { + /* graceful failure */ + } finally { + release(); + } + + if (result && result.trim()) { + console.log(JSON.stringify({ additional_context: result.trim() })); + } + } catch (err) { + if (process.env.GITNEXUS_DEBUG) { + console.error('GitNexus Cursor hook error:', (err.message || '').slice(0, 200)); + } + } +} + +main(); diff --git a/gitnexus-cursor-integration/hooks/hook-lock.cjs b/gitnexus-cursor-integration/hooks/hook-lock.cjs new file mode 100644 index 000000000..759856384 --- /dev/null +++ b/gitnexus-cursor-integration/hooks/hook-lock.cjs @@ -0,0 +1,119 @@ +const fs = require('fs'); +const path = require('path'); + +const HOOK_LOCK_SUBDIR = '.hook-locks'; +const HOOK_LOCK_MAX_INFLIGHT = 3; +const HOOK_LOCK_STALE_MS = 30000; + +function acquireHookSlot(gitNexusDir) { + const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR); + try { + fs.mkdirSync(lockDir, { recursive: true }); + } catch { + // Cannot create lock dir (read-only fs, cross-user perm denial, out of + // inodes, etc.) — fail closed by returning null. Caller skips augment. + // Fail-open here would let N concurrent hooks all proceed unguarded and + // reintroduce the #1486 fan-out the guard exists to prevent. + return null; + } + + const myPidStr = String(process.pid); + + for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) { + const slotPath = path.join(lockDir, `slot-${slot}.lock`); + for (let attempt = 0; attempt < 2; attempt++) { + try { + fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' }); + let released = false; + const release = () => { + if (released) return; + released = true; + try { + // Only unlink if we still own the slot. If we appeared stale and + // another hook took over, the file now belongs to it — leave alone. + const content = fs.readFileSync(slotPath, 'utf-8').trim(); + if (content === myPidStr) fs.unlinkSync(slotPath); + } catch { + /* already removed or unreadable */ + } + }; + process.on('exit', release); + return release; + } catch { + // Slot exists. Decide whether to take it over. + // Open once and inspect mtime + content via the same fd so there's + // no TOCTOU between the metadata check and the content read + // (codeql js/file-system-race). + let fd; + try { + fd = fs.openSync(slotPath, 'r'); + } catch { + continue; // Vanished between EEXIST and open — retry this slot. + } + let isLive = false; + let mtimeMs = Date.now(); + try { + mtimeMs = fs.fstatSync(fd).mtimeMs; + const buf = Buffer.alloc(32); + const n = fs.readSync(fd, buf, 0, 32, 0); + const ownerStr = buf.slice(0, n).toString('utf-8').trim(); + if (ownerStr === '') { + // Owner created the file but hasn't written its PID yet. The + // wx open+write window is microseconds; give it the benefit + // of the doubt and treat as live. + isLive = true; + } else { + const owner = Number.parseInt(ownerStr, 10); + if (Number.isFinite(owner) && owner > 0) { + try { + process.kill(owner, 0); + isLive = true; + } catch (e) { + // ESRCH = process gone → treat as dead. EPERM = process exists + // but owned by another user (cross-user lock dir) → still alive, + // keep the slot. Anything else: be conservative, assume alive. + if (e && e.code === 'ESRCH') { + isLive = false; + } else { + isLive = true; + } + } + } + } + } catch { + /* unreadable — treat as dead */ + } finally { + try { + fs.closeSync(fd); + } catch { + /* already closed */ + } + } + // For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins — + // a slow-but-alive hook is never wrongly evicted. For older slots, + // age is the final arbiter as a defense against PID reuse on long- + // abandoned slots. 30s >> the 7s augment timeout, so a healthy run + // never crosses this threshold. + if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) { + isLive = false; + } + if (isLive) break; // Try the next slot. + try { + fs.unlinkSync(slotPath); + } catch { + /* another hook beat us to it — retry will hit EEXIST */ + } + // Loop and retry this slot. + } + } + } + + return null; +} + +module.exports = { + HOOK_LOCK_SUBDIR, + HOOK_LOCK_MAX_INFLIGHT, + HOOK_LOCK_STALE_MS, + acquireHookSlot, +}; diff --git a/gitnexus-cursor-integration/hooks/hooks.json b/gitnexus-cursor-integration/hooks/hooks.json index ede0dfbf3..9ae542c14 100644 --- a/gitnexus-cursor-integration/hooks/hooks.json +++ b/gitnexus-cursor-integration/hooks/hooks.json @@ -1,11 +1,11 @@ { "version": 1, "hooks": { - "beforeShellExecution": [ + "postToolUse": [ { - "command": "./hooks/augment-shell.sh", - "timeout": 5, - "matcher": "\\brg\\b|\\bgrep\\b" + "matcher": "Shell|Read|Grep", + "command": "node ./hooks/gitnexus-hook.cjs", + "timeout": 10 } ] } diff --git a/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts b/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts index d09e8fa89..dcaa45055 100644 --- a/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts +++ b/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts @@ -40,11 +40,27 @@ export interface MethodDispatchIndex { readonly mroByOwnerDefId: ReadonlyMap; /** Interfaces / traits → classes that implement them. */ readonly implsByInterfaceDefId: ReadonlyMap; + /** + * Optional parallel MRO view that EXCLUDES mixin-like augmentation + * (e.g., PHP traits). Populated only when the input supplies + * `computeExtendsOnlyMro`. Used by the super-branch dispatch in + * `receiver-bound-calls` so that `parent::method()` walks the + * inheritance chain only, not the trait-augmented one. Undefined for + * languages without mixin-like semantics — callers should fall back + * to `mroFor` when this is missing. + */ + readonly extendsOnlyMroByOwnerDefId?: ReadonlyMap; /** `mroByOwnerDefId.get`, with an empty frozen array on miss. */ mroFor(ownerDefId: DefId): readonly DefId[]; /** `implsByInterfaceDefId.get`, with an empty frozen array on miss. */ implementorsOf(interfaceDefId: DefId): readonly DefId[]; + /** + * `extendsOnlyMroByOwnerDefId.get`, with an empty frozen array on miss. + * Undefined when `extendsOnlyMroByOwnerDefId` was not populated; callers + * should treat this as equivalent to `mroFor` for non-mixin languages. + */ + readonly extendsOnlyMroFor?: (ownerDefId: DefId) => readonly DefId[]; } export interface MethodDispatchInput { @@ -81,12 +97,25 @@ export interface MethodDispatchInput { * write-wins policy and fires at most once per unique owner. */ readonly implementsOf: (ownerDefId: DefId) => readonly DefId[]; + /** + * Optional: return the EXTENDS-only ancestor chain for `ownerDefId`, + * excluding the owner itself AND any mixin-like augmentation (e.g., + * PHP traits). Languages without mixin semantics leave this undefined + * and the index's `extendsOnlyMroByOwnerDefId` stays unpopulated. + * + * Same contract as `computeMro`: pure, deterministic, `[]` on no parents. + * Called at most once per unique owner (first-write-wins). + */ + readonly computeExtendsOnlyMro?: (ownerDefId: DefId) => readonly DefId[]; } // ─── Builder ──────────────────────────────────────────────────────────────── export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDispatchIndex { const mroByOwnerDefId = new Map(); + const extendsOnlyByOwnerDefId = input.computeExtendsOnlyMro + ? new Map() + : undefined; const implsBuilding = new Map(); const implsSeen = new Map>(); @@ -97,6 +126,14 @@ export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDisp const chain = input.computeMro(ownerId); mroByOwnerDefId.set(ownerId, Object.freeze(chain.slice())); } + if ( + input.computeExtendsOnlyMro !== undefined && + extendsOnlyByOwnerDefId !== undefined && + !extendsOnlyByOwnerDefId.has(ownerId) + ) { + const extOnly = input.computeExtendsOnlyMro(ownerId); + extendsOnlyByOwnerDefId.set(ownerId, Object.freeze(extOnly.slice())); + } for (const ifaceId of input.implementsOf(ownerId)) { let seen = implsSeen.get(ifaceId); @@ -121,7 +158,7 @@ export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDisp implsByInterfaceDefId.set(ifaceId, Object.freeze(owners.slice())); } - return wrapIndex(mroByOwnerDefId, implsByInterfaceDefId); + return wrapIndex(mroByOwnerDefId, implsByInterfaceDefId, extendsOnlyByOwnerDefId); } // ─── Internal ─────────────────────────────────────────────────────────────── @@ -131,8 +168,9 @@ const EMPTY: readonly DefId[] = Object.freeze([]); function wrapIndex( mroByOwnerDefId: Map, implsByInterfaceDefId: Map, + extendsOnlyMroByOwnerDefId: Map | undefined, ): MethodDispatchIndex { - return { + const base: MethodDispatchIndex = { mroByOwnerDefId, implsByInterfaceDefId, mroFor(ownerDefId: DefId): readonly DefId[] { @@ -142,4 +180,14 @@ function wrapIndex( return implsByInterfaceDefId.get(interfaceDefId) ?? EMPTY; }, }; + if (extendsOnlyMroByOwnerDefId !== undefined) { + return { + ...base, + extendsOnlyMroByOwnerDefId, + extendsOnlyMroFor(ownerDefId: DefId): readonly DefId[] { + return extendsOnlyMroByOwnerDefId.get(ownerDefId) ?? EMPTY; + }, + }; + } + return base; } diff --git a/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts index a18ad4930..fff3e7adb 100644 --- a/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts +++ b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts @@ -423,13 +423,30 @@ function applyArityFilter( } let anyCompatible = false; + let anyUnknown = false; for (const state of perCandidate.values()) { const verdict = arityFn(callsite, state.def); state.signals.arityVerdict = verdict; if (verdict === 'compatible') anyCompatible = true; + else if (verdict === 'unknown') anyUnknown = true; } - if (!anyCompatible) return; + // When ALL candidates are 'incompatible' (none compatible, none unknown), + // the call is genuinely arity-broken — drop every candidate so the + // registry returns no resolution. This matches the PHP variadic case + // f(int $req, ...$rest) called with zero args: every candidate definitively + // rejects, and emitting an edge to a definitively-rejected callable is + // a false positive. When some candidates are 'unknown' (missing metadata), + // keep the set so downstream evidence can break the tie — that's the + // original safety-fallback behavior. + if (!anyCompatible) { + if (!anyUnknown) { + for (const defId of perCandidate.keys()) { + perCandidate.delete(defId); + } + } + return; + } // Filter: when at least one compatible candidate exists, drop incompatibles. for (const [defId, state] of perCandidate) { diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 3a560ca5a..91613d2b1 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -26,10 +26,10 @@ "graphology-layout-forceatlas2": "^0.10.1", "graphology-layout-noverlap": "^0.4.2", "graphology-utils": "^2.3.0", - "langchain": "^1.3.4", + "langchain": "^1.3.5", "lru-cache": "^11.2.4", - "lucide-react": "^1.11.0", - "mermaid": "^11.14.0", + "lucide-react": "^1.14.0", + "mermaid": "^11.15.0", "mnemonist": "^0.39.0", "pandemonium": "^2.4.0", "react": "^19.2.5", @@ -49,7 +49,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/dompurify": "^3.0.5", + "@types/dompurify": "^3.2.0", "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -60,7 +60,7 @@ "jsdom": "^29.1.1", "tree-sitter-wasms": "^0.1.13", "typescript": "^5.4.5", - "vite": "^8.0.10", + "vite": "^8.0.11", "vitest": "^4.1.5", "wait-on": "^9.0.5" }, @@ -528,41 +528,10 @@ "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", "license": "MIT" }, - "node_modules/@chevrotain/cst-dts-gen": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz", - "integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==", - "license": "Apache-2.0", - "dependencies": { - "@chevrotain/gast": "12.0.0", - "@chevrotain/types": "12.0.0" - } - }, - "node_modules/@chevrotain/gast": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz", - "integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==", - "license": "Apache-2.0", - "dependencies": { - "@chevrotain/types": "12.0.0" - } - }, - "node_modules/@chevrotain/regexp-to-ast": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz", - "integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==", - "license": "Apache-2.0" - }, "node_modules/@chevrotain/types": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz", - "integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==", - "license": "Apache-2.0" - }, - "node_modules/@chevrotain/utils": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz", - "integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", "license": "Apache-2.0" }, "node_modules/@cspotcode/source-map-support": { @@ -1679,12 +1648,12 @@ } }, "node_modules/@mermaid-js/parser": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz", - "integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz", + "integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==", "license": "MIT", "dependencies": { - "langium": "^4.0.0" + "@chevrotain/types": "~11.1.1" } }, "node_modules/@napi-rs/wasm-runtime": { @@ -1744,9 +1713,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "version": "0.128.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz", + "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -1769,9 +1738,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==", "cpu": [ "arm64" ], @@ -1785,9 +1754,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==", "cpu": [ "arm64" ], @@ -1801,9 +1770,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==", "cpu": [ "x64" ], @@ -1817,9 +1786,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==", "cpu": [ "x64" ], @@ -1833,9 +1802,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz", + "integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==", "cpu": [ "arm" ], @@ -1849,9 +1818,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==", "cpu": [ "arm64" ], @@ -1865,9 +1834,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==", "cpu": [ "arm64" ], @@ -1881,9 +1850,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==", "cpu": [ "ppc64" ], @@ -1897,9 +1866,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==", "cpu": [ "s390x" ], @@ -1913,9 +1882,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==", "cpu": [ "x64" ], @@ -1929,9 +1898,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==", "cpu": [ "x64" ], @@ -1945,9 +1914,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==", "cpu": [ "arm64" ], @@ -1961,9 +1930,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz", + "integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==", "cpu": [ "wasm32" ], @@ -1979,9 +1948,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==", "cpu": [ "arm64" ], @@ -1995,9 +1964,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==", "cpu": [ "x64" ], @@ -2800,13 +2769,14 @@ "license": "MIT" }, "node_modules/@types/dompurify": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", - "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz", + "integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==", + "deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.", "dev": true, "license": "MIT", "dependencies": { - "@types/trusted-types": "*" + "dompurify": "*" } }, "node_modules/@types/estree": { @@ -2909,8 +2879,8 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "devOptional": true, - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/@types/unist": { "version": "3.0.3", @@ -3642,34 +3612,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chevrotain": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz", - "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", - "license": "Apache-2.0", - "dependencies": { - "@chevrotain/cst-dts-gen": "12.0.0", - "@chevrotain/gast": "12.0.0", - "@chevrotain/regexp-to-ast": "12.0.0", - "@chevrotain/types": "12.0.0", - "@chevrotain/utils": "12.0.0" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/chevrotain-allstar": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz", - "integrity": "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==", - "license": "MIT", - "dependencies": { - "lodash-es": "^4.17.21" - }, - "peerDependencies": { - "chevrotain": "^12.0.0" - } - }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -4627,6 +4569,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", @@ -5643,53 +5595,21 @@ "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" }, "node_modules/langchain": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.3.4.tgz", - "integrity": "sha512-umrD+ZC6vr0Q0U1lC5PoIMMQqgnP+7QhaIdAXCeZiSX2GPVBiVR7Ed2ZR+3MPvz1yWrRtIm17wPaovkND+wOXg==", + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.3.5.tgz", + "integrity": "sha512-QSB8TEo6G1tWupgNt1Osm8ylLLoOMq1lLw5NeijnIwRPI5BqdBjUn4/U8usbjEJJcQnbXSK2qTKgTx7zvblnBw==", "license": "MIT", "dependencies": { "@langchain/langgraph": "^1.2.9", "@langchain/langgraph-checkpoint": "^1.0.1", "langsmith": ">=0.5.0 <1.0.0", - "uuid": "^11.1.0", "zod": "^3.25.76 || ^4" }, "engines": { "node": ">=20" }, "peerDependencies": { - "@langchain/core": "^1.1.41" - } - }, - "node_modules/langchain/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, - "node_modules/langium": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.2.tgz", - "integrity": "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==", - "license": "MIT", - "dependencies": { - "@chevrotain/regexp-to-ast": "~12.0.0", - "chevrotain": "~12.0.0", - "chevrotain-allstar": "~0.4.1", - "vscode-languageserver": "~9.0.1", - "vscode-languageserver-textdocument": "~1.0.11", - "vscode-uri": "~3.1.0" - }, - "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" + "@langchain/core": "^1.1.42" } }, "node_modules/langsmith": { @@ -6041,9 +5961,9 @@ } }, "node_modules/lucide-react": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.11.0.tgz", - "integrity": "sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz", + "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -6435,14 +6355,14 @@ } }, "node_modules/mermaid": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz", - "integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==", + "version": "11.15.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz", + "integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==", "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", - "@mermaid-js/parser": "^1.1.0", + "@mermaid-js/parser": "^1.1.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", @@ -6453,27 +6373,14 @@ "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", + "es-toolkit": "^1.45.1", "katex": "^0.16.25", "khroma": "^2.1.0", - "lodash-es": "^4.17.23", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", - "uuid": "^11.1.0" - } - }, - "node_modules/mermaid/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "node_modules/micromark": { @@ -7229,9 +7136,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -7608,9 +7515,9 @@ } }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "funding": [ { "type": "opencollective", @@ -7960,13 +7867,13 @@ "license": "Unlicense" }, "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", + "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@oxc-project/types": "=0.128.0", + "@rolldown/pluginutils": "1.0.0-rc.18" }, "bin": { "rolldown": "bin/cli.mjs" @@ -7975,27 +7882,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + "@rolldown/binding-android-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-x64": "1.0.0-rc.18", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", + "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", "license": "MIT" }, "node_modules/roughjs": { @@ -8715,15 +8622,15 @@ } }, "node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz", + "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==", "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", + "postcss": "^8.5.14", + "rolldown": "1.0.0-rc.18", "tinyglobby": "^0.2.16" }, "bin": { @@ -8740,7 +8647,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -8888,55 +8795,6 @@ "dev": true, "license": "MIT" }, - "node_modules/vscode-jsonrpc": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", - "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/vscode-languageserver": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", - "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", - "license": "MIT", - "dependencies": { - "vscode-languageserver-protocol": "3.17.5" - }, - "bin": { - "installServerIntoExtension": "bin/installServerIntoExtension" - } - }, - "node_modules/vscode-languageserver-protocol": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", - "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", - "license": "MIT", - "dependencies": { - "vscode-jsonrpc": "8.2.0", - "vscode-languageserver-types": "3.17.5" - } - }, - "node_modules/vscode-languageserver-textdocument": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", - "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", - "license": "MIT" - }, - "node_modules/vscode-languageserver-types": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", - "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", - "license": "MIT" - }, - "node_modules/vscode-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", - "license": "MIT" - }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 51d7b0520..79d290299 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -36,10 +36,10 @@ "graphology-layout-forceatlas2": "^0.10.1", "graphology-layout-noverlap": "^0.4.2", "graphology-utils": "^2.3.0", - "langchain": "^1.3.4", + "langchain": "^1.3.5", "lru-cache": "^11.2.4", - "lucide-react": "^1.11.0", - "mermaid": "^11.14.0", + "lucide-react": "^1.14.0", + "mermaid": "^11.15.0", "mnemonist": "^0.39.0", "pandemonium": "^2.4.0", "react": "^19.2.5", @@ -59,7 +59,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/dompurify": "^3.0.5", + "@types/dompurify": "^3.2.0", "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -70,7 +70,7 @@ "jsdom": "^29.1.1", "tree-sitter-wasms": "^0.1.13", "typescript": "^5.4.5", - "vite": "^8.0.10", + "vite": "^8.0.11", "vitest": "^4.1.5", "wait-on": "^9.0.5" } diff --git a/gitnexus/CHANGELOG.md b/gitnexus/CHANGELOG.md index ca51f0493..583ed97b3 100644 --- a/gitnexus/CHANGELOG.md +++ b/gitnexus/CHANGELOG.md @@ -4,6 +4,73 @@ All notable changes to GitNexus will be documented in this file. ## [Unreleased] +## [1.6.4] - 2026-05-10 + +### Added + +- **`gitnexus publish`** — opt-in command to push your indexed graph to the understand-quickly registry for shareable browsing (#1425) +- **`IncludeExtractor` for C++** — cross-repo include tracking joins the group contract pipeline (#1156) +- **Unreal Engine C++ support** — strips reflection macros (`UCLASS`, `UFUNCTION`, `UPROPERTY`, etc.) before tree-sitter parses, so UE projects index cleanly (#1439) +- **Thrift contracts extractor** — group-mode contract detection for Apache Thrift IDL (#1234) +- **Workspace extractors for Node, Python, Go, Java, Elixir** — group-mode auto-discovery of cross-package boundaries (#1260) +- **Rust workspace cross-crate contracts** — auto-discovery of `[workspace]` member crates and their cross-crate links (#1256) +- **Go scope-resolution hooks** — Go joins Python / C# / TypeScript on the registry-primary RFC #909 path (#1302) +- **TypeScript registry-primary scope resolution (Ring 3)** — TypeScript fully migrated to scope-based resolution (#1050) +- **Configurable group cross-link path exclusions** — reduces false-positive contract links in vendored / monorepo trees (#1093) +- **MCP tool safety annotations** — every MCP tool advertises read-only / mutating semantics so hosts can prompt appropriately (#1127) +- **`--embeddings ` opt-in cap** — bound the embeddings pass on huge graphs (closes #382, #1375) +- **Pino structured logger** — replaces ad-hoc console output across the core with structured JSON logs (with pretty-print for TTY) (#1336) +- **Shared resilient-fetch helper** — single retries + circuit breaker module reused by HF / Docker / publish flows (#1448) +- **`/autofix` ChatOps button** — fork-safe PR autofix pipeline replaces the inline reviewdog flow (#1446, #1458) +- **Automated security & vulnerability scans** in CI (#1297, #1455) + +### Fixed + +- **FTS read-only DB cluster** — hook resolves canonical repo root and guards read-only FTS ensure; missing-FTS warning is now surfaced. Closes #1255, #1287, #1170, #1449, #1440, #1216, #1438 (#1226, #1418, #1107, #1123) +- **WAL corruption recovery** — quarantine corrupted `.wal` files instead of failing analyze; CHECKPOINT before close prevents recurrence; `safeClose` consolidates flush. Closes #1402, #1236, #1273, #1361 (#1417, #1314, #1377) +- **Embedding download failures** — actionable HF_ENDPOINT guidance, retries, timeout, and circuit breaker; bridge `HF_ENDPOINT` to transformers.js; iterative DFS; HF cache via `os.homedir()`. Closes #1378, #1437, #1205 (#1419, #1252, #1078) +- **Windows reliability** — pin tree-sitter-c/cpp to fix segfault, prefer `.cmd`/`.bat` from `where` output, robust LadybugDB lock acquisition for CI integration tests, surface silent finalize-skips so analyze cannot exit 0 without persisting. Closes #1242, #1427, #1447, #1468, #1400; partial #1218 (#1243, #1299, #1430, #1237, #1226, #1235) +- **DuckDB / LadybugDB native** — bumped to 0.16.0 then 0.16.1; prevent extension install hangs; CHECKPOINT before close; WAL quarantine on corruption. Closes #1162, #1160, #273 (#1235, #1326, #1129, #1314, #1417) +- **C# scope-resolution "Cannot add property" crashes** — generic typed properties included in context and impact, fixing crashes on Unity ECS partial structs and on properties whose name matches the class name. Closes #1426, #1465 (#1399) +- **C# frozen-bucket regression** + scope-resolution I8 hardening — closes #1066 (#1082, #1085) +- **Scope resolution** — same-range Module-as-parent for top-level scopes (closes #1086) (#1087); avoid variadic reference-site aggregation (#1112); skip empty scope extraction (#1100); classify Python class methods as Method (#1102) +- **Python** — index repos with empty `__init__.py` and >32 KB files (#1163); walk ancestors for multi-segment dotted imports (#1241); deterministic multi-segment suffix fallback (#1253) +- **TypeScript** — capture missed CALLS edges from HOF callbacks and JSX (#1175); name HOC-wrapped const declarations (`forwardRef` / `memo` / `useCallback` / `useMemo` / `observer`) (#1261); pair-with-arrow `@declaration.function` anchored on inner arrow +- **Go** — loose equality for `Array.find()` null checks (#1384) +- **Swift** — switched to the official prebuilt parser runtime (#1130) +- **Server hardening cluster (U2–U8)** — JS path-injection on `/api/file` + docker-server (U2, #1322); git-clone path/CLI-injection / ReDoS hardening (U3, #1325); per-route rate limiting on FS-touching endpoints (U4, #1327); URL/regex/tag-filter sanitization (U7, #1330); ReDoS in cobol-preprocessor + rust-workspace + cross-impact resource exhaustion (U8, #1331); critical type-confusion + validation helper (#1317); rate-limit `/api/analyze` and `/api/embed` (closes #1328, #1339); IPv6 ipKeyGenerator (closes #1360, #1374); IPv4-compatible IPv6 / NAT64 SSRF bypasses in `validateGitUrl` (closes #1148, 95814847); predictable tempfile names → `crypto.randomBytes` (#1387); log-injection / http-to-file-access / client-side request forgery (#1456); pin Docker Node base images + Trivy verification + Dependabot policy (#1455) +- **Group / contracts** — `runExactMatch` honours `.gitnexusignore` via shared `IgnoreService` (closes #1185, #1247); custom manifest links resolved against graph symbols (#1254); `IgnoreService` EACCES test under uid=0 (#1108) +- **MCP** — close MCP server timeout via stdout discipline + cold-start friction (#1383); avoid `git` from non-repo cwd in sibling-cwd match (closes #1138, #1293); start MCP bridge correctly when using `npx` (#1114); project `tool_map` flows from handlers (#1113); parallelize staleness checks in `list_repos` (#1416) +- **Storage / CLI** — derive registry name from canonical repo root, not worktree slug (closes #1259, #1296); `--skip-git` treats cwd as index root (#1245); keep GitNexus ignores inside `.gitnexus/` (#1248); surface silent finalize-skips so `analyze` cannot exit 0 without persisting (closes #1169, #1237); ignore global registry during staleness checks (#1141); use `os.homedir()` instead of `process.env.HOME` for HF cache dir (#1078); correct OpenCode skills install path in status message (#1386) +- **Docker / server** — dedicated health endpoint for container healthcheck (closes #1147, #1355); HEAD probe so SSE heartbeat doesn't time out healthcheck (#1182); flush WAL after `/api/embed` so search sees new embeddings (closes #1149, #1359); platform-aware semantic fallback (#1150); skip vector index query on unsupported platforms (closes #1178, #1181); serve web UI at root path instead of 404 (#1048) +- **Worker pool** — wait for replacement worker online before dispatch (#1324); prevent premature pool resolution in worker split-and-retry path (#1321); recover worker parse stalls (#1121); widened CI flake-tolerant timeouts (#1323, #1347, #1354) +- **Embeddings storage** — CHECKPOINT before closing DB to prevent WAL corruption (#1314) +- **Performance** — replace O(n³) C3 merge loop with O(n²) head-pointer algorithm (#1316) +- **Install** — vendor tree-sitter-dart source (#1125) +- **Git utils** — suppress stderr leak in `getCurrentCommit` and `getGitRoot` (closes #1172, #1341) +- **Search** — load FTS during core DB init (#1123); create FTS indexes during `analyze` (#1107); surface warning when FTS indexes are missing (#1418) +- **Hooks** — clarify `PostToolUse` hook is notification-only, not auto-reindex (#1070) +- **Docs** — README Web UI section corrected (closes #1110, #1159, #2ff3e64f); Goliath capitalisation typo (#1126) +- **CI** — fork-safe PR autofix pipeline (#1446); consolidated Claude review workflow (#1258); fine-grained PAT for RC tag push (#1407); handle expired artifacts in base coverage fetch (#1410, #1412); allow expected legacy parity failures (#1099); avoid duplicate main push checks; isolate native LadybugDB / CLI e2e flakes; seed e2e with a small fixture repo (#1249); configure e2e GitNexus home at runtime; widen rate-limit test window for Windows CI (#1347) + +### Changed + +- **`gitnexus publish` artefact contract** — universal opt-in publish format introduced (#1425, #1458) +- **Refactor: per-language patterns consolidated into `LanguageProvider`** (#1279) +- **Refactor: `safeClose` helper** consolidates WAL flush across LadybugDB call sites (#1377) +- **Quality: exclude `test/fixtures` from CodeQL, ESLint, and Prettier** (#1313) +- **Regression coverage** for `.gitnexusignore` behaviour with `--skip-git` (#1450) + +### Chore / Dependencies + +- `@ladybugdb/core` 0.16.0 → 0.16.1 (#1235, #1326) +- `@anthropic-ai/sdk` (#1442), `@langchain/anthropic` (#1389), `@langchain/core` (#1394), `@langchain/openai` (#1215) +- `hono` 4.12.9 → 4.12.18 + `@hono/node-server` (#1310, #1311, #1443) +- `axios` (#1345), `fast-uri` 3.1.0 → 3.1.2 (#1441), `lru-cache` 11.3.5 → 11.3.6 (#1344), `mnemonist` 0.40.3 → 0.40.4 (#1239), `express-rate-limit` (#1343, #1397), `onnxruntime-node` (#1213, #1435), `uuid` 13 → 14 in /gitnexus-web (#1211, after revert #1222 / re-land #1250 + #1208) +- `react`/`@types/react` (#1210), `react-dom` 19.2.5 → 19.2.6 (#1396), `react-zoom-pan-pinch` (#1214), `jsdom` 29.0.2 → 29.1.1 (#1395) +- npm_and_yarn group bump (#1312), uv group bump (#1315), `python-dotenv` (#1320), `@types/node` (#1212, #1421, #1436) +- GitHub Actions: `docker/build-push-action` 6.19.2 → 7.1.0 (#1391), `github/codeql-action` 3.35.3 → 4.35.3 (#1390) + ## [1.6.3] - 2026-04-24 ### Added diff --git a/gitnexus/README.md b/gitnexus/README.md index 8a5144598..55cdc12f7 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -33,7 +33,7 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up | Editor | MCP | Skills | Hooks (auto-augment) | Support | |--------|-----|--------|---------------------|---------| | **Claude Code** | Yes | Yes | Yes (PreToolUse) | **Full** | -| **Cursor** | Yes | Yes | — | MCP + Skills | +| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](../gitnexus-cursor-integration/README.md#hook-install)) | **Full** | | **Codex** | Yes | Yes | — | MCP + Skills | | **Windsurf** | Yes | — | — | MCP | | **OpenCode** | Yes | Yes | — | MCP + Skills | diff --git a/gitnexus/hooks/claude/gitnexus-hook.cjs b/gitnexus/hooks/claude/gitnexus-hook.cjs index 7bfa150cd..9541fcb50 100755 --- a/gitnexus/hooks/claude/gitnexus-hook.cjs +++ b/gitnexus/hooks/claude/gitnexus-hook.cjs @@ -14,6 +14,7 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); +const { acquireHookSlot } = require('./hook-lock.cjs'); /** * Read JSON input from stdin synchronously. @@ -207,7 +208,8 @@ function runGitNexusCli(cliPath, args, cwd, timeout) { function handlePreToolUse(input) { const cwd = input.cwd || process.cwd(); if (!path.isAbsolute(cwd)) return; - if (!findGitNexusDir(cwd)) return; + const gitNexusDir = findGitNexusDir(cwd); + if (!gitNexusDir) return; const toolName = input.tool_name || ''; const toolInput = input.tool_input || {}; @@ -217,6 +219,9 @@ function handlePreToolUse(input) { const pattern = extractPattern(toolName, toolInput); if (!pattern || pattern.length < 3) return; + const release = acquireHookSlot(gitNexusDir); + if (!release) return; + const cliPath = resolveCliPath(); let result = ''; try { @@ -226,6 +231,8 @@ function handlePreToolUse(input) { } } catch { /* graceful failure */ + } finally { + release(); } if (result && result.trim()) { diff --git a/gitnexus/hooks/claude/hook-lock.cjs b/gitnexus/hooks/claude/hook-lock.cjs new file mode 100644 index 000000000..759856384 --- /dev/null +++ b/gitnexus/hooks/claude/hook-lock.cjs @@ -0,0 +1,119 @@ +const fs = require('fs'); +const path = require('path'); + +const HOOK_LOCK_SUBDIR = '.hook-locks'; +const HOOK_LOCK_MAX_INFLIGHT = 3; +const HOOK_LOCK_STALE_MS = 30000; + +function acquireHookSlot(gitNexusDir) { + const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR); + try { + fs.mkdirSync(lockDir, { recursive: true }); + } catch { + // Cannot create lock dir (read-only fs, cross-user perm denial, out of + // inodes, etc.) — fail closed by returning null. Caller skips augment. + // Fail-open here would let N concurrent hooks all proceed unguarded and + // reintroduce the #1486 fan-out the guard exists to prevent. + return null; + } + + const myPidStr = String(process.pid); + + for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) { + const slotPath = path.join(lockDir, `slot-${slot}.lock`); + for (let attempt = 0; attempt < 2; attempt++) { + try { + fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' }); + let released = false; + const release = () => { + if (released) return; + released = true; + try { + // Only unlink if we still own the slot. If we appeared stale and + // another hook took over, the file now belongs to it — leave alone. + const content = fs.readFileSync(slotPath, 'utf-8').trim(); + if (content === myPidStr) fs.unlinkSync(slotPath); + } catch { + /* already removed or unreadable */ + } + }; + process.on('exit', release); + return release; + } catch { + // Slot exists. Decide whether to take it over. + // Open once and inspect mtime + content via the same fd so there's + // no TOCTOU between the metadata check and the content read + // (codeql js/file-system-race). + let fd; + try { + fd = fs.openSync(slotPath, 'r'); + } catch { + continue; // Vanished between EEXIST and open — retry this slot. + } + let isLive = false; + let mtimeMs = Date.now(); + try { + mtimeMs = fs.fstatSync(fd).mtimeMs; + const buf = Buffer.alloc(32); + const n = fs.readSync(fd, buf, 0, 32, 0); + const ownerStr = buf.slice(0, n).toString('utf-8').trim(); + if (ownerStr === '') { + // Owner created the file but hasn't written its PID yet. The + // wx open+write window is microseconds; give it the benefit + // of the doubt and treat as live. + isLive = true; + } else { + const owner = Number.parseInt(ownerStr, 10); + if (Number.isFinite(owner) && owner > 0) { + try { + process.kill(owner, 0); + isLive = true; + } catch (e) { + // ESRCH = process gone → treat as dead. EPERM = process exists + // but owned by another user (cross-user lock dir) → still alive, + // keep the slot. Anything else: be conservative, assume alive. + if (e && e.code === 'ESRCH') { + isLive = false; + } else { + isLive = true; + } + } + } + } + } catch { + /* unreadable — treat as dead */ + } finally { + try { + fs.closeSync(fd); + } catch { + /* already closed */ + } + } + // For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins — + // a slow-but-alive hook is never wrongly evicted. For older slots, + // age is the final arbiter as a defense against PID reuse on long- + // abandoned slots. 30s >> the 7s augment timeout, so a healthy run + // never crosses this threshold. + if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) { + isLive = false; + } + if (isLive) break; // Try the next slot. + try { + fs.unlinkSync(slotPath); + } catch { + /* another hook beat us to it — retry will hit EEXIST */ + } + // Loop and retry this slot. + } + } + } + + return null; +} + +module.exports = { + HOOK_LOCK_SUBDIR, + HOOK_LOCK_MAX_INFLIGHT, + HOOK_LOCK_STALE_MS, + acquireHookSlot, +}; diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 04233b1a1..15831e54d 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitnexus", - "version": "1.6.3", + "version": "1.6.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitnexus", - "version": "1.6.3", + "version": "1.6.4", "hasInstallScript": true, "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { @@ -63,7 +63,7 @@ "vitest": "^4.0.18" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "optionalDependencies": { "node-addon-api": "^8.0.0", @@ -1600,9 +1600,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { @@ -1628,9 +1628,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", + "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { @@ -1646,9 +1646,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, "node_modules/@rolldown/binding-android-arm64": { @@ -4543,22 +4543,22 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", - "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz", + "integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", + "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", + "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" }, diff --git a/gitnexus/package.json b/gitnexus/package.json index 810052502..633c69f24 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -1,6 +1,6 @@ { "name": "gitnexus", - "version": "1.6.3", + "version": "1.6.4", "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.", "author": "Abhigyan Patwari", "license": "PolyForm-Noncommercial-1.0.0", diff --git a/gitnexus/scripts/build.js b/gitnexus/scripts/build.js index 84f43fc6b..ec7f67cf4 100644 --- a/gitnexus/scripts/build.js +++ b/gitnexus/scripts/build.js @@ -21,11 +21,15 @@ const SHARED_DEST = path.join(DIST, '_shared'); // ── 1. Build gitnexus-shared ─────────────────────────────────────── console.log('[build] compiling gitnexus-shared…'); -execSync('npx tsc', { cwd: SHARED_ROOT, stdio: 'inherit', timeout: 120_000 }); +const tscCmd = + process.platform === 'win32' + ? path.join('node_modules', '.bin', 'tsc.cmd') + : path.join('node_modules', '.bin', 'tsc'); +execSync(tscCmd, { cwd: SHARED_ROOT, stdio: 'inherit', timeout: 120_000 }); // ── 2. Build gitnexus ────────────────────────────────────────────── console.log('[build] compiling gitnexus…'); -execSync('npx tsc', { cwd: ROOT, stdio: 'inherit', timeout: 120_000 }); +execSync(tscCmd, { cwd: ROOT, stdio: 'inherit', timeout: 120_000 }); // ── 3. Copy shared dist ──────────────────────────────────────────── console.log('[build] copying shared module into dist/_shared…'); diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 41ba6c73e..8e18fed7d 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -28,6 +28,7 @@ interface RepoStats { export interface AIContextOptions { skipAgentsMd?: boolean; noStats?: boolean; + skipSkills?: boolean; } const GITNEXUS_START_MARKER = ''; @@ -94,6 +95,7 @@ function generateGitNexusContent( generatedSkills?: GeneratedSkillInfo[], groupNames?: string[], noStats?: boolean, + skipSkills?: boolean, ): string { const generatedRows = generatedSkills && generatedSkills.length > 0 @@ -105,14 +107,26 @@ function generateGitNexusContent( .join('\n') : ''; - const skillsTable = `| Task | Read this skill file | -|------|---------------------| -| Understand architecture / "How does X work?" | \`.claude/skills/gitnexus/gitnexus-exploring/SKILL.md\` | + // Standard skill rows reference files installed by installSkills(). When + // --skip-skills suppresses that install, these rows must be omitted — else + // AGENTS.md/CLAUDE.md would direct agents to read files that don't exist. + // Community skills (generatedRows) live in .claude/skills/generated/ and + // are independent of --skip-skills, so they remain when present. + const standardSkillsRows = skipSkills + ? '' + : `| Understand architecture / "How does X work?" | \`.claude/skills/gitnexus/gitnexus-exploring/SKILL.md\` | | Blast radius / "What breaks if I change X?" | \`.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md\` | | Trace bugs / "Why is X failing?" | \`.claude/skills/gitnexus/gitnexus-debugging/SKILL.md\` | | Rename / extract / split / refactor | \`.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md\` | | Tools, resources, schema reference | \`.claude/skills/gitnexus/gitnexus-guide/SKILL.md\` | -| Index, status, clean, wiki CLI commands | \`.claude/skills/gitnexus/gitnexus-cli/SKILL.md\` |${generatedRows ? '\n' + generatedRows : ''}`; +| Index, status, clean, wiki CLI commands | \`.claude/skills/gitnexus/gitnexus-cli/SKILL.md\` |`; + + const tableBody = [standardSkillsRows, generatedRows].filter(Boolean).join('\n'); + const skillsTable = tableBody + ? `| Task | Read this skill file | +|------|---------------------| +${tableBody}` + : ''; return `${GITNEXUS_START_MARKER} # GitNexus — Code Intelligence @@ -153,11 +167,15 @@ This repository is listed under GitNexus **group(s): ${groupNames.join(', ')}** ` : '' -}## CLI +}${ + skillsTable + ? `## CLI ${skillsTable} -${GITNEXUS_END_MARKER}`; +` + : '' + }${GITNEXUS_END_MARKER}`; } /** @@ -319,6 +337,7 @@ export async function generateAIContextFiles( generatedSkills, groupNames, options?.noStats, + options?.skipSkills, ); const createdFiles: string[] = []; @@ -337,10 +356,14 @@ export async function generateAIContextFiles( createdFiles.push('CLAUDE.md (skipped via --skip-agents-md)'); } - // Install skills to .claude/skills/gitnexus/ - const installedSkills = await installSkills(repoPath); - if (installedSkills.length > 0) { - createdFiles.push(`.claude/skills/gitnexus/ (${installedSkills.length} skills)`); + // Install skills to .claude/skills/gitnexus/ (unless --skip-skills) + if (!options?.skipSkills) { + const installedSkills = await installSkills(repoPath); + if (installedSkills.length > 0) { + createdFiles.push(`.claude/skills/gitnexus/ (${installedSkills.length} skills)`); + } + } else { + createdFiles.push('.claude/skills/gitnexus/ (skipped via --skip-skills)'); } return { files: createdFiles }; diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 47745c575..d5a7638f9 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -119,6 +119,10 @@ export interface AnalyzeOptions { skipAgentsMd?: boolean; /** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */ noStats?: boolean; + /** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */ + skipSkills?: boolean; + /** Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills). */ + indexOnly?: boolean; /** Index the folder even when no .git directory is present. */ skipGit?: boolean; /** @@ -150,6 +154,24 @@ export interface AnalyzeOptions { embeddingDevice?: string; } +/** + * Whether the post-index skill step should run. + * + * The gated block does two things in sequence: (1) generates the community + * skill files from `--skills`, and (2) re-runs `generateAIContextFiles` so + * AGENTS.md/CLAUDE.md can reference the freshly written skills. Both are + * suppressed together — `--index-only` drops the entire step, not just the + * community-skill write. Name retained for the test contract; see call site + * in `analyzeCommand` for the AGENTS.md/CLAUDE.md re-generation it also gates. + * + * Kept as a pure helper so the `--index-only --skills` contract is unit-tested + * without booting the full analyze pipeline (#742 review). + */ +export const shouldGenerateCommunitySkillFiles = ( + options: Pick | undefined, + pipelineResult: unknown, +): boolean => Boolean(options?.skills && pipelineResult && !options?.indexOnly); + export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => { if (ensureHeap()) return; @@ -245,6 +267,18 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption console.log('\n GitNexus Analyzer\n'); + // `--index-only` is the stronger contract — it suppresses every form of file + // injection, including community skill writes that `--skills` would normally + // produce. Surface the override explicitly so users don't wonder why a + // pipeline re-index ran but no skill files appeared. The pipeline still + // re-runs (see `force: options?.force || options?.skills` below); the warning + // is purely about the dropped post-index write step. + if (options?.indexOnly && options?.skills) { + console.log( + ' Note: --index-only overrides --skills; community skill files will not be written.\n', + ); + } + let repoPath: string; if (inputPath) { repoPath = path.resolve(inputPath); @@ -399,6 +433,9 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption // ── Run shared analysis orchestrator ─────────────────────────────── try { + const skipAll = options?.indexOnly; + const skipAgentsMd = skipAll || options?.skipAgentsMd; + const skipSkills = skipAll || options?.skipSkills; const result = await runFullAnalysis( repoPath, { @@ -410,7 +447,8 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption embeddingsNodeLimit, dropEmbeddings: options?.dropEmbeddings, skipGit: options?.skipGit, - skipAgentsMd: options?.skipAgentsMd, + skipAgentsMd, + skipSkills, noStats: options?.noStats, registryName: options?.name, // Registry-collision bypass — its own CLI flag, intentionally NOT @@ -456,8 +494,10 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption // a healthy index. await assertAnalysisFinalized(repoPath); - // Skill generation (CLI-only, uses pipeline result from analysis) - if (options?.skills && result.pipelineResult) { + // Skill generation (CLI-only, uses pipeline result from analysis). + // Gated so `--index-only --skills` skips community skill writes too + // (`shouldGenerateCommunitySkillFiles` — see unit test). + if (shouldGenerateCommunitySkillFiles(options, result.pipelineResult)) { updateBar(99, 'Generating skill files...'); try { const { generateSkillFiles } = await import('./skill-gen.js'); @@ -497,7 +537,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption processes: s.processes, }, skillResult.skills, - { skipAgentsMd: options?.skipAgentsMd, noStats: options?.noStats }, + { skipAgentsMd, skipSkills, noStats: options?.noStats }, ); } } catch { diff --git a/gitnexus/src/cli/augment.ts b/gitnexus/src/cli/augment.ts index 553e3f591..a97f6e23c 100644 --- a/gitnexus/src/cli/augment.ts +++ b/gitnexus/src/cli/augment.ts @@ -2,7 +2,7 @@ * Augment CLI Command * * Fast-path command for platform hooks. - * Shells out from Claude Code PreToolUse / Cursor beforeShellExecution hooks. + * Shells out from Claude Code PreToolUse / Cursor postToolUse hooks. * * Usage: gitnexus augment * Returns enriched text to stdout. diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index e4a455d40..4b009e4aa 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -33,9 +33,20 @@ program 'Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` ' + 'preserves any embeddings already present in the index.', ) - .option('--skills', 'Generate repo-specific skill files from detected communities') + .option( + '--skills', + 'Generate repo-specific skill files from detected communities ' + + '(no-op when --index-only is also set).', + ) .option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md') .option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md') + .option( + '--skip-skills', + 'Skip installing standard GitNexus skill files under .claude/skills/gitnexus/. ' + + 'Does not suppress community skills from --skills (those use .claude/skills/generated/). ' + + 'Use --index-only to skip all AI-context file injection.', + ) + .option('--index-only', 'Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills)') .option( '--skip-git', 'Treat the provided path/cwd as the index root and skip parent git-root discovery', @@ -150,6 +161,8 @@ program ) .option('--no-reasoning-model', 'Disable reasoning model mode (overrides saved config)') .option('--concurrency ', 'Parallel LLM calls (default: 3)', '3') + .option('--timeout ', 'Per-attempt LLM request timeout in seconds (default: 60)') + .option('--retries ', 'Max LLM retry attempts per request (default: 3)') .option('--gist', 'Publish wiki as a public GitHub Gist after generation') .option('-v, --verbose', 'Enable verbose output (show LLM commands and responses)') .option('--review', 'Stop after grouping to review module structure before generating pages') diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index af3c4737a..8f52e0f2d 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -364,6 +364,15 @@ async function installClaudeCodeHooks(result: SetupResult): Promise { // Script not found in source — skip } + try { + await fs.copyFile( + path.join(pluginHooksPath, 'hook-lock.cjs'), + path.join(destHooksDir, 'hook-lock.cjs'), + ); + } catch { + // Helper not found in source — skip + } + const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/'); // Escape backslashes FIRST, then quotes (CodeQL js/incomplete-sanitization). // The previous shape `replace(/"/g, '\\"')` alone would let `path\with"quote` diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index 38a0f82a6..8d9da9572 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -33,6 +33,8 @@ export interface WikiCommandOptions { provider?: LLMProvider; verbose?: boolean; review?: boolean; + timeout?: string; + retries?: string; } /** @@ -347,6 +349,16 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio } } + // ── Apply per-run overrides not saved to config ──────────────────── + if (options?.timeout) { + const secs = parseInt(options.timeout, 10); + if (!isNaN(secs) && secs > 0) llmConfig.requestTimeoutMs = secs * 1000; + } + if (options?.retries) { + const n = parseInt(options.retries, 10); + if (!isNaN(n) && n > 0) llmConfig.maxAttempts = n; + } + // ── Setup progress bar with elapsed timer ────────────────────────── const bar = new cliProgress.SingleBar( { diff --git a/gitnexus/src/core/augmentation/engine.ts b/gitnexus/src/core/augmentation/engine.ts index f97415cc9..81e41077e 100644 --- a/gitnexus/src/core/augmentation/engine.ts +++ b/gitnexus/src/core/augmentation/engine.ts @@ -2,8 +2,8 @@ * Augmentation Engine * * Lightweight, fast-path enrichment of search patterns with knowledge graph context. - * Designed to be called from platform hooks (Claude Code PreToolUse, Cursor beforeShellExecution) - * when an agent runs grep/glob/search. + * Designed to be called from platform hooks (Claude Code PreToolUse, Cursor postToolUse) + * when an agent runs grep/glob/read/search. * * Performance target: <500ms cold start, <200ms warm. * @@ -86,6 +86,9 @@ async function findRepoForCwd(cwd: string): Promise<{ export async function augment(pattern: string, cwd?: string): Promise { if (!pattern || pattern.length < 3) return ''; + const patternFirstWord = pattern.trim().replace(/'/g, "''").split(/\s+/)[0]; + if (!patternFirstWord || patternFirstWord.length < 2) return ''; + const workDir = cwd || process.cwd(); try { @@ -104,9 +107,7 @@ export async function augment(pattern: string, cwd?: string): Promise { } // Step 1: BM25 search (fast, no embeddings) - const { results: bm25Results } = await searchFTSFromLbug(pattern, 10, repoId); - - if (bm25Results.length === 0) return ''; + const { results: bm25Results, ftsAvailable } = await searchFTSFromLbug(pattern, 10, repoId); // Step 2: Map BM25 file results to symbols const symbolMatches: Array<{ @@ -124,7 +125,7 @@ export async function augment(pattern: string, cwd?: string): Promise { repoId, ` MATCH (n) WHERE n.filePath = '${escaped}' - AND n.name CONTAINS '${pattern.replace(/'/g, "''").split(/\s+/)[0]}' + AND n.name CONTAINS '${patternFirstWord}' RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath LIMIT 3 `, @@ -143,6 +144,29 @@ export async function augment(pattern: string, cwd?: string): Promise { } } + // When FTS indexes are unavailable (read-only DB, first run before indexes are built), + // fall back to a direct name CONTAINS query so enrichment still works. + if (symbolMatches.length === 0 && !ftsAvailable) { + const fallbackRows = await executeQuery( + repoId, + ` + MATCH (n) + WHERE n.name CONTAINS '${patternFirstWord}' + RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath + LIMIT 5 + `, + ).catch(() => []); + for (const sym of fallbackRows) { + symbolMatches.push({ + nodeId: sym.id || sym[0], + name: sym.name || sym[1], + type: sym.type || sym[2], + filePath: sym.filePath || sym[3], + score: 1.0, + }); + } + } + if (symbolMatches.length === 0) return ''; // Step 3: Batch-fetch callers/callees/processes/cohesion for top matches diff --git a/gitnexus/src/core/embeddings/ast-utils.ts b/gitnexus/src/core/embeddings/ast-utils.ts index 8456b249e..e4976d71e 100644 --- a/gitnexus/src/core/embeddings/ast-utils.ts +++ b/gitnexus/src/core/embeddings/ast-utils.ts @@ -10,6 +10,7 @@ import { isLanguageAvailable, resolveLanguageKey, } from '../tree-sitter/parser-loader.js'; +import { parseSourceSafe } from '../tree-sitter/safe-parse.js'; const parserCache = new Map(); @@ -29,7 +30,7 @@ export const ensureAndParse = async (content: string, filePath: string): Promise parserCache.set(parserKey, parserInstance); } - return parserInstance.parse(content); + return parseSourceSafe(parserInstance, content); }; const FUNCTION_LIKE_TYPES = new Set([ diff --git a/gitnexus/src/core/embeddings/http-client.ts b/gitnexus/src/core/embeddings/http-client.ts index e3fb06045..e8e9073ff 100644 --- a/gitnexus/src/core/embeddings/http-client.ts +++ b/gitnexus/src/core/embeddings/http-client.ts @@ -40,8 +40,11 @@ const readConfig = (): HttpConfig | null => { const rawDims = process.env.GITNEXUS_EMBEDDING_DIMS; let dimensions: number | undefined; if (rawDims !== undefined) { + if (!/^\d+$/.test(rawDims)) { + throw new Error(`GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${rawDims}"`); + } const parsed = parseInt(rawDims, 10); - if (Number.isNaN(parsed) || parsed <= 0) { + if (parsed <= 0) { throw new Error(`GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${rawDims}"`); } dimensions = parsed; @@ -91,7 +94,13 @@ interface EmbeddingItem { * @param model - Model name for the request body * @param apiKey - Bearer token (only used in Authorization header) * @param batchIndex - Logical batch number (for error context) - * @param attempt - Current retry attempt (internal) + * @param dimensions - Optional output-vector size. When provided, sent as + * the `dimensions` field in the request body. Endpoints that implement + * Matryoshka truncation (OpenAI text-embedding-3-*, Cohere embed-v3, + * Voyage) return a truncated vector at that size; endpoints that do not + * recognise the field may ignore it or return 400. Leave + * `GITNEXUS_EMBEDDING_DIMS` unset for strict backends that reject + * unknown fields. */ const httpEmbedBatch = async ( url: string, @@ -99,7 +108,16 @@ const httpEmbedBatch = async ( model: string, apiKey: string, batchIndex = 0, + dimensions?: number, ): Promise => { + const requestBody: { input: string[]; model: string; dimensions?: number } = { + input: batch, + model, + }; + if (dimensions !== undefined) { + requestBody.dimensions = dimensions; + } + let resp: Response; try { resp = await resilientFetch( @@ -111,7 +129,7 @@ const httpEmbedBatch = async ( 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, - body: JSON.stringify({ input: batch, model }), + body: JSON.stringify(requestBody), }, { breakerKey: HTTP_BREAKER_KEY, @@ -169,7 +187,14 @@ export const httpEmbed = async (texts: string[]): Promise => { for (let i = 0; i < texts.length; i += HTTP_BATCH_SIZE) { const batch = texts.slice(i, i + HTTP_BATCH_SIZE); const batchIndex = Math.floor(i / HTTP_BATCH_SIZE); - const items = await httpEmbedBatch(url, batch, config.model, config.apiKey, batchIndex); + const items = await httpEmbedBatch( + url, + batch, + config.model, + config.apiKey, + batchIndex, + config.dimensions, + ); if (items.length !== batch.length) { throw new Error( @@ -212,7 +237,14 @@ export const httpEmbedQuery = async (text: string): Promise => { if (!config) throw new Error('HTTP embedding not configured'); const url = `${config.baseUrl}/embeddings`; - const items = await httpEmbedBatch(url, [text], config.model, config.apiKey); + const items = await httpEmbedBatch( + url, + [text], + config.model, + config.apiKey, + 0, + config.dimensions, + ); if (!items.length) { throw new Error(`Embedding endpoint returned empty response (${safeUrl(url)})`); } diff --git a/gitnexus/src/core/group/extractors/grpc-extractor.ts b/gitnexus/src/core/group/extractors/grpc-extractor.ts index c08ba7c36..56107d8ee 100644 --- a/gitnexus/src/core/group/extractors/grpc-extractor.ts +++ b/gitnexus/src/core/group/extractors/grpc-extractor.ts @@ -5,6 +5,7 @@ import { createIgnoreFilter } from '../../../config/ignore-service.js'; import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js'; import type { ExtractedContract, RepoHandle } from '../types.js'; import { readSafe } from './fs-utils.js'; +import { parseSourceSafe } from '../../tree-sitter/safe-parse.js'; import { logger } from '../../logger.js'; import { GRPC_SCAN_GLOB, @@ -428,7 +429,7 @@ export class GrpcExtractor implements ContractExtractor { let detections: GrpcDetection[] = []; try { parser.setLanguage(plugin.language); - const tree = parser.parse(content); + const tree = parseSourceSafe(parser, content); detections = plugin.scan(tree); } catch { continue; diff --git a/gitnexus/src/core/group/extractors/http-patterns/python.ts b/gitnexus/src/core/group/extractors/http-patterns/python.ts index 27ddf6633..0d3385247 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/python.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/python.ts @@ -1,3 +1,4 @@ +import type Parser from 'tree-sitter'; import Python from 'tree-sitter-python'; import { compilePatterns, @@ -12,6 +13,7 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js'; * - FastAPI `@app.get("/path")` provider decorators * - `requests.get/post/...("url")` consumer calls * - Generic `requests.request("METHOD", "url")` consumer calls + * - `httpx.AsyncClient` instances calling `.get/.post/...("url")` */ const FASTAPI_VERBS: Record = { @@ -77,11 +79,161 @@ const REQUESTS_GENERIC_PATTERNS = compilePatterns({ ], } satisfies LanguagePatterns>); +// ─── Consumer: httpx.AsyncClient assignments ──────────────────────── +// NOTE: This targeted detector only tracks explicit `httpx.AsyncClient(...)` +// construction. Direct imports (`from httpx import AsyncClient`) and module +// aliases (`import httpx as hx`) and annotated assignments (`client: httpx.AsyncClient = ...`) +// are intentionally left for a follow-up. Module-scope clients are only matched +// at module scope; calls inside functions require a function/class-local tracked +// client to avoid false positives from same-name local variables. +const HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS = compilePatterns({ + name: 'python-httpx-async-client-assign', + language: Python, + patterns: [ + { + meta: {}, + query: ` + (assignment + left: (_) @client + right: (call + function: (attribute + object: (identifier) @module (#eq? @module "httpx") + attribute: (identifier) @client_class (#eq? @client_class "AsyncClient")))) + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Consumer: async with httpx.AsyncClient() as client ────────────── +const HTTPX_ASYNC_CLIENT_WITH_ALIAS_PATTERNS = compilePatterns({ + name: 'python-httpx-async-client-with-alias', + language: Python, + patterns: [ + { + meta: {}, + query: ` + (as_pattern + (call + function: (attribute + object: (identifier) @module (#eq? @module "httpx") + attribute: (identifier) @client_class (#eq? @client_class "AsyncClient"))) + (as_pattern_target (identifier) @client)) + `, + }, + ], +} satisfies LanguagePatterns>); + +function getScopeKey(node: Parser.SyntaxNode | null, preferClass = false): string { + if (preferClass) { + let current: Parser.SyntaxNode | null = node; + while (current) { + if (current.type === 'class_definition') { + return `class:${current.startIndex}:${current.endIndex}`; + } + current = current.parent; + } + } + + let current: Parser.SyntaxNode | null = node; + while (current) { + if (current.type === 'function_definition') { + return `function:${current.startIndex}:${current.endIndex}`; + } + current = current.parent; + } + + return 'module'; +} + +function trackedClientScopeKey(clientNode: Parser.SyntaxNode): string { + return getScopeKey(clientNode.parent, clientNode.text.includes('.')); +} + +function callScopeKeys(clientNode: Parser.SyntaxNode): string[] { + const keys = new Set(); + const preferClass = clientNode.text.includes('.'); + const nearestScope = getScopeKey(clientNode.parent, preferClass); + + keys.add(nearestScope); + + return [...keys]; +} + +function collectHttpxAsyncClients(tree: Parser.Tree): Map> { + const clients = new Map>(); + + const addClient = (clientNode: Parser.SyntaxNode | undefined) => { + if (!clientNode) return; + const scopeKey = trackedClientScopeKey(clientNode); + const clientText = clientNode.text; + const scopes = clients.get(clientText) ?? new Set(); + scopes.add(scopeKey); + clients.set(clientText, scopes); + }; + + for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS, tree)) { + addClient(match.captures.client); + } + + for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_WITH_ALIAS_PATTERNS, tree)) { + addClient(match.captures.client); + } + + return clients; +} + +function hasTrackedHttpxAsyncClient( + clients: Map>, + clientNode: Parser.SyntaxNode, +): boolean { + const scopes = clients.get(clientNode.text); + if (!scopes) return false; + + return callScopeKeys(clientNode).some((scopeKey) => scopes.has(scopeKey)); +} + +// ─── Consumer: httpx AsyncClient .get/.post/...("url") ────────────── +const HTTPX_ASYNC_CLIENT_VERB_PATTERNS = compilePatterns({ + name: 'python-httpx-async-client-verb', + language: Python, + patterns: [ + { + meta: {}, + query: ` + (call + function: (attribute + object: (_) @client + attribute: (identifier) @method (#match? @method "^(get|post|put|delete|patch)$")) + arguments: (argument_list . (string) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Consumer: httpx AsyncClient .request("METHOD", "url") ───────── +const HTTPX_ASYNC_CLIENT_GENERIC_PATTERNS = compilePatterns({ + name: 'python-httpx-async-client-generic', + language: Python, + patterns: [ + { + meta: {}, + query: ` + (call + function: (attribute + object: (_) @client + attribute: (identifier) @method (#eq? @method "request")) + arguments: (argument_list . (string) @http_method (string) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = { name: 'python-http', language: Python, scan(tree) { const out: HttpDetection[] = []; + const httpxAsyncClients = collectHttpxAsyncClients(tree); // Providers: FastAPI for (const match of runCompiledPatterns(FASTAPI_PATTERNS, tree)) { @@ -137,6 +289,45 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = { }); } + // Consumers: httpx.AsyncClient.("url") + for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_VERB_PATTERNS, tree)) { + const clientNode = match.captures.client; + const methodNode = match.captures.method; + const pathNode = match.captures.path; + if (!clientNode || !methodNode || !pathNode) continue; + if (!hasTrackedHttpxAsyncClient(httpxAsyncClients, clientNode)) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'python-httpx', + method: methodNode.text.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + + // Consumers: httpx.AsyncClient.request("METHOD", "url") + for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_GENERIC_PATTERNS, tree)) { + const clientNode = match.captures.client; + const methodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!clientNode || !methodNode || !pathNode) continue; + if (!hasTrackedHttpxAsyncClient(httpxAsyncClients, clientNode)) continue; + const methodRaw = unquoteLiteral(methodNode.text); + const path = unquoteLiteral(pathNode.text); + if (methodRaw === null || path === null) continue; + out.push({ + role: 'consumer', + framework: 'python-httpx', + method: methodRaw.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + return out; }, }; diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index d989876a8..898a22c38 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -5,6 +5,7 @@ import { createIgnoreFilter } from '../../../config/ignore-service.js'; import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js'; import type { ExtractedContract, RepoHandle } from '../types.js'; import { readSafe } from './fs-utils.js'; +import { parseSourceSafe } from '../../tree-sitter/safe-parse.js'; import { getPluginForFile, HTTP_SCAN_GLOB, type HttpDetection } from './http-patterns/index.js'; /** @@ -172,7 +173,7 @@ export class HttpRouteExtractor implements ContractExtractor { } try { parser.setLanguage(plugin.language); - const tree = parser.parse(content); + const tree = parseSourceSafe(parser, content); const detections = plugin.scan(tree); cachedDetections.set(rel, detections); return detections; diff --git a/gitnexus/src/core/group/extractors/include-extractor.ts b/gitnexus/src/core/group/extractors/include-extractor.ts index 7bbfd61ed..98cbd371f 100644 --- a/gitnexus/src/core/group/extractors/include-extractor.ts +++ b/gitnexus/src/core/group/extractors/include-extractor.ts @@ -10,6 +10,7 @@ import { readSafe } from './fs-utils.js'; import { buildSuffixIndex, type SuffixIndex } from '../../ingestion/import-resolvers/utils.js'; import { createIgnoreFilter } from '../../../config/ignore-service.js'; import { getMaxFileSizeBytes } from '../../ingestion/utils/max-file-size.js'; +import { parseSourceSafe } from '../../tree-sitter/safe-parse.js'; import { logger } from '../../logger.js'; /** @@ -505,7 +506,7 @@ export class IncludeExtractor implements ContractExtractor { let extractionSource: 'tree_sitter' | 'regex_fallback'; try { parser.setLanguage(lang); - const tree = parser.parse(content); + const tree = parseSourceSafe(parser, content); let matches: Parser.QueryMatch[]; try { matches = query.matches(tree.rootNode); diff --git a/gitnexus/src/core/group/extractors/thrift-extractor.ts b/gitnexus/src/core/group/extractors/thrift-extractor.ts index 709968790..7d23e19cc 100644 --- a/gitnexus/src/core/group/extractors/thrift-extractor.ts +++ b/gitnexus/src/core/group/extractors/thrift-extractor.ts @@ -3,6 +3,7 @@ import Parser from 'tree-sitter'; import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js'; import type { ExtractedContract, RepoHandle } from '../types.js'; import { readSafe } from './fs-utils.js'; +import { parseSourceSafe } from '../../tree-sitter/safe-parse.js'; import { getPluginForFile, THRIFT_SCAN_GLOB, @@ -311,7 +312,7 @@ export class ThriftExtractor implements ContractExtractor { let detections: ThriftDetection[] = []; try { parser.setLanguage(plugin.language); - const tree = parser.parse(content); + const tree = parseSourceSafe(parser, content); detections = plugin.scan(tree); } catch { continue; diff --git a/gitnexus/src/core/group/extractors/tree-sitter-scanner.ts b/gitnexus/src/core/group/extractors/tree-sitter-scanner.ts index cd50456aa..0463e8f0c 100644 --- a/gitnexus/src/core/group/extractors/tree-sitter-scanner.ts +++ b/gitnexus/src/core/group/extractors/tree-sitter-scanner.ts @@ -1,4 +1,5 @@ import Parser from 'tree-sitter'; +import { parseSourceSafe } from '../../tree-sitter/safe-parse.js'; /** * Shared, language-agnostic tree-sitter scanning utilities used by group @@ -155,7 +156,7 @@ export function scanFile( let tree: Parser.Tree; try { parser.setLanguage(plugin.language); - tree = parser.parse(content); + tree = parseSourceSafe(parser, content); } catch { return []; } diff --git a/gitnexus/src/core/incremental/shadow-candidates.ts b/gitnexus/src/core/incremental/shadow-candidates.ts new file mode 100644 index 000000000..415a6d9df --- /dev/null +++ b/gitnexus/src/core/incremental/shadow-candidates.ts @@ -0,0 +1,76 @@ +/** + * Shadow-candidate path derivation for incremental indexing. + * + * Background — Bugbot review on PR #1479: + * queryImporters() on a NEWLY ADDED file returns 0 importers in the + * pre-pipeline DB, because the new file's IMPORTS rows haven't been + * written yet. But pre-existing files may have IMPORTS edges that + * *resolved to a sibling path*, and the newcomer can now steal that + * resolution under standard JS/TS module-resolution rules. Without + * pulling those pre-existing files into the writable set, their + * stale CALLS edges remain pointing at the OLD resolution target. + * + * Given an added file path, this helper enumerates the pre-existing + * file paths whose import-resolution claim the newcomer can steal. + * Caller filters the candidates against the prior-run `fileHashes` + * map so we only query importers of paths that actually existed. + * + * Shadow patterns covered (resolution-priority-aware): + * + * (a) Same basename, different extension — + * added `foo/bar.ts` shadows `foo/bar.{tsx,js,jsx,mjs,cjs,d.ts}`. + * (b) Bare-file beats directory-style index — + * added `foo/bar.ts` shadows `foo/bar/index.{ts,tsx,...}`. + * (c) Directory-index beats bare-file — + * added `foo/index.ts` shadows `foo.{ts,tsx,...}` (rare but real, + * e.g. converting a single-file module into a directory module). + * + * Resolution-order priority is conservatively wide: we enumerate ALL + * common extensions because we don't know which the importer actually + * specified, and over-seeding is harmless (extra BFS work, but the + * subgraph extract still gates write-back by file membership). + * + * Cross-platform path separators: candidates are emitted with both `/` + * and `\` for shadow pattern (b), since the caller's prior fileHashes + * map may use either depending on the OS that wrote it. + */ + +const SHADOW_EXTS = ['.d.ts', '.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs']; + +/** + * Enumerate pre-existing paths whose import-resolution `added` can steal. + * + * @param added — repo-relative path of a newly-added file + * @returns deduplicated list of candidate paths (NOT filtered against + * any known-files set — caller does that) + */ +export const shadowCandidatesFor = (added: string): string[] => { + const ext = SHADOW_EXTS.find((e) => added.endsWith(e)); + if (!ext) return []; + + const noExt = added.slice(0, -ext.length); + const out = new Set(); + + // (a) Same basename, different extension. + for (const alt of SHADOW_EXTS) { + if (alt !== ext) out.add(noExt + alt); + } + + // (b) Bare file beats sibling directory-style index. + for (const idx of SHADOW_EXTS) { + out.add(`${noExt}/index${idx}`); + out.add(`${noExt}\\index${idx}`); + } + + // (c) New `foo/index.ext` shadows old `foo.ext`. + const idxSuffixSlash = '/index'; + const idxSuffixBack = '\\index'; + let dir: string | null = null; + if (noExt.endsWith(idxSuffixSlash)) dir = noExt.slice(0, -idxSuffixSlash.length); + else if (noExt.endsWith(idxSuffixBack)) dir = noExt.slice(0, -idxSuffixBack.length); + if (dir !== null) { + for (const alt of SHADOW_EXTS) out.add(dir + alt); + } + + return [...out]; +}; diff --git a/gitnexus/src/core/incremental/subgraph-extract.ts b/gitnexus/src/core/incremental/subgraph-extract.ts new file mode 100644 index 000000000..71fe656be --- /dev/null +++ b/gitnexus/src/core/incremental/subgraph-extract.ts @@ -0,0 +1,123 @@ +/** + * Subgraph extraction for incremental DB writeback. + * + * Given the FULL ctx.graph produced by the pipeline (all files parsed, + * all phases run) and the set of file paths whose DB rows must be + * replaced, produce a smaller KnowledgeGraph that contains: + * + * - Every node whose `properties.filePath` is in `toWriteSet`. + * - Every graph-wide node (Community, Process) — these are regenerated + * each run by the communities/processes phases and must be fully + * rewritten. + * - Every relationship where AT LEAST ONE endpoint is in the writable + * set above. Relationships entirely between unchanged-file nodes + * are skipped — their rows are still in the DB and re-inserting + * them would PK-conflict at COPY time. + * + * The resulting subgraph is what gets passed to `loadGraphToLbug` after + * the orchestrator has deleted the corresponding DB rows. Hydrated + * unchanged-file rows are never touched in the DB. + * + * # Cross-file edge consistency (Finding 1) + * + * `extractChangedSubgraph` intentionally does NOT expand the set it is + * given — expansion is the orchestrator's job, so the SAME expanded set + * can be fed to both `deleteNodesForFile` and this function (asymmetry + * between the delete set and the write set silently corrupts the DB). + * `computeEffectiveWriteSet` below performs the boundary-crossing 1-hop + * walk; the orchestrator composes it with its importer-BFS expansion and + * passes the result here. + * + * Why the 1-hop walk is needed: consider a barrel re-export change — + * file C (a barrel) shifts `export { foo } from './b'` to + * `export { foo } from './d'`. After scope resolution, file A's CALLS + * edge to `foo` resolves to D instead of B, even though A's content is + * byte-for-byte identical: + * + * - Old A→B edge survives in DB (neither A nor B is changed → not deleted) + * - New A→D edge is missing (neither A nor D in writable set → skipped) + * + * Pulling the unchanged-side file of every writable-boundary-crossing + * edge into the write set fixes both halves: the orchestrator's + * `DETACH DELETE` cleans up the stale unchanged-side rows, and the new + * cross-file edges land because at least one endpoint is now writable. + * + * Limitation (documented): if a file X *stopped* importing from a + * changed file C, X has no edge to C in the new graph, so this 1-hop + * walk doesn't catch it. The orchestrator's importer-BFS (which reads + * IMPORTS from the pre-pipeline DB) covers that case instead. + */ + +import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; +import { createKnowledgeGraph } from '../graph/graph.js'; +import type { KnowledgeGraph } from '../graph/types.js'; + +const isGraphWide = (label: string): boolean => label === 'Community' || label === 'Process'; + +/** + * Build a Map for every File-bound node in the graph. + * Graph-wide nodes (Community/Process) have no filePath and are filtered. + */ +const indexNodeFilePaths = (fullGraph: KnowledgeGraph): Map => { + const idx = new Map(); + fullGraph.forEachNode((n: GraphNode) => { + const fp = n.properties?.filePath as string | undefined; + if (fp) idx.set(n.id, fp); + }); + return idx; +}; + +export const extractChangedSubgraph = ( + fullGraph: KnowledgeGraph, + toWriteSet: ReadonlySet, +): KnowledgeGraph => { + const sub = createKnowledgeGraph(); + const writableNodeIds = new Set(); + + fullGraph.forEachNode((n: GraphNode) => { + const filePath = n.properties?.filePath as string | undefined; + const include = (filePath && toWriteSet.has(filePath)) || isGraphWide(n.label); + if (include) { + sub.addNode(n); + writableNodeIds.add(n.id); + } + }); + + fullGraph.forEachRelationship((r: GraphRelationship) => { + if (writableNodeIds.has(r.sourceId) || writableNodeIds.has(r.targetId)) { + sub.addRelationship(r); + } + }); + + return sub; +}; + +/** + * Public — derive the EFFECTIVE write-set: `toWriteSet` expanded by one + * hop along every edge in the new graph that crosses the writable + * boundary (one endpoint in a writable file, the other in an unchanged + * file). The unchanged-side file is pulled in so its stale rows are + * deleted + rewritten in lockstep with the changed side. + * + * Single pass over the edge list. Does NOT mutate `toWriteSet`. The + * orchestrator MUST feed the returned set to both `deleteNodesForFile` + * and `extractChangedSubgraph` — feeding the unexpanded set to either + * one leaves stale rows or PK-conflicts at COPY time. + */ +export const computeEffectiveWriteSet = ( + fullGraph: KnowledgeGraph, + toWriteSet: ReadonlySet, +): Set => { + const nodeFilePaths = indexNodeFilePaths(fullGraph); + const expanded = new Set(toWriteSet); + fullGraph.forEachRelationship((r: GraphRelationship) => { + const sourcePath = nodeFilePaths.get(r.sourceId); + const targetPath = nodeFilePaths.get(r.targetId); + if (!sourcePath || !targetPath) return; // skip edges to graph-wide nodes + const sourceWritable = toWriteSet.has(sourcePath); + const targetWritable = toWriteSet.has(targetPath); + if (sourceWritable && !targetWritable) expanded.add(targetPath); + else if (targetWritable && !sourceWritable) expanded.add(sourcePath); + }); + return expanded; +}; diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index bf0206057..b45478f7a 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -40,13 +40,16 @@ import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared'; import { isRegistryPrimary } from './registry-primary-flag.js'; import { isVerboseIngestionEnabled } from './utils/verbose.js'; import { yieldToEventLoop } from './utils/event-loop.js'; +import { parseSourceSafe } from '../tree-sitter/safe-parse.js'; import { + CLASS_CONTAINER_TYPES, FUNCTION_NODE_TYPES, - findEnclosingClassId, findEnclosingClassInfo, genericFuncName, inferFunctionLabel, } from './utils/ast-helpers.js'; +import type { FieldInfo, FieldExtractorContext } from './field-types.js'; +import type { LanguageProvider } from './language-provider.js'; import { typeTagForId, constTagForId, buildCollisionGroups } from './utils/method-props.js'; import type { MethodInfo } from './method-types.js'; import { @@ -76,6 +79,62 @@ import type { LiteralTypeInferrer } from './type-extractors/types.js'; import type { SyntaxNode } from './utils/ast-helpers.js'; import { logger } from '../logger.js'; + +// ── Property-prepass helpers (parity with parse-worker.ts) ── +// These mirror the sequential-path equivalents in parse-worker.ts so the main- +// thread `processCalls` pre-pass produces byte-identical Property nodes/symbols +// to the worker pool. Drift between the two paths breaks the +// `incremental ≡ --force` invariant the moment a repo crosses the worker +// threshold between runs. + +/** Walk up to the nearest enclosing class/struct/interface AST node. */ +const findEnclosingClassNode = (node: SyntaxNode): SyntaxNode | null => { + let current = node.parent; + while (current) { + if (CLASS_CONTAINER_TYPES.has(current.type)) return current; + current = current.parent; + } + return null; +}; + +/** No-op SymbolTable stub for FieldExtractorContext — matches parse-worker. */ +const NOOP_SYMBOL_TABLE: SymbolTableReader = { + lookupExact: () => undefined, + lookupExactFull: () => undefined, + lookupExactAll: () => [], + lookupCallableByName: () => [], + getFiles: () => [][Symbol.iterator](), + getStats: () => ({ fileCount: 0 }), +}; + +/** + * Extract (and cache) field info for a class node. Cache is passed in so it + * stays scoped to a single `processCalls` invocation rather than leaking + * across analyze runs (worker uses module-level caching because each worker + * process is short-lived; the main thread is not). + * + * Cache key is `${filePath}:${classNode.startIndex}` — startIndex alone is a + * per-file byte offset, so almost every Ruby/Python file's leading class lands + * at byte 0 and would collide across files in the shared map. + */ +const getFieldInfo = ( + classNode: SyntaxNode, + provider: LanguageProvider, + context: FieldExtractorContext, + cache: Map>, +): Map | undefined => { + if (!provider.fieldExtractor) return undefined; + const cacheKey = `${context.filePath}:${classNode.startIndex}`; + const cached = cache.get(cacheKey); + if (cached) return cached; + const result = provider.fieldExtractor.extract(classNode, context); + if (!result?.fields?.length) return undefined; + const map = new Map(); + for (const field of result.fields) map.set(field.name, field); + cache.set(cacheKey, map); + return map; +}; + /** Per-file resolved type bindings for exported symbols. * Populated during call processing, consumed by Phase 14 re-resolution pass. */ export type ExportedTypeMap = Map>; @@ -771,7 +830,7 @@ export const processCalls = async ( if (!tree) { const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content; try { - tree = parser.parse(parseContent, undefined, { + tree = parseSourceSafe(parser, parseContent, undefined, { bufferSize: getTreeSitterBufferSize(parseContent), }); } catch (parseError) { @@ -859,6 +918,120 @@ export const processCalls = async ( prepared.push({ file, language, provider, tree, matches, parentMap, typeEnv }); } + // ── Property-registration pre-pass ── + // Register all routed properties (e.g. Ruby attr_accessor) BEFORE the + // resolution loop so cross-file field-type lookups (e.g. + // `user.address.save → Address#save`) succeed regardless of file + // processing order. This MUST stay in lockstep with the equivalent + // worker-path block in parse-worker.ts (kind === 'properties') — any + // divergence between the two paths breaks the `incremental ≡ --force` + // invariant once a repo crosses the worker threshold between runs. + const fieldInfoCache = new Map>(); + for (const { file, language, provider, matches, typeEnv } of prepared) { + const callRouter = provider.callRouter; + if (!callRouter) continue; + matches.forEach((match) => { + const captureMap: Record = {}; + match.captures.forEach((c) => (captureMap[c.name] = c.node)); + if (!captureMap['call']) return; + const callNameNode = captureMap['call.name']; + if (!callNameNode) return; + const routed = callRouter(callNameNode.text, captureMap['call']); + if (!routed || routed.kind !== 'properties') return; + + const propEnclosingInfo = findEnclosingClassInfo( + captureMap['call'], + file.path, + provider.resolveEnclosingOwner, + ); + const propEnclosingClassId = propEnclosingInfo?.classId ?? null; + + // Enrich routed properties with FieldExtractor metadata so types + // discovered from constructor assignments (e.g. `@address = Address.new`) + // are propagated even when the routing payload itself lacks declaredType. + let routedFieldMap: Map | undefined; + if (provider.fieldExtractor && typeEnv) { + const classNode = findEnclosingClassNode(captureMap['call']); + if (classNode) { + routedFieldMap = getFieldInfo( + classNode, + provider, + { + typeEnv, + symbolTable: NOOP_SYMBOL_TABLE, + filePath: file.path, + language, + }, + fieldInfoCache, + ); + } + } + + const fileId = generateId('File', file.path); + for (const item of routed.items) { + const routedFieldInfo = routedFieldMap?.get(item.propName); + const propQualifiedName = propEnclosingInfo + ? `${propEnclosingInfo.className}.${item.propName}` + : item.propName; + const nodeId = generateId('Property', `${file.path}:${propQualifiedName}`); + graph.addNode({ + id: nodeId, + label: 'Property', + properties: { + name: item.propName, + filePath: file.path, + startLine: item.startLine, + endLine: item.endLine, + language, + isExported: true, + description: item.accessorType, + ...(item.declaredType + ? { declaredType: item.declaredType } + : routedFieldInfo?.type + ? { declaredType: routedFieldInfo.type } + : {}), + ...(routedFieldInfo?.visibility !== undefined + ? { visibility: routedFieldInfo.visibility } + : {}), + ...(routedFieldInfo?.isStatic !== undefined + ? { isStatic: routedFieldInfo.isStatic } + : {}), + ...(routedFieldInfo?.isReadonly !== undefined + ? { isReadonly: routedFieldInfo.isReadonly } + : {}), + }, + }); + ctx.model.symbols.add(file.path, item.propName, nodeId, 'Property', { + ...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}), + ...(item.declaredType + ? { declaredType: item.declaredType } + : routedFieldInfo?.type + ? { declaredType: routedFieldInfo.type } + : {}), + }); + const relId = generateId('DEFINES', `${fileId}->${nodeId}`); + graph.addRelationship({ + id: relId, + sourceId: fileId, + targetId: nodeId, + type: 'DEFINES', + confidence: 1.0, + reason: '', + }); + if (propEnclosingClassId) { + graph.addRelationship({ + id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`), + sourceId: propEnclosingClassId, + targetId: nodeId, + type: 'HAS_PROPERTY', + confidence: 1.0, + reason: '', + }); + } + } + }); + } + // ── Resolution loop: verify constructor bindings and resolve calls ── // The accumulator (if present) is now fully populated from the preparation // loop above, so verifyConstructorBindings sees all provider bindings @@ -929,9 +1102,10 @@ export const processCalls = async ( provider, ); const srcId = enclosing || generateId('File', file.path); - // Defer resolution: Ruby attr_accessor properties are registered during - // this same loop, so cross-file lookups fail if the declaring file hasn't - // been processed yet. Collect now, resolve after all files are done. + // Defer resolution so write-access tracking sees the FINAL graph + // state — properties from the pre-pass are present, but receiver-type + // resolution can still depend on inference that completes during the + // main loop. Resolve after all files have been processed. pendingWrites.push({ receiverTypeName, propertyName, filePath: file.path, srcId }); } // Assignment-only capture (no @call sibling): skip the rest of this @@ -1052,47 +1226,8 @@ export const processCalls = async ( return; case 'properties': { - const fileId = generateId('File', file.path); - const propEnclosingClassId = findEnclosingClassId(captureMap['call'], file.path); - for (const item of routed.items) { - const nodeId = generateId('Property', `${file.path}:${item.propName}`); - graph.addNode({ - id: nodeId, - label: 'Property', - properties: { - name: item.propName, - filePath: file.path, - startLine: item.startLine, - endLine: item.endLine, - language, - isExported: true, - description: item.accessorType, - }, - }); - ctx.model.symbols.add(file.path, item.propName, nodeId, 'Property', { - ...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}), - ...(item.declaredType ? { declaredType: item.declaredType } : {}), - }); - const relId = generateId('DEFINES', `${fileId}->${nodeId}`); - graph.addRelationship({ - id: relId, - sourceId: fileId, - targetId: nodeId, - type: 'DEFINES', - confidence: 1.0, - reason: '', - }); - if (propEnclosingClassId) { - graph.addRelationship({ - id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`), - sourceId: propEnclosingClassId, - targetId: nodeId, - type: 'HAS_PROPERTY', - confidence: 1.0, - reason: '', - }); - } - } + // Properties already registered in the pre-pass above. + // Skip to avoid duplicate nodes/edges. return; } @@ -3283,7 +3418,7 @@ export const extractFetchCallsFromFiles = async ( if (!tree) { const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content; try { - tree = parser.parse(parseContent, undefined, { + tree = parseSourceSafe(parser, parseContent, undefined, { bufferSize: getTreeSitterBufferSize(parseContent), }); } catch { diff --git a/gitnexus/src/core/ingestion/community-processor.ts b/gitnexus/src/core/ingestion/community-processor.ts index 9913e4a3f..ac8f068fa 100644 --- a/gitnexus/src/core/ingestion/community-processor.ts +++ b/gitnexus/src/core/ingestion/community-processor.ts @@ -41,6 +41,24 @@ interface LeidenDetailedResult { modularity: number; } +/** + * Deterministic PRNG (mulberry32) seed for the vendored Leiden algorithm. + * Vendored Leiden defaults `rng: Math.random`, which makes community + * assignment non-deterministic across runs. Passing a seeded RNG gives us + * reproducible community/modularity output, which is required for the + * incremental-indexing equivalence test (incremental ≡ full rebuild). + */ +const LEIDEN_SEED = 0xc0de; +function createSeededRng(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + // ============================================================================ // TYPES // ============================================================================ @@ -150,6 +168,7 @@ export const processCommunities = async ( leiden.detailed(graph, { resolution: isLarge ? 2.0 : 1.0, maxIterations: isLarge ? 3 : 0, + rng: createSeededRng(LEIDEN_SEED), }), ), new Promise((_, reject) => diff --git a/gitnexus/src/core/ingestion/heritage-processor.ts b/gitnexus/src/core/ingestion/heritage-processor.ts index f8628e651..c223bd286 100644 --- a/gitnexus/src/core/ingestion/heritage-processor.ts +++ b/gitnexus/src/core/ingestion/heritage-processor.ts @@ -22,6 +22,7 @@ import { generateId } from '../../lib/utils.js'; import { getLanguageFromFilename, type NodeLabel, type SupportedLanguages } from 'gitnexus-shared'; import { isVerboseIngestionEnabled } from './utils/verbose.js'; import { yieldToEventLoop } from './utils/event-loop.js'; +import { parseSourceSafe } from '../tree-sitter/safe-parse.js'; import { getProvider } from './languages/index.js'; import { getTreeSitterBufferSize } from './constants.js'; import type { @@ -224,7 +225,7 @@ export const processHeritage = async ( // re-parses see the same input as the cached AST. const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content; try { - tree = parser.parse(parseContent, undefined, { + tree = parseSourceSafe(parser, parseContent, undefined, { bufferSize: getTreeSitterBufferSize(parseContent), }); } catch (parseError) { @@ -419,7 +420,7 @@ export async function extractExtractedHeritageFromFiles( if (!tree) { const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content; try { - tree = parser.parse(parseContent, undefined, { + tree = parseSourceSafe(parser, parseContent, undefined, { bufferSize: getTreeSitterBufferSize(parseContent), }); } catch { diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index 6f0b40b6f..f7742faa1 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -8,6 +8,7 @@ import { generateId } from '../../lib/utils.js'; import { getLanguageFromFilename } from 'gitnexus-shared'; import { isVerboseIngestionEnabled } from './utils/verbose.js'; import { yieldToEventLoop } from './utils/event-loop.js'; +import { parseSourceSafe } from '../tree-sitter/safe-parse.js'; import type { ExtractedImport } from './workers/parse-worker.js'; import { getTreeSitterBufferSize } from './constants.js'; import { loadImportConfigs } from './language-config.js'; @@ -307,7 +308,7 @@ export const processImports = async ( if (!tree) { const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content; try { - tree = parser.parse(parseContent, undefined, { + tree = parseSourceSafe(parser, parseContent, undefined, { bufferSize: getTreeSitterBufferSize(parseContent), }); } catch (parseError) { diff --git a/gitnexus/src/core/ingestion/import-resolvers/standard.ts b/gitnexus/src/core/ingestion/import-resolvers/standard.ts index f8aae9625..47e2dabb2 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/standard.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/standard.ts @@ -128,7 +128,21 @@ export const resolveImportPath = ( if (importPath.startsWith('.')) { const resolved = tryResolveWithExtensions(basePath, allFiles); - return cache(resolved); + if (resolved) return cache(resolved); + + // TypeScript ESM: imports use .js/.jsx/.mjs/.cjs but source files are + // .ts/.tsx/.mts/.cts. Strip the JS-family extension and re-resolve. + // NOTE: This fallback only applies to relative imports. Path alias imports + // (e.g. @/utils.js via tsconfig paths) do not yet strip .js extensions — + // that is a known limitation tracked for follow-up. + if (language === SupportedLanguages.TypeScript || language === SupportedLanguages.JavaScript) { + const stripped = stripJsExtension(basePath); + if (stripped !== null) { + return cache(tryResolveWithExtensions(stripped, allFiles)); + } + } + + return cache(null); } // ---- Generic package/absolute import resolution (suffix matching) ---- @@ -182,3 +196,19 @@ export function resolveStandard( export function createStandardStrategy(language: SupportedLanguages): ImportResolverStrategy { return (raw, fp, ctx) => resolveStandard(raw, fp, ctx, language); } + +// ============================================================================ +// ESM extension helpers +// ============================================================================ + +/** JS-family extensions that TypeScript ESM maps to TS equivalents. */ +const JS_EXTENSION_PATTERN = /\.(js|jsx|mjs|cjs)$/; + +/** + * Strip a JS-family extension from a path, returning the stem. + * Returns `null` if the path does not end with a JS-family extension. + */ +export function stripJsExtension(path: string): string | null { + const match = JS_EXTENSION_PATTERN.exec(path); + return match ? path.slice(0, -match[0].length) : null; +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/utils.ts b/gitnexus/src/core/ingestion/import-resolvers/utils.ts index 8d915eb7f..c4d36556c 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/utils.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/utils.ts @@ -9,8 +9,12 @@ export const EXTENSIONS = [ // TypeScript/JavaScript '.tsx', '.ts', + '.mts', + '.cts', '.jsx', '.js', + '.mjs', + '.cjs', '.vue', '/index.tsx', '/index.ts', diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 693e8cec2..7fab4689b 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -46,6 +46,15 @@ import { createCallExtractor } from '../call-extractors/generic.js'; import { cCallConfig, cppCallConfig } from '../call-extractors/configs/c-cpp.js'; import { createHeritageExtractor } from '../heritage-extractors/generic.js'; import { stripUeMacros } from '../cpp-ue-preprocessor.js'; +import { + emitCScopeCaptures, + interpretCImport, + interpretCTypeBinding, + cArityCompatibility, + cBindingScopeFor, + cImportOwningScope, + cReceiverBinding, +} from './c/index.js'; const C_BUILT_INS: ReadonlySet = new Set([ 'printf', @@ -367,6 +376,16 @@ export const cProvider = defineLanguage({ heritageExtractor: createHeritageExtractor(SupportedLanguages.C), labelOverride: cppLabelOverride, builtInNames: C_BUILT_INS, + + // ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ────────── + emitScopeCaptures: emitCScopeCaptures, + interpretImport: interpretCImport, + interpretTypeBinding: interpretCTypeBinding, + bindingScopeFor: cBindingScopeFor, + importOwningScope: cImportOwningScope, + receiverBinding: cReceiverBinding, + arityCompatibility: cArityCompatibility, + // mergeBindings + resolveImportTarget live on ScopeResolver (see c/scope-resolver.ts). }); export const cppProvider = defineLanguage({ diff --git a/gitnexus/src/core/ingestion/languages/c/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/c/arity-metadata.ts new file mode 100644 index 000000000..013d78ea8 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/arity-metadata.ts @@ -0,0 +1,102 @@ +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +export interface CArityInfo { + parameterCount?: number; + requiredParameterCount?: number; + parameterTypes?: string[]; +} + +/** + * Compute declaration arity from a C function definition or declaration node. + */ +export function computeCDeclarationArity(node: SyntaxNode): CArityInfo { + // Find the function_declarator child (may be wrapped in pointer_declarator) + const funcDecl = findFuncDeclarator(node); + if (funcDecl === null) return {}; + + const paramList = funcDecl.childForFieldName('parameters'); + if (paramList === null) return {}; + + const params: SyntaxNode[] = []; + for (let i = 0; i < paramList.childCount; i++) { + const child = paramList.child(i); + if (child === null) continue; + if (child.type === 'parameter_declaration' || child.type === 'variadic_parameter') { + params.push(child); + } + } + + // K&R old-style declaration: `int foo()` has an empty parameter_list with + // no parameter_declaration or variadic_parameter children. Per C89/C99, + // this means the function accepts an unspecified number/types of arguments — + // NOT zero arguments. Return unknown arity to avoid false 'incompatible'. + // `int foo(void)` is the explicit zero-parameter form and is handled below. + if (params.length === 0) return {}; + + // (void) means zero parameters + if (params.length === 1 && params[0].type === 'parameter_declaration') { + const typeNode = params[0].childForFieldName('type'); + const hasDeclarator = params[0].childForFieldName('declarator') !== null; + if (typeNode !== null && typeNode.text === 'void' && !hasDeclarator) { + return { parameterCount: 0, requiredParameterCount: 0, parameterTypes: [] }; + } + } + + const isVariadic = params.some((p) => p.type === 'variadic_parameter'); + const nonVariadicCount = params.filter((p) => p.type !== 'variadic_parameter').length; + + const types: string[] = []; + for (const p of params) { + if (p.type === 'variadic_parameter') { + types.push('...'); + } else { + const typeNode = p.childForFieldName('type'); + types.push(typeNode?.text ?? 'unknown'); + } + } + + return { + parameterCount: isVariadic ? undefined : nonVariadicCount, + requiredParameterCount: nonVariadicCount, + parameterTypes: types, + }; +} + +/** + * Compute call-site arity from a call_expression node. + */ +export function computeCCallArity(node: SyntaxNode): number { + const argList = node.childForFieldName('arguments'); + if (argList === null) return 0; + + let count = 0; + for (let i = 0; i < argList.childCount; i++) { + const child = argList.child(i); + if (child === null) continue; + // Skip punctuation (commas, parens) + if (child.type !== ',' && child.type !== '(' && child.type !== ')') { + count++; + } + } + return count; +} + +function findFuncDeclarator(node: SyntaxNode): SyntaxNode | null { + // Direct child + let decl = node.childForFieldName('declarator'); + if (decl === null) { + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c?.type === 'function_declarator') return c; + } + return null; + } + // Unwrap pointer_declarator + while (decl.type === 'pointer_declarator') { + const next = decl.childForFieldName('declarator'); + if (next === null) break; + decl = next; + } + if (decl.type === 'function_declarator') return decl; + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/c/arity.ts b/gitnexus/src/core/ingestion/languages/c/arity.ts new file mode 100644 index 000000000..7bbfa9e57 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/arity.ts @@ -0,0 +1,20 @@ +import type { Callsite, SymbolDefinition } from 'gitnexus-shared'; + +/** + * C arity compatibility: no overloading. Variadic functions detected + * via '...' in parameterTypes. Otherwise exact match or unknown. + */ +export function cArityCompatibility( + def: SymbolDefinition, + callsite: Callsite, +): 'compatible' | 'unknown' | 'incompatible' { + const max = def.parameterCount; + const min = def.requiredParameterCount; + if (max === undefined && min === undefined) return 'unknown'; + if (!Number.isFinite(callsite.arity) || callsite.arity < 0) return 'unknown'; + + const variadic = def.parameterTypes?.some((t) => t === '...') ?? false; + if (min !== undefined && callsite.arity < min) return 'incompatible'; + if (max !== undefined && callsite.arity > max && !variadic) return 'incompatible'; + return 'compatible'; +} diff --git a/gitnexus/src/core/ingestion/languages/c/captures.ts b/gitnexus/src/core/ingestion/languages/c/captures.ts new file mode 100644 index 000000000..836eb3eac --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/captures.ts @@ -0,0 +1,142 @@ +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { + findNodeAtRange, + nodeToCapture, + syntheticCapture, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; +import { getCParser, getCScopeQuery } from './query.js'; +import { getTreeSitterBufferSize } from '../../constants.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; +import { splitCInclude } from './import-decomposer.js'; +import { computeCDeclarationArity, computeCCallArity } from './arity-metadata.js'; +import { markStaticName } from './static-linkage.js'; + +export function emitCScopeCaptures( + sourceText: string, + filePath: string, + cachedTree?: unknown, +): readonly CaptureMatch[] { + let tree = cachedTree as ReturnType['parse']> | undefined; + if (tree === undefined) { + tree = parseSourceSafe(getCParser(), sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + } + + const rawMatches = getCScopeQuery().matches(tree.rootNode); + const out: CaptureMatch[] = []; + + // Track ranges where typedef-struct/union was captured as @declaration.struct/union + // so we can suppress the duplicate @declaration.typedef match at the same range. + const structTypedefRanges = new Set(); + + for (const m of rawMatches) { + const grouped: Record = {}; + for (const c of m.captures) { + const tag = '@' + c.name; + if (tag.startsWith('@_')) continue; + grouped[tag] = nodeToCapture(tag, c.node); + } + if (Object.keys(grouped).length === 0) continue; + + // Handle #include statements + if (grouped['@import.statement'] !== undefined) { + const anchor = grouped['@import.statement']!; + const includeNode = findNodeAtRange(tree.rootNode, anchor.range, 'preproc_include'); + if (includeNode !== null) { + const split = splitCInclude(includeNode); + if (split !== null) { + out.push(split); + continue; + } + } + } + + // Track typedef-struct ranges to suppress duplicate typedef declarations + const structAnchor = grouped['@declaration.struct'] ?? grouped['@declaration.union']; + if (structAnchor !== undefined) { + const r = structAnchor.range; + structTypedefRanges.add(`${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`); + } + + // Suppress @declaration.typedef if the same range was already captured as struct/union + const typedefAnchor = grouped['@declaration.typedef']; + if (typedefAnchor !== undefined) { + const r = typedefAnchor.range; + const key = `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`; + if (structTypedefRanges.has(key)) continue; + } + + // Enrich function declarations with arity metadata and detect static linkage + const declAnchor = grouped['@declaration.function']; + if (declAnchor !== undefined) { + const fnNode = + findNodeAtRange(tree.rootNode, declAnchor.range, 'function_definition') ?? + findNodeAtRange(tree.rootNode, declAnchor.range, 'declaration'); + if (fnNode !== null) { + const arity = computeCDeclarationArity(fnNode); + if (arity.parameterCount !== undefined) { + grouped['@declaration.parameter-count'] = syntheticCapture( + '@declaration.parameter-count', + fnNode, + String(arity.parameterCount), + ); + } + if (arity.requiredParameterCount !== undefined) { + grouped['@declaration.required-parameter-count'] = syntheticCapture( + '@declaration.required-parameter-count', + fnNode, + String(arity.requiredParameterCount), + ); + } + if (arity.parameterTypes !== undefined) { + grouped['@declaration.parameter-types'] = syntheticCapture( + '@declaration.parameter-types', + fnNode, + JSON.stringify(arity.parameterTypes), + ); + } + + // Detect static storage class (file-local linkage) + if (hasStaticStorageClass(fnNode)) { + const nameText = grouped['@declaration.name']?.text; + if (nameText !== undefined) { + markStaticName(filePath, nameText); + } + } + } + } + + // Enrich call references with arity + const callAnchor = grouped['@reference.call.free'] ?? grouped['@reference.call.member']; + if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) { + const callNode = findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression'); + if (callNode !== null) { + grouped['@reference.arity'] = syntheticCapture( + '@reference.arity', + callNode, + String(computeCCallArity(callNode)), + ); + } + } + + out.push(grouped); + } + + return out; +} + +/** + * Check if a C function_definition or declaration has `static` storage class. + * Walks direct children for a `storage_class_specifier` node with text `static`. + */ +function hasStaticStorageClass(node: SyntaxNode): boolean { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child !== null && child.type === 'storage_class_specifier' && child.text === 'static') { + return true; + } + } + return false; +} diff --git a/gitnexus/src/core/ingestion/languages/c/header-scan.ts b/gitnexus/src/core/ingestion/languages/c/header-scan.ts new file mode 100644 index 000000000..034e27f4d --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/header-scan.ts @@ -0,0 +1,58 @@ +import { readdirSync, type Dirent } from 'fs'; +import { join, relative } from 'path'; + +/** C header extensions to scan for in the workspace. */ +const HEADER_EXTENSIONS = new Set(['.h']); + +/** + * Walk `repoPath` recursively and return relative paths of all `.h` files. + * Used by `loadResolutionConfig` so the C resolver can resolve `#include` + * targets that live in `.h` files (classified as C++ by language detection + * but importable from `.c` files). + */ +export function scanHeaderFiles(repoPath: string): ReadonlySet { + const headers = new Set(); + walk(repoPath, repoPath, headers); + return headers; +} + +function walk(dir: string, root: string, out: Set): void { + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' }); + } catch { + return; // permission denied, etc. + } + for (const entry of entries) { + const name = entry.name; + const full = join(dir, name); + if (entry.isDirectory()) { + // Skip common non-source directories and build output dirs. + // Build dirs (dist, build, out, target, _build, .next, cmake-build-*) + // may contain generated headers that shadow source headers. + if ( + name === 'node_modules' || + name === '.git' || + name === 'vendor' || + name === 'dist' || + name === 'build' || + name === 'out' || + name === 'target' || + name === '_build' || + name === '.next' || + name.startsWith('cmake-build') + ) { + continue; + } + walk(full, root, out); + } else if (entry.isFile()) { + const ext = name.slice(name.lastIndexOf('.')); + if (HEADER_EXTENSIONS.has(ext)) { + // Normalize to forward slashes for cross-platform consistency. + // path.relative() returns backslash-separated paths on Windows, + // but the scope-resolution pipeline uses forward slashes uniformly. + out.add(relative(root, full).replace(/\\/g, '/')); + } + } + } +} diff --git a/gitnexus/src/core/ingestion/languages/c/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/c/import-decomposer.ts new file mode 100644 index 000000000..5cef27430 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/import-decomposer.ts @@ -0,0 +1,55 @@ +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Decompose a `preproc_include` node into a CaptureMatch with structured + * import captures. C #include maps to a wildcard import (all symbols + * from the header are visible). + */ +export function splitCInclude(node: SyntaxNode): CaptureMatch | null { + // node.type === 'preproc_include' + // path field: (string_literal (string_content)) | (system_lib_string) + const pathNode = node.childForFieldName?.('path') ?? null; + if (pathNode === null) { + // Fallback: scan children + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child === null) continue; + if (child.type === 'string_literal' || child.type === 'system_lib_string') { + return buildIncludeCapture(node, child); + } + } + return null; + } + return buildIncludeCapture(node, pathNode); +} + +function buildIncludeCapture(node: SyntaxNode, pathNode: SyntaxNode): CaptureMatch { + let raw: string; + if (pathNode.type === 'string_literal') { + // string_literal has children: `"`, string_content, `"` + // Use namedChildren to find the string_content node + const content = pathNode.namedChildren.find((c) => c.type === 'string_content'); + raw = content?.text ?? pathNode.text.replace(/^"|"$/g, ''); + } else { + // system_lib_string: → strip angle brackets + raw = pathNode.text; + if (raw.startsWith('<') && raw.endsWith('>')) { + raw = raw.slice(1, -1); + } + } + + const isSystem = pathNode.type === 'system_lib_string'; + + const result: Record = { + '@import.statement': nodeToCapture('@import.statement', node), + '@import.kind': syntheticCapture('@import.kind', node, 'wildcard'), + '@import.source': syntheticCapture('@import.source', node, raw), + }; + + if (isSystem) { + result['@import.system'] = syntheticCapture('@import.system', node, 'true'); + } + + return result; +} diff --git a/gitnexus/src/core/ingestion/languages/c/import-target.ts b/gitnexus/src/core/ingestion/languages/c/import-target.ts new file mode 100644 index 000000000..0cb9c2fb4 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/import-target.ts @@ -0,0 +1,64 @@ +import { dirname, join } from 'path'; + +/** + * Resolve a C #include path to a file in the workspace. + * + * Strategy: + * 1. Check for a same-directory sibling relative to the including file + * (matches C compiler `#include "…"` relative-lookup semantics). + * 2. Check for an exact match (path as-is in the workspace). + * 3. Fall back to suffix matching against all workspace file paths. + * Tie-breaking: prefer the match with the fewest path components + * (closest to root). On equal depth, break ties lexicographically + * by normalized path to ensure deterministic resolution regardless + * of filesystem iteration order. + */ +export function resolveCImportTarget( + targetRaw: string, + fromFile: string, + allFilePaths: ReadonlySet, +): string | null { + if (!targetRaw) return null; + + const normalizedTarget = targetRaw.replace(/\\/g, '/'); + + // Same-directory sibling first: mirrors the C compiler's #include "…" + // relative-lookup semantics where the directory of the including + // file is searched before the include-path list. + if (fromFile) { + const siblingRaw = join(dirname(fromFile), targetRaw); + const sibling = siblingRaw.replace(/\\/g, '/'); + if (allFilePaths.has(sibling)) return sibling; + // When targetRaw contains backslashes, the normalized form may + // resolve to a different sibling path — try it as well. + if (targetRaw !== normalizedTarget) { + const siblingAlt = join(dirname(fromFile), normalizedTarget); + const siblingAltNorm = siblingAlt.replace(/\\/g, '/'); + if (allFilePaths.has(siblingAltNorm)) return siblingAltNorm; + } + } + + // Exact match (path as-is in the workspace) + if (allFilePaths.has(normalizedTarget)) return normalizedTarget; + + // Suffix match: find files ending with /targetRaw or equal to targetRaw + const suffix = '/' + normalizedTarget; + let bestMatch: string | null = null; + let bestDepth = Infinity; + let bestNormalized = ''; + + for (const filePath of allFilePaths) { + const normalized = filePath.replace(/\\/g, '/'); + if (normalized === normalizedTarget || normalized.endsWith(suffix)) { + // Prefer shortest path (closest match) + const depth = normalized.split('/').length; + if (depth < bestDepth || (depth === bestDepth && normalized < bestNormalized)) { + bestDepth = depth; + bestMatch = filePath; + bestNormalized = normalized; + } + } + } + + return bestMatch; +} diff --git a/gitnexus/src/core/ingestion/languages/c/index.ts b/gitnexus/src/core/ingestion/languages/c/index.ts new file mode 100644 index 000000000..c6900ecba --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/index.ts @@ -0,0 +1,16 @@ +/** + * C scope-resolution hooks (RFC #909 Ring 3). + */ +export { emitCScopeCaptures } from './captures.js'; +export { interpretCImport, interpretCTypeBinding, normalizeCTypeName } from './interpret.js'; +export { splitCInclude } from './import-decomposer.js'; +export { cArityCompatibility } from './arity.js'; +export { cMergeBindings } from './merge-bindings.js'; +export { cBindingScopeFor, cImportOwningScope, cReceiverBinding } from './simple-hooks.js'; +export { resolveCImportTarget } from './import-target.js'; +export { + markStaticName, + isStaticName, + clearStaticNames, + expandCWildcardNames, +} from './static-linkage.js'; diff --git a/gitnexus/src/core/ingestion/languages/c/interpret.ts b/gitnexus/src/core/ingestion/languages/c/interpret.ts new file mode 100644 index 000000000..e5a15f2fe --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/interpret.ts @@ -0,0 +1,51 @@ +import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared'; + +/** + * Interpret a C #include capture into a ParsedImport. + * C includes are always wildcard imports (all symbols from the header). + */ +export function interpretCImport(captures: CaptureMatch): ParsedImport | null { + const source = captures['@import.source']?.text; + if (source === undefined) return null; + + // System headers (e.g. ) are not resolved to local files + if (captures['@import.system'] !== undefined) return null; + + return { kind: 'wildcard', targetRaw: source }; +} + +/** + * Interpret a C type-binding capture into a ParsedTypeBinding. + */ +export function interpretCTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null { + const name = captures['@type-binding.name']?.text; + const type = captures['@type-binding.type']?.text; + if (name === undefined || type === undefined) return null; + + let source: TypeRef['source'] = 'annotation'; + + if (captures['@type-binding.parameter'] !== undefined) { + source = 'parameter-annotation'; + } else if (captures['@type-binding.assignment'] !== undefined) { + source = 'assignment-inferred'; + } + + return { boundName: name, rawTypeName: normalizeCTypeName(type), source }; +} + +/** + * Normalize a C type name: strip pointer/array syntax, qualifiers. + */ +export function normalizeCTypeName(text: string): string { + let t = text.trim(); + // Strip const, volatile, restrict qualifiers + t = t.replace(/\b(const|volatile|restrict|static|extern|inline)\b/g, '').trim(); + // Strip pointer stars + while (t.endsWith('*')) t = t.slice(0, -1).trim(); + while (t.startsWith('*')) t = t.slice(1).trim(); + // Strip array brackets + t = t.replace(/\[.*?\]/g, '').trim(); + // Strip struct/union/enum prefixes + t = t.replace(/^(struct|union|enum)\s+/, ''); + return t; +} diff --git a/gitnexus/src/core/ingestion/languages/c/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/c/merge-bindings.ts new file mode 100644 index 000000000..b8bd20c6f --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/merge-bindings.ts @@ -0,0 +1,32 @@ +import type { BindingRef } from 'gitnexus-shared'; + +const TIER: Record = { + local: 0, + namespace: 1, + import: 2, + reexport: 3, + wildcard: 4, +}; + +/** + * C merge bindings: simple first-wins by tier (local > import > wildcard). + * C has no namespaces or reexports, but the tiers are defined for + * compatibility with the shared infrastructure. + */ +export function cMergeBindings( + existing: readonly BindingRef[], + incoming: readonly BindingRef[], + _scopeId: string, +): BindingRef[] { + const seen = new Set(); + return [...existing, ...incoming] + .sort( + (a, b) => + (TIER[a.origin] ?? 99) - (TIER[b.origin] ?? 99) || a.def.nodeId.localeCompare(b.def.nodeId), + ) + .filter((binding) => { + if (seen.has(binding.def.nodeId)) return false; + seen.add(binding.def.nodeId); + return true; + }); +} diff --git a/gitnexus/src/core/ingestion/languages/c/query.ts b/gitnexus/src/core/ingestion/languages/c/query.ts new file mode 100644 index 000000000..da49a1a6a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/query.ts @@ -0,0 +1,165 @@ +import Parser from 'tree-sitter'; +import C from 'tree-sitter-c'; + +const C_SCOPE_QUERY = ` +;; Scopes +(translation_unit) @scope.module +(struct_specifier) @scope.class +(union_specifier) @scope.class +(function_definition) @scope.function +(compound_statement) @scope.block +(if_statement) @scope.block +(for_statement) @scope.block +(while_statement) @scope.block +(do_statement) @scope.block +(switch_statement) @scope.block +(case_statement) @scope.block + +;; Declarations — struct (named) +(struct_specifier + name: (type_identifier) @declaration.name + body: (field_declaration_list)) @declaration.struct + +;; Declarations — struct (typedef struct { ... } Name) +(type_definition + type: (struct_specifier + body: (field_declaration_list)) + declarator: (type_identifier) @declaration.name) @declaration.struct + +;; Declarations — union (named) +(union_specifier + name: (type_identifier) @declaration.name + body: (field_declaration_list)) @declaration.union + +;; Declarations — union (typedef union { ... } Name) +(type_definition + type: (union_specifier + body: (field_declaration_list)) + declarator: (type_identifier) @declaration.name) @declaration.union + +;; Declarations — enum +(enum_specifier + name: (type_identifier) @declaration.name) @declaration.enum + +;; Declarations — function definition +(function_definition + declarator: (function_declarator + declarator: (identifier) @declaration.name)) @declaration.function + +;; Declarations — function definition with pointer return +(function_definition + declarator: (pointer_declarator + declarator: (function_declarator + declarator: (identifier) @declaration.name))) @declaration.function + +;; Declarations — function declaration (prototype) +;; Note: Both prototypes and definitions are captured as @declaration.function. +;; This may produce duplicate Function nodes in the knowledge graph when a +;; function is declared in a header and defined in a .c file. CALLS edges +;; resolve correctly through scope-based wildcard import chains; the +;; duplication is a graph-quality concern only (no false edges). +(declaration + declarator: (function_declarator + declarator: (identifier) @declaration.name)) @declaration.function + +;; Declarations — function declaration with pointer return (prototype) +(declaration + declarator: (pointer_declarator + declarator: (function_declarator + declarator: (identifier) @declaration.name))) @declaration.function + +;; Declarations — typedef +(type_definition + declarator: (type_identifier) @declaration.name) @declaration.typedef + +;; Declarations — typedef for function pointers: typedef void (*callback)(int, int) +(type_definition + declarator: (function_declarator + declarator: (parenthesized_declarator + (pointer_declarator + declarator: (type_identifier) @declaration.name)))) @declaration.typedef + +;; Declarations — struct fields +(field_declaration + declarator: (field_identifier) @declaration.name) @declaration.field + +;; Declarations — struct fields (pointer) +(field_declaration + declarator: (pointer_declarator + declarator: (field_identifier) @declaration.name)) @declaration.field + +;; Declarations — variables (with initializer) +(declaration + declarator: (init_declarator + declarator: (identifier) @declaration.name)) @declaration.variable + +;; Declarations — macro definitions +(preproc_def + name: (identifier) @declaration.name) @declaration.macro + +(preproc_function_def + name: (identifier) @declaration.name) @declaration.macro + +;; Declarations — enum constants +(enumerator + name: (identifier) @declaration.name) @declaration.const + +;; Imports +(preproc_include) @import.statement + +;; Type bindings — parameter annotations +(parameter_declaration + type: (_) @type-binding.type + declarator: (identifier) @type-binding.name) @type-binding.parameter + +;; Type bindings — variable with type (init_declarator) +(declaration + type: (_) @type-binding.type + declarator: (init_declarator + declarator: (identifier) @type-binding.name)) @type-binding.assignment + +;; References — free calls +;; Note: This also captures calls through function pointer variables (e.g. fp(x)) +;; since tree-sitter-c produces structurally identical AST nodes for both direct +;; function calls and function-pointer-variable calls. A type-based guard to +;; distinguish variable-calls from function-calls is not implemented — this is a +;; known architectural trade-off shared with the Go resolver. The uniqueness +;; constraint in pickUniqueGlobalCallable limits false edge exposure. +(call_expression + function: (identifier) @reference.name) @reference.call.free + +;; References — member calls via pointer (ptr->func()) +(call_expression + function: (field_expression + argument: (_) @reference.receiver + field: (field_identifier) @reference.name)) @reference.call.member + +;; References — field reads +(field_expression + argument: (_) @reference.receiver + field: (field_identifier) @reference.name) @reference.read + +;; References — field writes (assignment) +(assignment_expression + left: (field_expression + argument: (_) @reference.receiver + field: (field_identifier) @reference.name)) @reference.write +`; + +let _parser: Parser | null = null; +let _query: Parser.Query | null = null; + +export function getCParser(): Parser { + if (_parser === null) { + _parser = new Parser(); + _parser.setLanguage(C as Parameters[0]); + } + return _parser; +} + +export function getCScopeQuery(): Parser.Query { + if (_query === null) { + _query = new Parser.Query(C as Parameters[0], C_SCOPE_QUERY); + } + return _query; +} diff --git a/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts new file mode 100644 index 000000000..90d54e072 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts @@ -0,0 +1,73 @@ +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; +import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; +import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { cProvider } from '../c-cpp.js'; +import { cArityCompatibility, cMergeBindings, resolveCImportTarget } from './index.js'; +import { scanHeaderFiles } from './header-scan.js'; +import { expandCWildcardNames, isStaticName, clearStaticNames } from './static-linkage.js'; + +/** + * C `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by + * the generic `runScopeResolution` orchestrator (RFC #909 Ring 3). + * + * C is a structurally simple language for scope resolution: + * - No classes (structs are value types, no method dispatch) + * - No inheritance (no MRO needed beyond the shared first-wins default) + * - No overloading (arity check is simple: variadic detection only) + * - `#include` is wildcard import (all symbols from header are visible) + * - `static` functions are file-local (not exported) + */ +export const cScopeResolver: ScopeResolver = { + language: SupportedLanguages.C, + languageProvider: cProvider, + importEdgeReason: 'c-scope: include', + + loadResolutionConfig: (repoPath: string) => { + // Clear stale static-linkage data from any previous invocation to + // prevent cross-repo contamination in server-mode scenarios. + clearStaticNames(); + return scanHeaderFiles(repoPath); + }, + + resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => { + // Augment allFilePaths with .h files discovered via loadResolutionConfig + // since the phase only passes .c files to the C resolver but #include + // targets .h files classified as C++ in language detection. + const headerPaths = resolutionConfig as ReadonlySet | undefined; + if (headerPaths !== undefined && headerPaths.size > 0) { + const augmented = new Set(allFilePaths); + for (const h of headerPaths) augmented.add(h); + return resolveCImportTarget(targetRaw, fromFile, augmented); + } + return resolveCImportTarget(targetRaw, fromFile, allFilePaths); + }, + + expandsWildcardTo: (targetModuleScope, parsedFiles) => + expandCWildcardNames(targetModuleScope, parsedFiles), + + mergeBindings: (existing, incoming, scopeId) => cMergeBindings(existing, incoming, scopeId), + + arityCompatibility: (callsite, def) => cArityCompatibility(def, callsite), + + buildMro: (graph, parsedFiles, nodeLookup) => + buildMro(graph, parsedFiles, nodeLookup, defaultLinearize), + + populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed), + + isSuperReceiver: () => false, + + // C is statically typed — disable field fallback heuristic + fieldFallbackOnMethodLookup: false, + // C has no method return types to propagate + propagatesReturnTypesAcrossImports: false, + // C #include brings in all symbols — enable global free call fallback + allowGlobalFreeCallFallback: true, + // C `static` functions have file-local (translation-unit) linkage — + // exclude them from global free-call fallback cross-file resolution. + isFileLocalDef: (def: SymbolDefinition) => { + const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; + return isStaticName(def.filePath, simple); + }, +}; diff --git a/gitnexus/src/core/ingestion/languages/c/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/c/simple-hooks.ts new file mode 100644 index 000000000..537ea07c5 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/simple-hooks.ts @@ -0,0 +1,38 @@ +import type { + CaptureMatch, + ParsedImport, + Scope, + ScopeId, + ScopeTree, + TypeRef, +} from 'gitnexus-shared'; + +/** + * C binding scope: always use default auto-hoist (null). + * C has no self/receiver bindings that need special scoping. + */ +export function cBindingScopeFor( + _decl: CaptureMatch, + _innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + return null; +} + +/** + * C import owning scope: always use default (null). + */ +export function cImportOwningScope( + _imp: ParsedImport, + _innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + return null; +} + +/** + * C receiver binding: always null. C has no methods or receivers. + */ +export function cReceiverBinding(_functionScope: Scope): TypeRef | null { + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/c/static-linkage.ts b/gitnexus/src/core/ingestion/languages/c/static-linkage.ts new file mode 100644 index 000000000..5cfd166b1 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/static-linkage.ts @@ -0,0 +1,64 @@ +import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; + +/** + * Per-file set of function names declared with `static` storage class. + * Populated during `emitCScopeCaptures` and consumed by `expandCWildcardNames` + * to exclude file-local symbols from cross-file wildcard import visibility. + * + * NOTE: module-level state, single-process-single-repo use only. + * For server-mode or multi-repo-in-one-process use cases, call + * `clearStaticNames()` at the start of each resolution pass to avoid + * stale static-linkage data from a previous invocation. + * + * Key: filePath, Value: Set of static function names. + */ +const staticNames = new Map>(); + +/** Record a symbol name as `static` (file-local linkage) for the given file. */ +export function markStaticName(filePath: string, name: string): void { + let names = staticNames.get(filePath); + if (names === undefined) { + names = new Set(); + staticNames.set(filePath, names); + } + names.add(name); +} + +/** Check whether a symbol name has `static` linkage in the given file. */ +export function isStaticName(filePath: string, name: string): boolean { + return staticNames.get(filePath)?.has(name) ?? false; +} + +/** Clear tracked static names (for testing). */ +export function clearStaticNames(): void { + staticNames.clear(); +} + +/** + * Return the names visible through a C wildcard import (`#include`). + * All module-scope defs from the target file are visible EXCEPT those + * declared with `static` storage class (file-local linkage in C). + */ +export function expandCWildcardNames( + targetModuleScope: ScopeId, + parsedFiles: readonly ParsedFile[], +): readonly string[] { + const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope); + if (target === undefined) return []; + + const seen = new Set(); + const names: string[] = []; + for (const def of target.localDefs) { + const name = simpleName(def); + if (name === '') continue; + if (isStaticName(target.filePath, name)) continue; + if (seen.has(name)) continue; + seen.add(name); + names.push(name); + } + return names; +} + +function simpleName(def: SymbolDefinition): string { + return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/captures.ts b/gitnexus/src/core/ingestion/languages/csharp/captures.ts index e29552e78..a090b82f8 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/captures.ts @@ -24,6 +24,7 @@ import { synthesizeCsharpReceiverBinding } from './receiver-binding.js'; import { getCsharpParser, getCsharpScopeQuery } from './query.js'; import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; import { getTreeSitterBufferSize } from '../../constants.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; /** Declaration anchors that carry function-like arity metadata. */ const FUNCTION_DECL_TAGS = [ @@ -86,7 +87,7 @@ export function emitCsharpScopeCaptures( // the LanguageProvider contract layer; cast here at the use site. let tree = cachedTree as ReturnType['parse']> | undefined; if (tree === undefined) { - tree = getCsharpParser().parse(sourceText, undefined, { + tree = parseSourceSafe(getCsharpParser(), sourceText, undefined, { bufferSize: getTreeSitterBufferSize(sourceText), }); recordCacheMiss(); diff --git a/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts b/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts index 2cd2cf724..b6de589e2 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts @@ -36,6 +36,7 @@ import type { BindingRef, ParsedFile, Scope, ScopeId, SymbolDefinition } from 'g import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { getCsharpParser } from './query.js'; import { getTreeSitterBufferSize } from '../../constants.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; interface CsharpFileStructure { /** Declared namespace names in file source order. Empty array means @@ -56,7 +57,7 @@ function extractFileStructure(content: string, cachedTree: unknown): CsharpFileS type CsharpTree = ReturnType['parse']>; const tree = (cachedTree as CsharpTree | undefined) ?? - getCsharpParser().parse(content, undefined, { + parseSourceSafe(getCsharpParser(), content, undefined, { bufferSize: getTreeSitterBufferSize(content), }); const namespaces: string[] = []; @@ -359,7 +360,7 @@ export function populateCsharpNamespaceSiblings( const q = def.qualifiedName ?? ''; const key = q.includes('.') ? q.slice(q.lastIndexOf('.') + 1) : q; if (key === '') continue; - const arr = defsByName.get(key) ?? []; + const arr = [...(defsByName.get(key) ?? [])]; arr.push(def); defsByName.set(key, arr); } diff --git a/gitnexus/src/core/ingestion/languages/go/captures.ts b/gitnexus/src/core/ingestion/languages/go/captures.ts index 93fcbc51a..73fa61862 100644 --- a/gitnexus/src/core/ingestion/languages/go/captures.ts +++ b/gitnexus/src/core/ingestion/languages/go/captures.ts @@ -12,6 +12,7 @@ import { splitGoImportStatement } from './import-decomposer.js'; import { synthesizeGoReceiverBinding } from './receiver-binding.js'; import { synthesizeGoTypeBindings } from './type-binding.js'; import { getTreeSitterBufferSize } from '../../constants.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; export function emitGoScopeCaptures( sourceText: string, @@ -20,7 +21,7 @@ export function emitGoScopeCaptures( ): readonly CaptureMatch[] { let tree = cachedTree as ReturnType['parse']> | undefined; if (tree === undefined) { - tree = getGoParser().parse(sourceText, undefined, { + tree = parseSourceSafe(getGoParser(), sourceText, undefined, { bufferSize: getTreeSitterBufferSize(sourceText), }); recordGoCacheMiss(); diff --git a/gitnexus/src/core/ingestion/languages/go/range-binding.ts b/gitnexus/src/core/ingestion/languages/go/range-binding.ts index 14e520c2d..1f06f3373 100644 --- a/gitnexus/src/core/ingestion/languages/go/range-binding.ts +++ b/gitnexus/src/core/ingestion/languages/go/range-binding.ts @@ -2,6 +2,7 @@ import type { ParsedFile, Scope, TypeRef } from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { getGoParser } from './query.js'; import { getTreeSitterBufferSize } from '../../constants.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; export function populateGoRangeBindings( parsedFiles: readonly ParsedFile[], @@ -20,7 +21,7 @@ export function populateGoRangeBindings( const cachedTree = ctx.treeCache?.get(parsed.filePath); const tree = (cachedTree as ReturnType | undefined) ?? - parser.parse(sourceText, undefined, { + parseSourceSafe(parser, sourceText, undefined, { bufferSize: getTreeSitterBufferSize(sourceText), }); const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index 96139ccda..c70eacb10 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -27,6 +27,17 @@ import { javaMethodConfig } from '../method-extractors/configs/jvm.js'; import { createVariableExtractor } from '../variable-extractors/generic.js'; import { javaVariableConfig } from '../variable-extractors/configs/jvm.js'; import { createHeritageExtractor } from '../heritage-extractors/generic.js'; +import { + emitJavaScopeCaptures, + interpretJavaImport, + interpretJavaTypeBinding, + javaBindingScopeFor, + javaImportOwningScope, + javaMergeBindings, + javaReceiverBinding, + javaArityCompatibility, + resolveJavaImportTarget, +} from './java/index.js'; export const javaProvider = defineLanguage({ id: SupportedLanguages.Java, @@ -65,4 +76,15 @@ export const javaProvider = defineLanguage({ variableExtractor: createVariableExtractor(javaVariableConfig), classExtractor: createClassExtractor(javaClassConfig), heritageExtractor: createHeritageExtractor(SupportedLanguages.Java), + + // ── RFC #909 Ring 3: scope-based resolution hooks ── + emitScopeCaptures: emitJavaScopeCaptures, + interpretImport: interpretJavaImport, + interpretTypeBinding: interpretJavaTypeBinding, + bindingScopeFor: javaBindingScopeFor, + importOwningScope: javaImportOwningScope, + mergeBindings: (_scope, bindings) => javaMergeBindings(bindings), + receiverBinding: javaReceiverBinding, + arityCompatibility: javaArityCompatibility, + resolveImportTarget: resolveJavaImportTarget, }); diff --git a/gitnexus/src/core/ingestion/languages/java/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/java/arity-metadata.ts new file mode 100644 index 000000000..47cccbff9 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/arity-metadata.ts @@ -0,0 +1,49 @@ +/** + * Extract Java arity metadata from a method-like tree-sitter node — + * `method_declaration` or `constructor_declaration`. + * + * Reuses `javaMethodConfig.extractParameters` so scope-extracted defs + * carry the same arity semantics as the legacy parse-worker path: + * - varargs (`...`) collapses `parameterCount` to `undefined` + * - `parameterTypes` collects declared type names; a literal + * `'varargs'` marker is appended for variadic methods so + * `javaArityCompatibility` can detect them. + */ + +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { javaMethodConfig } from '../../method-extractors/configs/jvm.js'; + +export interface JavaArityMetadata { + readonly parameterCount: number | undefined; + readonly requiredParameterCount: number | undefined; + readonly parameterTypes: readonly string[] | undefined; +} + +export function computeJavaArityMetadata(fnNode: SyntaxNode): JavaArityMetadata { + const params = javaMethodConfig.extractParameters?.(fnNode) ?? []; + + let hasVariadic = false; + const types: string[] = []; + for (const p of params) { + if (p.isVariadic) hasVariadic = true; + if (p.type !== null) types.push(p.type); + } + if (hasVariadic) types.push('varargs'); + + const total = params.length; + // For varargs methods, `parameterCount` (max) is unknown — any number of + // trailing arguments is valid. But the fixed-prefix parameters (everything + // before the variadic `...` param) are still required, so we preserve that + // count in `requiredParameterCount` so `javaArityCompatibility` can reject + // calls that undersupply the fixed prefix (e.g. `f(int x, String... args)` + // called with 0 args). + const fixedCount = params.filter((p) => !p.isVariadic).length; + const parameterCount = hasVariadic ? undefined : total; + const requiredParameterCount = hasVariadic ? fixedCount : total; + + return { + parameterCount, + requiredParameterCount, + parameterTypes: types.length > 0 ? types : undefined, + }; +} diff --git a/gitnexus/src/core/ingestion/languages/java/arity.ts b/gitnexus/src/core/ingestion/languages/java/arity.ts new file mode 100644 index 000000000..f98a8209d --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/arity.ts @@ -0,0 +1,31 @@ +/** + * Java arity check, accommodating varargs (`...`). + * + * Verdicts: + * - `'compatible'` — argCount matches parameterCount, OR varargs present. + * - `'incompatible'` — argCount mismatches with no varargs. + * - `'unknown'` — metadata absent / incomplete. + */ + +import type { Callsite, SymbolDefinition } from 'gitnexus-shared'; + +export function javaArityCompatibility( + def: SymbolDefinition, + callsite: Callsite, +): 'compatible' | 'unknown' | 'incompatible' { + const max = def.parameterCount; + const min = def.requiredParameterCount; + if (max === undefined && min === undefined) return 'unknown'; + + const argCount = callsite.arity; + if (!Number.isFinite(argCount) || argCount < 0) return 'unknown'; + + const hasVarArgs = + def.parameterTypes !== undefined && + def.parameterTypes.some((t) => t === 'varargs' || t.includes('...')); + + if (min !== undefined && argCount < min) return 'incompatible'; + if (max !== undefined && argCount > max && !hasVarArgs) return 'incompatible'; + + return 'compatible'; +} diff --git a/gitnexus/src/core/ingestion/languages/java/cache-stats.ts b/gitnexus/src/core/ingestion/languages/java/cache-stats.ts new file mode 100644 index 000000000..a4c58f11f --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/cache-stats.ts @@ -0,0 +1,30 @@ +/** + * Dev-mode counters for the cross-phase scope-captures parse cache + * (Java mirror of `languages/csharp/cache-stats.ts`). + * + * Gated by `PROF_SCOPE_RESOLUTION=1`. Production builds fold every + * increment into dead code via the module-level `PROF` constant, so + * the hot path in `captures.ts` stays branch-free. + */ + +const PROF = process.env.PROF_SCOPE_RESOLUTION === '1'; + +let CACHE_HITS = 0; +let CACHE_MISSES = 0; + +export function recordCacheHit(): void { + if (PROF) CACHE_HITS++; +} + +export function recordCacheMiss(): void { + if (PROF) CACHE_MISSES++; +} + +export function getJavaCaptureCacheStats(): { hits: number; misses: number } { + return { hits: CACHE_HITS, misses: CACHE_MISSES }; +} + +export function resetJavaCaptureCacheStats(): void { + CACHE_HITS = 0; + CACHE_MISSES = 0; +} diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts new file mode 100644 index 000000000..73ea605fe --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -0,0 +1,235 @@ +/** + * `emitScopeCaptures` for Java. + * + * Drives the Java scope query against tree-sitter-java and groups raw + * matches into `CaptureMatch[]` for the central extractor. Layers: + * + * 1. **Decomposed import declarations** — each `import_declaration` + * is re-emitted with `@import.kind/source/name` markers. + * 2. **Receiver binding synthesis** — `this`/`super` type-bindings + * on instance methods. + * 3. **Arity metadata** on method/constructor declarations. + * 4. **Reference arity** on call sites. + * + * Pure given the input source text. No I/O, no globals consulted. + */ + +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js'; +import { splitImportDeclaration } from './import-decomposer.js'; +import { computeJavaArityMetadata } from './arity-metadata.js'; +import { synthesizeJavaReceiverBinding } from './receiver-binding.js'; +import { getJavaParser, getJavaScopeQuery } from './query.js'; +import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; +import { getTreeSitterBufferSize } from '../../constants.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; + +/** Declaration anchors that carry function-like arity metadata. */ +const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const; + +/** tree-sitter-java node types that the method extractor accepts. */ +const FUNCTION_NODE_TYPES = ['method_declaration', 'constructor_declaration'] as const; + +/** Suppress read.member emissions when the field_access is already + * covered by a method_invocation (object of a call) or an + * assignment_expression (write target). */ +function shouldEmitReadMember(memberNode: SyntaxNode): boolean { + const parent = memberNode.parent; + 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: + return true; + } +} + +export function emitJavaScopeCaptures( + sourceText: string, + _filePath: string, + cachedTree?: unknown, +): readonly CaptureMatch[] { + let tree = cachedTree as ReturnType['parse']> | undefined; + if (tree === undefined) { + tree = parseSourceSafe(getJavaParser(), sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + recordCacheMiss(); + } else { + recordCacheHit(); + } + + const rawMatches = getJavaScopeQuery().matches(tree.rootNode); + const out: CaptureMatch[] = []; + + for (const m of rawMatches) { + const grouped: Record = {}; + for (const c of m.captures) { + const tag = '@' + c.name; + grouped[tag] = nodeToCapture(tag, c.node); + } + if (Object.keys(grouped).length === 0) continue; + + // Decompose each `import_declaration`. + if (grouped['@import.statement'] !== undefined) { + const stmtCapture = grouped['@import.statement']; + const stmtNode = findNodeAtRange(tree.rootNode, stmtCapture.range, 'import_declaration'); + if (stmtNode !== null) { + const decomposed = splitImportDeclaration(stmtNode); + if (decomposed !== null) { + out.push(decomposed); + continue; + } + } + out.push(grouped); + continue; + } + + // Skip free-call matches that are actually member calls. The query + // matches ALL method_invocations as @reference.call.free (without + // negation) because tree-sitter-java's query engine drops !object + // patterns when a positive object: pattern exists for the same node + // type. Filter here: if the match has @reference.call.free but also + // has @reference.receiver, it's a member call — skip the free match + // (the separate @reference.call.member match covers it). + if ( + grouped['@reference.call.free'] !== undefined && + grouped['@reference.receiver'] !== undefined + ) { + continue; + } + + // Filter read.member when it's a child of method_invocation or assignment. + if (grouped['@reference.read.member'] !== undefined) { + const anchor = grouped['@reference.read.member']; + const memberNode = findNodeAtRange(tree.rootNode, anchor.range, 'field_access'); + if (memberNode === null || !shouldEmitReadMember(memberNode)) { + continue; + } + } + + // Synthesize `this` / `super` receiver type-bindings on every + // instance method-like. + if (grouped['@scope.function'] !== undefined) { + out.push(grouped); + const anchor = grouped['@scope.function']!; + const fnNode = findFunctionNode(tree.rootNode, anchor.range); + if (fnNode !== null) { + for (const synth of synthesizeJavaReceiverBinding(fnNode)) { + out.push(synth); + } + } + continue; + } + + // Synthesize arity metadata on function-like declarations. + const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined); + if (declTag !== undefined) { + const anchor = grouped[declTag]!; + const fnNode = findFunctionNode(tree.rootNode, anchor.range); + if (fnNode !== null) { + const arity = computeJavaArityMetadata(fnNode); + if (arity.parameterCount !== undefined) { + grouped['@declaration.parameter-count'] = syntheticCapture( + '@declaration.parameter-count', + fnNode, + String(arity.parameterCount), + ); + } + if (arity.requiredParameterCount !== undefined) { + grouped['@declaration.required-parameter-count'] = syntheticCapture( + '@declaration.required-parameter-count', + fnNode, + String(arity.requiredParameterCount), + ); + } + if (arity.parameterTypes !== undefined) { + grouped['@declaration.parameter-types'] = syntheticCapture( + '@declaration.parameter-types', + fnNode, + JSON.stringify(arity.parameterTypes), + ); + } + } + } + + // Synthesize `@reference.arity` on every callsite. + const callTag = ( + ['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const + ).find((t) => grouped[t] !== undefined); + if (callTag !== undefined && grouped['@reference.arity'] === undefined) { + const anchor = grouped[callTag]!; + const callNode = + findNodeAtRange(tree.rootNode, anchor.range, 'method_invocation') ?? + findNodeAtRange(tree.rootNode, anchor.range, 'object_creation_expression'); + if (callNode !== null) { + const argList = callNode.childForFieldName('arguments'); + const args = + argList === null + ? [] + : argList.namedChildren.filter((c) => c !== null && c.type !== 'comment'); + grouped['@reference.arity'] = syntheticCapture( + '@reference.arity', + callNode, + String(args.length), + ); + + const argTypes = args.map((arg) => inferArgType(arg!)); + grouped['@reference.parameter-types'] = syntheticCapture( + '@reference.parameter-types', + callNode, + JSON.stringify(argTypes), + ); + } + } + + out.push(grouped); + } + + return out; +} + +type SyntaxNode = ReturnType['parse']>['rootNode']; + +/** Infer a Java argument's static type from literal patterns. */ +function inferArgType(argNode: SyntaxNode): string { + switch (argNode.type) { + case 'decimal_integer_literal': + case 'hex_integer_literal': + case 'octal_integer_literal': + case 'binary_integer_literal': + return 'int'; + case 'decimal_floating_point_literal': + case 'hex_floating_point_literal': + return 'double'; + case 'string_literal': + return 'String'; + case 'character_literal': + return 'char'; + case 'true': + case 'false': + return 'boolean'; + case 'null_literal': + return 'null'; + case 'object_creation_expression': { + const typeNode = argNode.childForFieldName('type'); + return typeNode?.text ?? ''; + } + default: + return ''; + } +} + +/** Find the first Java function-like node at the given range. */ +function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null { + for (const nodeType of FUNCTION_NODE_TYPES) { + const n = findNodeAtRange(rootNode, range, nodeType); + if (n !== null) return n as SyntaxNode; + } + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/java/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/java/import-decomposer.ts new file mode 100644 index 000000000..59c74d144 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/import-decomposer.ts @@ -0,0 +1,104 @@ +/** + * Decompose a Java `import_declaration` into a `CaptureMatch` carrying + * the synthesized markers `@import.kind` / `@import.source` / + * `@import.name` that `interpretJavaImport` consumes. + * + * Unlike C#'s using-directive decomposer, Java has four import forms: + * + * import com.example.User; → named + * import com.example.*; → wildcard + * import static com.example.Utils.format; → static + * import static com.example.Utils.*; → static-wildcard + * + * Each produces exactly one import. The decomposer inspects the raw + * source text and tree-sitter children to determine the flavor. + */ + +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; + +type ImportKind = 'named' | 'wildcard' | 'static' | 'static-wildcard'; + +interface ImportSpec { + readonly kind: ImportKind; + /** Full dotted path: `com.example.User`. */ + readonly source: string; + /** Local binding name — last path segment for named/static, + * `'*'` for wildcard/static-wildcard. */ + readonly name: string; + /** Node to anchor the synthesized captures (range-wise). */ + readonly atNode: SyntaxNode; +} + +export function splitImportDeclaration(stmtNode: SyntaxNode): CaptureMatch | null { + if (stmtNode.type !== 'import_declaration') return null; + const spec = parseImportDeclaration(stmtNode); + if (spec === null) return null; + return buildImportMatch(stmtNode, spec); +} + +function parseImportDeclaration(node: SyntaxNode): ImportSpec | null { + // Detect `static` by checking for an anonymous `static` token child. + let isStatic = false; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child !== null && child.type === 'static') { + isStatic = true; + break; + } + } + + // Detect wildcard by checking for `asterisk` named child. + let isWildcard = false; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child !== null && child.type === 'asterisk') { + isWildcard = true; + break; + } + } + + // Find the scoped_identifier (or identifier for single-segment imports). + let pathNode: SyntaxNode | null = null; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child !== null && (child.type === 'scoped_identifier' || child.type === 'identifier')) { + pathNode = child; + break; + } + } + if (pathNode === null) return null; + + const fullPath = pathNode.text; + if (fullPath === '') return null; + + if (isStatic && isWildcard) { + // `import static com.example.Utils.*;` + return { kind: 'static-wildcard', source: fullPath, name: '*', atNode: node }; + } + if (isStatic) { + // `import static com.example.Utils.format;` + const lastDot = fullPath.lastIndexOf('.'); + const name = lastDot >= 0 ? fullPath.slice(lastDot + 1) : fullPath; + return { kind: 'static', source: fullPath, name, atNode: node }; + } + if (isWildcard) { + // `import com.example.*;` + return { kind: 'wildcard', source: fullPath, name: '*', atNode: node }; + } + + // `import com.example.User;` + const lastDot = fullPath.lastIndexOf('.'); + const name = lastDot >= 0 ? fullPath.slice(lastDot + 1) : fullPath; + return { kind: 'named', source: fullPath, name, atNode: node }; +} + +function buildImportMatch(stmtNode: SyntaxNode, spec: ImportSpec): CaptureMatch { + const m: Record = { + '@import.statement': nodeToCapture('@import.statement', stmtNode), + '@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind), + '@import.source': syntheticCapture('@import.source', spec.atNode, spec.source), + '@import.name': syntheticCapture('@import.name', spec.atNode, spec.name), + }; + return m; +} diff --git a/gitnexus/src/core/ingestion/languages/java/import-target.ts b/gitnexus/src/core/ingestion/languages/java/import-target.ts new file mode 100644 index 000000000..b78b6369b --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/import-target.ts @@ -0,0 +1,108 @@ +/** + * Adapter from `(ParsedImport, WorkspaceIndex)` → concrete file path. + * + * Converts Java package paths (dots → slashes) and tries: + * 1. Exact file match: `com/example/User.java` + * 2. Suffix match for nested layouts + * 3. Directory match (wildcard imports) + * 4. Progressive prefix stripping for non-standard layouts + * + * Returns `null` for unresolvable / JDK imports. + */ + +import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; + +export interface JavaResolveContext { + readonly fromFile: string; + readonly allFilePaths: ReadonlySet; +} + +export function resolveJavaImportTarget( + parsedImport: ParsedImport, + workspaceIndex: WorkspaceIndex, +): string | null { + const ctx = workspaceIndex as JavaResolveContext | undefined; + if ( + ctx === undefined || + typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' || + !((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set) + ) { + return null; + } + if (parsedImport.kind === 'dynamic-unresolved') return null; + if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null; + + // Strip trailing `.*` for wildcard imports: `com.example.*` → `com.example` + let target = parsedImport.targetRaw; + if (target.endsWith('.*')) { + target = target.slice(0, -2); + } + + // Package path: `com.example.User` → `com/example/User` + const pathLike = target.replace(/\./g, '/'); + const suffix = `/${pathLike}`; + + let exactFile: string | null = null; + let suffixFile: string | null = null; + let directoryChild: string | null = null; + const dirPrefix = `${pathLike}/`; + const suffixDirPrefix = `/${dirPrefix}`; + + for (const raw of ctx.allFilePaths) { + const f = raw.replace(/\\/g, '/'); + if (!f.endsWith('.java')) continue; + if (f === `${pathLike}.java`) { + exactFile = raw; + break; + } + if (suffixFile === null && f.endsWith(`${suffix}.java`)) { + suffixFile = raw; + } + if (directoryChild === null) { + const atRoot = f.startsWith(dirPrefix); + const atNested = f.includes(suffixDirPrefix); + if (atRoot || atNested) { + const idx = atRoot ? 0 : f.indexOf(suffixDirPrefix) + 1; + const after = f.slice(idx + dirPrefix.length); + if (after.length > 0 && !after.includes('/')) { + directoryChild = raw; + } + } + } + } + + if (exactFile !== null) return exactFile; + if (suffixFile !== null) return suffixFile; + if (directoryChild !== null) return directoryChild; + + // Progressive prefix stripping — handles `import com.example.User;` + // in a repo laid out `User.java` (no `com/example/` prefix). + const segments = pathLike.split('/').filter(Boolean); + for (let skip = 1; skip < segments.length; skip++) { + const tail = segments.slice(skip).join('/'); + if (tail === '') continue; + const tailFile = `${tail}.java`; + const tailSuffix = `/${tailFile}`; + const tailDir = `${tail}/`; + const tailSuffixDir = `/${tailDir}`; + let tailDirectChild: string | null = null; + for (const raw of ctx.allFilePaths) { + const f = raw.replace(/\\/g, '/'); + if (!f.endsWith('.java')) continue; + if (f === tailFile) return raw; + if (f.endsWith(tailSuffix)) return raw; + if (tailDirectChild === null) { + const atRoot = f.startsWith(tailDir); + const atNested = f.includes(tailSuffixDir); + if (atRoot || atNested) { + const idx = atRoot ? 0 : f.indexOf(tailSuffixDir) + 1; + const after = f.slice(idx + tailDir.length); + if (after.length > 0 && !after.includes('/')) tailDirectChild = raw; + } + } + } + if (tailDirectChild !== null) return tailDirectChild; + } + + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/java/index.ts b/gitnexus/src/core/ingestion/languages/java/index.ts new file mode 100644 index 000000000..443faac4b --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/index.ts @@ -0,0 +1,30 @@ +/** + * Java scope-resolution hooks (RFC #909 Ring 3). + * + * Public API barrel. Consumers should import from this file rather than + * the individual modules. + * + * Module layout: + * + * - `query.ts` — tree-sitter query + lazy parser/query singletons + * - `captures.ts` — `emitJavaScopeCaptures` orchestrator + * - `import-decomposer.ts` — each `import` → ParsedImport-shaped captures + * - `interpret.ts` — capture-match → `ParsedImport` / `ParsedTypeBinding` + * - `simple-hooks.ts` — small hooks made explicit + * - `receiver-binding.ts` — synthesize `this`/`super` type-bindings on + * instance-method entry + * - `merge-bindings.ts` — Java import precedence + * - `arity.ts` — Java arity compatibility (varargs) + * - `arity-metadata.ts` — synthesize arity metadata from declarations + * - `import-target.ts` — `(ParsedImport, WorkspaceIndex) → file path` adapter + * - `scope-resolver.ts` — `ScopeResolver` registered in `SCOPE_RESOLVERS` + * - `cache-stats.ts` — PROF_SCOPE_RESOLUTION cache hit/miss counters + */ + +export { emitJavaScopeCaptures } from './captures.js'; +export { getJavaCaptureCacheStats, resetJavaCaptureCacheStats } from './cache-stats.js'; +export { interpretJavaImport, interpretJavaTypeBinding } from './interpret.js'; +export { javaMergeBindings } from './merge-bindings.js'; +export { javaArityCompatibility } from './arity.js'; +export { resolveJavaImportTarget, type JavaResolveContext } from './import-target.js'; +export { javaBindingScopeFor, javaImportOwningScope, javaReceiverBinding } from './simple-hooks.js'; diff --git a/gitnexus/src/core/ingestion/languages/java/interpret.ts b/gitnexus/src/core/ingestion/languages/java/interpret.ts new file mode 100644 index 000000000..9c207d451 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/interpret.ts @@ -0,0 +1,141 @@ +/** + * Capture-match → semantic-shape interpreters for Java. + * + * - `interpretJavaImport` → `ParsedImport` + * - `interpretJavaTypeBinding` → `ParsedTypeBinding` + * + * Import matches arrive pre-decomposed by `emitJavaScopeCaptures` + * (one import per match, with synthesized `@import.kind/source/name` + * markers). Type-binding matches arrive from the raw query captures. + */ + +import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared'; + +// ─── interpretImport ────────────────────────────────────────────────────── + +export function interpretJavaImport(captures: CaptureMatch): ParsedImport | null { + const kindCap = captures['@import.kind']; + const sourceCap = captures['@import.source']; + const nameCap = captures['@import.name']; + + const kind = kindCap?.text; + if (kind === undefined || sourceCap === undefined) return null; + + switch (kind) { + case 'named': { + // `import com.example.User;` + return { + kind: 'named', + localName: nameCap?.text ?? sourceCap.text.split('.').pop() ?? sourceCap.text, + importedName: sourceCap.text, + targetRaw: sourceCap.text, + }; + } + case 'wildcard': { + // `import com.example.*;` + return { + kind: 'wildcard', + targetRaw: sourceCap.text + '.*', + }; + } + 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 classPath = lastDot >= 0 ? fullSource.slice(0, lastDot) : fullSource; + return { + kind: 'named', + localName: nameCap?.text ?? (lastDot >= 0 ? fullSource.slice(lastDot + 1) : fullSource), + importedName: fullSource, + targetRaw: classPath, + }; + } + case 'static-wildcard': { + // `import static com.example.Utils.*;` + // The source is the class path (e.g. `com.example.Utils`). + // Resolution should target the class file, not a wildcard directory + // scan — `Utils.java` is the file that contains the static members. + return { + kind: 'wildcard', + targetRaw: sourceCap.text + '.*', + }; + } + default: + return null; + } +} + +// ─── interpretTypeBinding ───────────────────────────────────────────────── + +export function interpretJavaTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null { + const nameCap = captures['@type-binding.name']; + const typeCap = captures['@type-binding.type']; + if (nameCap === undefined || typeCap === undefined) return null; + + // Strip qualifier first so that `com.example.BaseModel` becomes + // `BaseModel` before stripGeneric — the JVM-erasure fallback pattern + // requires an unqualified identifier at the start of the string. + const rawType = stripGeneric(stripQualifier(typeCap.text.trim())); + + // Skip `var` — tree-sitter-java parses `var` as type_identifier with + // text "var". When used without a constructor initializer, there's no + // concrete type to bind. + if (rawType === 'var') return null; + + 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.annotation'] !== undefined) source = 'annotation'; + else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation'; + + return { boundName: nameCap.text, rawTypeName: rawType, source }; +} + +/** + * Unwrap generic type parameters from Java types. + * + * Three tiers, checked in order: + * 1. Known single-arg collection wrappers → extract the element type + * (`List` → `User`, `Optional` → `User`). + * 2. Known two-arg map/container types → extract the value type + * (`Map` → `User`). + * 3. **Fallback (JVM type erasure):** any other generic type → + * strip the generic parameters and keep the raw class name + * (`BaseModel` → `BaseModel`, `CustomList` → `CustomList`). + * This ensures receiver bindings (`this`/`super`) on classes with + * generic superclasses resolve to the correct class file. + */ +function stripGeneric(text: string): string { + // Single-type-argument containers — extract the element type. + const single = text.match( + /^(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(?:List|ArrayList|LinkedList|Set|HashSet|TreeSet|SortedSet|LinkedHashSet|Collection|Iterable|Iterator|Optional|Stream|CompletableFuture|Future|Queue|Deque|ArrayDeque|PriorityQueue|Vector|Stack|Supplier|Consumer|Predicate|Function)<([^,<>]+)>$/, + ); + if (single !== null) return single[1].trim(); + + // Two-type-argument map/container types — extract the value type (second arg). + const twoArg = text.match( + /^(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(?:Map|HashMap|TreeMap|LinkedHashMap|ConcurrentHashMap|ConcurrentMap|SortedMap|NavigableMap|Hashtable|EnumMap|WeakHashMap|IdentityHashMap|BiFunction|BiConsumer|BiPredicate|Pair|Entry)<[^,<>]+,\s*([^,<>]+)>$/, + ); + if (twoArg !== null) return twoArg[1].trim(); + + // Fallback: strip generic parameters from any unrecognized generic type. + // `BaseModel` → `BaseModel`, `Builder` → `Builder`. + // This mirrors JVM type erasure — the raw class name is the resolvable symbol. + // The pattern matches up to the first `<` to handle nested generics safely + // (e.g. `BaseModel>` → `BaseModel`). + const fallback = text.match(/^([A-Za-z_$][A-Za-z0-9_$]*)<.+>$/s); + if (fallback !== null) return fallback[1].trim(); + + return text; +} + +/** `com.example.User` → `User`. */ +function stripQualifier(text: string): string { + const lastDot = text.lastIndexOf('.'); + if (lastDot === -1) return text; + return text.slice(lastDot + 1); +} diff --git a/gitnexus/src/core/ingestion/languages/java/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/java/merge-bindings.ts new file mode 100644 index 000000000..9056705d0 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/merge-bindings.ts @@ -0,0 +1,44 @@ +/** + * Java shadowing precedence for the `mergeBindings` hook. + * + * Tier ranking (lower wins): + * - 0: `local` — class member, method, local variable, parameter + * - 1: `import` / `namespace` / `reexport` — explicit imports + * - 2: `wildcard` — wildcard imports (`import x.y.*`) + * + * Within a surviving tier: de-dup by DefId, last-write-wins. + */ + +import type { BindingRef } from 'gitnexus-shared'; + +const TIER_LOCAL = 0; +const TIER_IMPORT = 1; +const TIER_WILDCARD = 2; +const TIER_UNKNOWN = 3; + +function tierOf(b: BindingRef): number { + switch (b.origin) { + case 'local': + return TIER_LOCAL; + case 'reexport': + case 'import': + case 'namespace': + return TIER_IMPORT; + case 'wildcard': + return TIER_WILDCARD; + default: + return TIER_UNKNOWN; + } +} + +export function javaMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] { + if (bindings.length === 0) return bindings; + + let bestTier = Number.POSITIVE_INFINITY; + for (const b of bindings) bestTier = Math.min(bestTier, tierOf(b)); + const survivors = bindings.filter((b) => tierOf(b) === bestTier); + + const seen = new Map(); + for (const b of survivors) seen.set(b.def.nodeId, b); + return [...seen.values()]; +} diff --git a/gitnexus/src/core/ingestion/languages/java/query.ts b/gitnexus/src/core/ingestion/languages/java/query.ts new file mode 100644 index 000000000..3fabbb7bf --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/query.ts @@ -0,0 +1,197 @@ +/** + * Tree-sitter query for Java scope captures (RFC §5.1). + * + * Captures the structural skeleton the generic scope-resolution + * pipeline consumes: scopes (module/class/function), declarations + * (class-likes, method-likes, fields, variables), imports (import + * declarations), type bindings (parameter annotations, variable + * annotations, constructor inference), and references (call sites, + * member writes/reads). + * + * Java specifics that shape this query: + * + * - Java uses `program` as the root node (not `compilation_unit`). + * - `import_declaration` nodes carry `scoped_identifier` children + * and optional `asterisk` for wildcard imports. + * - `static` imports are detected by an anonymous `static` token + * child within `import_declaration`. + * - `var` (Java 10+ local variable type inference) parses as a + * `type_identifier` with text `"var"`, not a dedicated node type. + * - Modifiers (`public`, `static`, etc.) are grouped under a + * `modifiers` named child with anonymous keyword tokens. + * - Superclass inheritance uses a `superclass:` field containing + * a `superclass` node wrapping a `type_identifier`. + * + * Exposes lazy `Parser` and `Query` singletons so callers don't pay + * tree-sitter init cost per file. + */ + +import Parser from 'tree-sitter'; +import Java from 'tree-sitter-java'; + +const JAVA_SCOPE_QUERY = ` +;; Scopes +(program) @scope.module + +(class_declaration) @scope.class +(interface_declaration) @scope.class +(enum_declaration) @scope.class +(record_declaration) @scope.class +(annotation_type_declaration) @scope.class + +(method_declaration) @scope.function +(constructor_declaration) @scope.function + +;; Declarations — types +(class_declaration + name: (identifier) @declaration.name) @declaration.class + +(interface_declaration + name: (identifier) @declaration.name) @declaration.interface + +(enum_declaration + name: (identifier) @declaration.name) @declaration.enum + +(record_declaration + name: (identifier) @declaration.name) @declaration.record + +(annotation_type_declaration + name: (identifier) @declaration.name) @declaration.class + +;; Declarations — methods / constructors +(method_declaration + name: (identifier) @declaration.name) @declaration.method + +(constructor_declaration + name: (identifier) @declaration.name) @declaration.constructor + +;; Declarations — fields +(field_declaration + declarator: (variable_declarator + name: (identifier) @declaration.name)) @declaration.variable + +;; Declarations — local variables +(local_variable_declaration + declarator: (variable_declarator + name: (identifier) @declaration.name)) @declaration.variable + +;; Imports — single anchor per import_declaration +(import_declaration) @import.statement + +;; Type bindings — parameter annotations: void f(User u) +(formal_parameter + type: (type_identifier) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.parameter + +(formal_parameter + type: (generic_type) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.parameter + +(formal_parameter + type: (scoped_type_identifier) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.parameter + +;; Type bindings — local variable annotations: User u = new User(); +(local_variable_declaration + type: (type_identifier) @type-binding.type + declarator: (variable_declarator + name: (identifier) @type-binding.name)) @type-binding.annotation + +(local_variable_declaration + type: (generic_type) @type-binding.type + declarator: (variable_declarator + name: (identifier) @type-binding.name)) @type-binding.annotation + +;; 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 +;; object_creation_expression so interpretJavaTypeBinding can infer +;; the concrete type from the constructor call. +(local_variable_declaration + type: (type_identifier) @_var_type + declarator: (variable_declarator + name: (identifier) @type-binding.name + value: (object_creation_expression + type: (type_identifier) @type-binding.type))) @type-binding.constructor + +;; Type bindings — field declarations: private User user; +(field_declaration + type: (type_identifier) @type-binding.type + declarator: (variable_declarator + name: (identifier) @type-binding.name)) @type-binding.annotation + +(field_declaration + type: (generic_type) @type-binding.type + declarator: (variable_declarator + name: (identifier) @type-binding.name)) @type-binding.annotation + +;; Type bindings — method return type: public User getUser() { } +(method_declaration + type: (type_identifier) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.return + +(method_declaration + type: (generic_type) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.return + +;; Type bindings — enhanced for: for (User u : list) +(enhanced_for_statement + type: (type_identifier) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.annotation + +(enhanced_for_statement + type: (generic_type) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.annotation + +;; 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 +;; node type, so we match all calls here and classify free vs +;; member in captures.ts based on the presence of @reference.receiver. +(method_invocation + object: (_) @reference.receiver + name: (identifier) @reference.name) @reference.call.member + +(method_invocation + name: (identifier) @reference.name) @reference.call.free + +;; References — constructor calls: new User(...) +(object_creation_expression + type: (type_identifier) @reference.name) @reference.call.constructor + +(object_creation_expression + type: (generic_type + (type_identifier) @reference.name)) @reference.call.constructor + +(object_creation_expression + type: (scoped_type_identifier) @reference.call.constructor.qualified) @reference.call.constructor + +;; References — field/property writes: obj.name = "x" +(assignment_expression + left: (field_access + object: (_) @reference.receiver + field: (identifier) @reference.name)) @reference.write.member + +;; References — field/property reads: obj.name +(field_access + object: (_) @reference.receiver + field: (identifier) @reference.name) @reference.read.member +`; + +let _parser: Parser | null = null; +let _query: Parser.Query | null = null; + +export function getJavaParser(): Parser { + if (_parser === null) { + _parser = new Parser(); + _parser.setLanguage(Java as Parameters[0]); + } + return _parser; +} + +export function getJavaScopeQuery(): Parser.Query { + if (_query === null) { + _query = new Parser.Query(Java as Parameters[0], JAVA_SCOPE_QUERY); + } + return _query; +} diff --git a/gitnexus/src/core/ingestion/languages/java/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/java/receiver-binding.ts new file mode 100644 index 000000000..4d6d60ded --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/receiver-binding.ts @@ -0,0 +1,103 @@ +/** + * Synthesize `@type-binding.self` captures for Java instance methods — + * one for `this` (always on non-static methods inside a type + * declaration) and optionally one for `super` (only on class methods + * when the enclosing class has a `superclass`). + * + * Mirrors `languages/csharp/receiver-binding.ts` in structure. + */ + +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; + +const TYPE_DECL_NODE_TYPES = new Set([ + 'class_declaration', + 'interface_declaration', + 'enum_declaration', + 'record_declaration', +]); + +const FUNCTION_NODE_TYPES = new Set(['method_declaration', 'constructor_declaration']); + +/** Walk up to the enclosing type declaration. */ +function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null { + let cur: SyntaxNode | null = node.parent; + while (cur !== null) { + if (TYPE_DECL_NODE_TYPES.has(cur.type)) return cur; + cur = cur.parent; + } + return null; +} + +function typeName(typeNode: SyntaxNode): string | null { + return typeNode.childForFieldName('name')?.text ?? null; +} + +/** First superclass text. tree-sitter-java uses a `superclass` field + * containing a `superclass` node wrapping a `type_identifier`. */ +function firstSuperclassText(typeNode: SyntaxNode): string | null { + const superclass = typeNode.childForFieldName('superclass'); + if (superclass === null) return null; + // The superclass node wraps the type_identifier + for (let i = 0; i < superclass.namedChildCount; i++) { + const child = superclass.namedChild(i); + if (child !== null && (child.type === 'type_identifier' || child.type === 'generic_type')) { + return child.text; + } + } + return null; +} + +/** Check if a method has the `static` modifier. In tree-sitter-java, + * modifiers are grouped under a `modifiers` named child with anonymous + * keyword tokens. */ +function isStaticMethod(fnNode: SyntaxNode): boolean { + for (let i = 0; i < fnNode.namedChildCount; i++) { + const child = fnNode.namedChild(i); + if (child !== null && child.type === 'modifiers') { + for (let j = 0; j < child.childCount; j++) { + const mod = child.child(j); + if (mod !== null && mod.text.trim() === 'static') return true; + } + } + } + return false; +} + +export function synthesizeJavaReceiverBinding(fnNode: SyntaxNode): CaptureMatch[] { + if (!FUNCTION_NODE_TYPES.has(fnNode.type)) return []; + if (isStaticMethod(fnNode)) return []; + + const enclosingType = findEnclosingTypeDeclaration(fnNode); + if (enclosingType === null) return []; + + const enclosingName = typeName(enclosingType); + if (enclosingName === null) return []; + + // Anchor to the method body so the synthesized captures are inside + // the function scope. + const anchorNode = fnNode.childForFieldName('body'); + if (anchorNode === null) return []; + + const out: CaptureMatch[] = []; + out.push(buildReceiverMatch(anchorNode, 'this', enclosingName)); + + // `super` applies only to class/record methods with an explicit superclass. + if (enclosingType.type === 'class_declaration' || enclosingType.type === 'record_declaration') { + const superText = firstSuperclassText(enclosingType); + if (superText !== null) { + out.push(buildReceiverMatch(anchorNode, 'super', superText)); + } + } + + return out; +} + +function buildReceiverMatch(anchorNode: SyntaxNode, name: string, typeText: string): CaptureMatch { + const m: Record = { + '@type-binding.self': nodeToCapture('@type-binding.self', anchorNode), + '@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, name), + '@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeText), + }; + return m; +} diff --git a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts new file mode 100644 index 000000000..dac974cc7 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts @@ -0,0 +1,97 @@ +/** + * Java `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by + * the generic `runScopeResolution` orchestrator (RFC #909 Ring 3). + * + * ## 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: + * + * - switch pattern binding / sealed-class exhaustiveness + * - Map.values() / entrySet() iteration type propagation + * - assignment / method chain return-type propagation across files + * - virtual dispatch / interface default methods + * + * These are the same category of advanced-resolution gaps seen in prior + * migrations (Python, C#, Go). Parity is below the ≥99% flip threshold + * per RFC §6.4. + * + * **CI visibility:** Because Java is absent from `MIGRATED_LANGUAGES`, + * the parity CI workflow (`ci-scope-parity.yml`) does not run Java in + * either `REGISTRY_PRIMARY_JAVA=0` or `=1` mode. Regressions in forced + * mode are only visible via manual `REGISTRY_PRIMARY_JAVA=1 npx vitest + * run java.test.ts`. Before flipping Java to registry-primary, a + * non-required CI step should be added to run Java tests in forced mode + * and report parity as a dashboard input. + * + * **Parity baseline (29 failures):** The 29 gaps in forced registry mode + * are tracked in this PR (#1482) and this JSDoc. If the gap count + * changes (up or down), update this baseline accordingly. + * + * ### Known flip-blockers (must fix before adding to MIGRATED_LANGUAGES) + * + * - Varargs arity: fixed-prefix count is now preserved, but no + * integration fixture exercises the 0-arg rejection path yet. + * - Static import resolution: `import static X.Y.m` now correctly + * resolves to `X/Y.java` (the class), not `X/Y/m.java` (the member). + * Edge cases with nested classes may remain. + * - Generic superclass receiver binding: `BaseModel` now strips + * to `BaseModel` via JVM type-erasure fallback in `stripGeneric`. + * - Wildcard import (`import com.example.*`) file selection is + * nondeterministic when multiple classes share a package directory. + * May produce wrong-file edges in forced mode. + * - Qualified generic type parameters in field/parameter annotations + * (`com.example.BaseModel`) — rare in practice but may miss + * resolution when the full qualifier is present with generics. + */ + +import type { ParsedFile } from 'gitnexus-shared'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; +import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; +import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { javaProvider } from '../java.js'; +import { + javaArityCompatibility, + javaMergeBindings, + resolveJavaImportTarget, + type JavaResolveContext, +} from './index.js'; + +const javaScopeResolver: ScopeResolver = { + language: SupportedLanguages.Java, + languageProvider: javaProvider, + importEdgeReason: 'java-scope: import', + + resolveImportTarget: (targetRaw, fromFile, allFilePaths) => { + const ws: JavaResolveContext = { fromFile, allFilePaths }; + return resolveJavaImportTarget( + { kind: 'named', localName: '_', importedName: '_', targetRaw }, + ws, + ); + }, + + mergeBindings: (existing, incoming) => [...javaMergeBindings([...existing, ...incoming])], + + arityCompatibility: (callsite, def) => javaArityCompatibility(def, callsite), + + buildMro: (graph, parsedFiles, nodeLookup) => + buildMro(graph, parsedFiles, nodeLookup, defaultLinearize), + + 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 + hoistTypeBindingsToModule: true, +}; + +export { javaScopeResolver }; diff --git a/gitnexus/src/core/ingestion/languages/java/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/java/simple-hooks.ts new file mode 100644 index 000000000..e69f768a6 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/simple-hooks.ts @@ -0,0 +1,54 @@ +/** + * Small hooks for the Java provider. Each is a few lines; they make + * the provider's choice explicit rather than relying on defaults. + */ + +import type { + CaptureMatch, + ParsedImport, + Scope, + ScopeId, + ScopeTree, + TypeRef, +} from 'gitnexus-shared'; + +// ─── bindingScopeFor ────────────────────────────────────────────────────── + +/** Method return-type bindings hoist to Module scope so cross-file + * `propagateImportedReturnTypes` and chain-follow can find them. */ +export function javaBindingScopeFor( + decl: CaptureMatch, + innermost: Scope, + tree: ScopeTree, +): ScopeId | null { + if (decl['@type-binding.return'] !== undefined) { + let cur: Scope | undefined = innermost; + while (cur !== undefined && cur.kind !== 'Module') { + const parentId: ScopeId | null = cur.parent ?? null; + if (parentId === null) break; + cur = tree.getScope(parentId); + } + if (cur !== undefined && cur.kind === 'Module') return cur.id; + } + return null; +} + +// ─── importOwningScope ──────────────────────────────────────────────────── + +/** Java imports are always at compilation-unit (Module) level (JLS §7.5). + * Return `null` unconditionally so the default Module scope is used. */ +export function javaImportOwningScope( + _imp: ParsedImport, + _innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + return null; +} + +// ─── receiverBinding ────────────────────────────────────────────────────── + +/** Look up `this` or `super` in the function scope's type bindings. */ +export function javaReceiverBinding(functionScope: Scope): TypeRef | null { + if (functionScope.kind !== 'Function') return null; + return functionScope.typeBindings.get('this') ?? functionScope.typeBindings.get('super') ?? null; +} diff --git a/gitnexus/src/core/ingestion/languages/php.ts b/gitnexus/src/core/ingestion/languages/php.ts index eb8f296f8..caca85335 100644 --- a/gitnexus/src/core/ingestion/languages/php.ts +++ b/gitnexus/src/core/ingestion/languages/php.ts @@ -5,12 +5,22 @@ * and standard export/import resolution. PHP files can use a variety of * extensions from legacy versions through modern PHP 8. */ +import { + emitPhpScopeCaptures, + interpretPhpImport, + interpretPhpTypeBinding, + phpArityCompatibility, + phpMergeBindings, + resolvePhpImportTarget, + phpBindingScopeFor, + phpImportOwningScope, + phpReceiverBinding, +} from './php/index.js'; import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; import { phpClassConfig } from '../class-extractors/configs/php.js'; -import { defineLanguage } from '../language-provider.js'; -import type { AstFrameworkPatternConfig } from '../language-provider.js'; +import { defineLanguage, type AstFrameworkPatternConfig } from '../language-provider.js'; import { typeConfig as phpConfig } from '../type-extractors/php.js'; import { phpExportChecker } from '../export-detection.js'; import { createImportResolver } from '../import-resolvers/resolver-factory.js'; @@ -289,4 +299,18 @@ export const phpProvider = defineLanguage({ descriptionExtractor: phpDescriptionExtractor, isRouteFile: isPhpRouteFile, builtInNames: BUILT_INS, + // ── RFC #909 Ring 3: scope-based resolution hooks ────────────────────── + emitScopeCaptures: emitPhpScopeCaptures, + interpretImport: interpretPhpImport, + interpretTypeBinding: interpretPhpTypeBinding, + // LanguageProvider uses (def, callsite); phpArityCompatibility uses (def, callsite) — same. + arityCompatibility: phpArityCompatibility, + // LanguageProvider adapter: (parsedImport, workspaceIndex) → string | null + resolveImportTarget: resolvePhpImportTarget, + // mergeBindings on LanguageProvider: (scope, bindings) — ignore scope id, + // delegate to phpMergeBindings which uses binding origin tiers. + mergeBindings: (_scope, bindings) => [...phpMergeBindings(bindings)], + bindingScopeFor: phpBindingScopeFor, + importOwningScope: phpImportOwningScope, + receiverBinding: phpReceiverBinding, }); diff --git a/gitnexus/src/core/ingestion/languages/php/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/php/arity-metadata.ts new file mode 100644 index 000000000..40662bd61 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/php/arity-metadata.ts @@ -0,0 +1,73 @@ +/** + * Extract PHP arity metadata from a method-like tree-sitter node — + * `method_declaration` or `function_definition`. + * + * Reuses `phpMethodConfig.extractParameters` so scope-extracted defs + * carry the same arity semantics as the legacy parse-worker path: + * - `variadic_parameter` (`...$args`) collapses `parameterCount` to + * `undefined`, which `phpArityCompatibility` then treats as + * "max unknown" — the candidate stays eligible at `argCount >= required`. + * - Defaulted parameters (`= expr`) contribute to `optionalCount`; + * `requiredParameterCount = total − optionalCount − (variadic ? 1 : 0)`. + * The variadic slot itself accepts zero args so it is subtracted from + * the required count — `f(int $a, ...$rest)` requires exactly 1 arg, + * not 2, and `f(...$rest)` requires 0. + * - `property_promotion_parameter` (constructor-promoted) is counted + * the same as `simple_parameter` since both consume an argument slot. + * - `parameterTypes` collects declared type names; a literal `'...'` + * marker is appended for variadic methods so `phpArityCompatibility` + * can detect them without re-reading the AST. + */ + +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { phpMethodConfig } from '../../method-extractors/configs/php.js'; + +interface PhpArityMetadata { + readonly parameterCount: number | undefined; + readonly requiredParameterCount: number | undefined; + readonly parameterTypes: readonly string[] | undefined; +} + +export function computePhpArityMetadata(fnNode: SyntaxNode): PhpArityMetadata { + const params = phpMethodConfig.extractParameters?.(fnNode) ?? []; + + let hasVariadic = false; + let optionalCount = 0; + const types: string[] = []; + + for (const p of params) { + if (p.isVariadic) { + hasVariadic = true; + } else if (p.isOptional) { + optionalCount++; + } + if (p.type !== null) types.push(p.type); + } + // PHP variadic marker convention: append the literal '...' string to + // `parameterTypes`. This is intentionally DIFFERENT from C#, which uses + // the literal 'params' (its source-language keyword). The shared + // `narrowOverloadCandidates` pass in `scope-resolution/passes/overload- + // narrowing.ts` checks for the C# 'params' marker — that branch is + // dead code for PHP because PHP variadic methods set `parameterCount + // = undefined` (see line below), which skips the `max !== undefined` + // gate that hosts the 'params' check. PHP's actual variadic-aware + // arity logic lives in `phpArityCompatibility` (arity.ts) and now + // also in `phpEmitUnresolvedReceiverEdges` (scope-resolver.ts), both + // of which check `'...'`. Finding 9 of PR #1497 adversarial review. + if (hasVariadic) types.push('...'); + + const total = params.length; + // Variadic methods accept any arg count ≥ required — leave `parameterCount` + // undefined so the registry treats max as unknown. + const parameterCount = hasVariadic ? undefined : total; + // The variadic slot itself accepts zero args; subtract it from the required + // count so PHP's ArgumentCountError-equivalent calls (too few args before + // the variadic) are correctly rejected by arity compatibility. + const requiredParameterCount = total - optionalCount - (hasVariadic ? 1 : 0); + + return { + parameterCount, + requiredParameterCount, + parameterTypes: types.length > 0 ? types : undefined, + }; +} diff --git a/gitnexus/src/core/ingestion/languages/php/arity.ts b/gitnexus/src/core/ingestion/languages/php/arity.ts new file mode 100644 index 000000000..5b99a73c7 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/php/arity.ts @@ -0,0 +1,47 @@ +/** + * PHP arity check, accommodating variadic (`...$args`) and default parameters. + * + * The `def` metadata synthesized by `arity-metadata.ts`: + * - `parameterCount` — total formal parameters; `undefined` when + * the method has a variadic `...$param`. + * - `requiredParameterCount` — min required (excludes defaulted params + * and the variadic itself). + * - `parameterTypes` — declared type strings; contains the + * literal `'...'` when the method is variadic. + * + * Verdicts: + * - `'compatible'` — `required <= argCount <= max`, OR the def has + * variadic (any `argCount >= required`). + * - `'incompatible'` — argCount below required, or above max with no variadic. + * - `'unknown'` — metadata absent / incomplete; named-args can satisfy + * any arity so we return unknown when we detect them. + * + * PHP supports named arguments (PHP 8.0+): `save(force: true)`. Named-arg + * call sites cannot be arity-checked statically without parsing arg names, + * so we return `'unknown'` when the callsite carries named args (signalled + * by a negative `arity` value per the shared Callsite contract). + */ + +import type { Callsite, SymbolDefinition } from 'gitnexus-shared'; + +export function phpArityCompatibility( + def: SymbolDefinition, + callsite: Callsite, +): 'compatible' | 'unknown' | 'incompatible' { + const max = def.parameterCount; + const min = def.requiredParameterCount; + if (max === undefined && min === undefined) return 'unknown'; + + const argCount = callsite.arity; + // Negative arity signals named-argument call sites — can't narrow statically. + if (!Number.isFinite(argCount) || argCount < 0) return 'unknown'; + + const hasVarArgs = + def.parameterTypes !== undefined && + def.parameterTypes.some((t) => t === '...' || t.startsWith('...')); + + if (min !== undefined && argCount < min) return 'incompatible'; + if (max !== undefined && argCount > max && !hasVarArgs) return 'incompatible'; + + return 'compatible'; +} diff --git a/gitnexus/src/core/ingestion/languages/php/cache-stats.ts b/gitnexus/src/core/ingestion/languages/php/cache-stats.ts new file mode 100644 index 000000000..508eec580 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/php/cache-stats.ts @@ -0,0 +1,30 @@ +/** + * Dev-mode counters for the cross-phase scope-captures parse cache + * (PHP mirror of `languages/csharp/cache-stats.ts`). + * + * Gated by `PROF_SCOPE_RESOLUTION=1`. Production builds fold every + * increment into dead code via the module-level `PROF` constant, so + * the hot path in `captures.ts` stays branch-free. + */ + +const PROF = process.env.PROF_SCOPE_RESOLUTION === '1'; + +let CACHE_HITS = 0; +let CACHE_MISSES = 0; + +export function recordCacheHit(): void { + if (PROF) CACHE_HITS++; +} + +export function recordCacheMiss(): void { + if (PROF) CACHE_MISSES++; +} + +export function getPhpCaptureCacheStats(): { hits: number; misses: number } { + return { hits: CACHE_HITS, misses: CACHE_MISSES }; +} + +export function resetPhpCaptureCacheStats(): void { + CACHE_HITS = 0; + CACHE_MISSES = 0; +} diff --git a/gitnexus/src/core/ingestion/languages/php/captures.ts b/gitnexus/src/core/ingestion/languages/php/captures.ts new file mode 100644 index 000000000..692d87bc1 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/php/captures.ts @@ -0,0 +1,806 @@ +/** + * `emitScopeCaptures` for PHP (RFC #909 Ring 3 LANG-php). + * + * Drives the PHP scope query against tree-sitter-php and groups raw + * matches into `CaptureMatch[]` for the central extractor. Layers two + * synthesized streams on top: + * + * 1. **Decomposed use declarations** — each `namespace_use_declaration` + * is re-emitted with `@import.kind/source/name/alias` markers so + * `interpretPhpImport` can recover the ParsedImport shape without + * re-parsing raw text. Grouped uses fan out to one match per clause. + * + * 2. **Receiver-binding synthesis** — `$this` and `parent` type-bindings + * are synthesized on every non-static method entry. PHP's grammar + * does not express "implicit receiver of a non-static class method" + * via a clean `.scm` pattern, so we walk up the AST in code. + * + * 3. **Arity metadata synthesis** — `@declaration.parameter-count` / + * `@declaration.required-parameter-count` / `@declaration.parameter-types` + * are synthesized on function-like declarations so the registry can + * narrow overloads. + * + * 4. **PHPDoc synthesis** — @param and @return annotations in comment + * nodes preceding method/function declarations are extracted and emitted + * as `@type-binding.parameter` and `@type-binding.return` matches. + * + * 5. **Foreach loop synthesis** — `foreach ($users as $user)` emits + * a `@type-binding.alias` match binding the loop variable to the + * element type of the iterable (resolved from PHPDoc or scopeEnv). + * + * Pure given the input source text. No I/O, no globals consulted. + */ + +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js'; +import { splitNamespaceUseDeclaration } from './import-decomposer.js'; +import { computePhpArityMetadata } from './arity-metadata.js'; +import { synthesizePhpReceiverBinding } from './receiver-binding.js'; +import { getPhpParser, getPhpScopeQuery } from './query.js'; +import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; +import { getTreeSitterBufferSize } from '../../constants.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; + +type SyntaxNode = ReturnType['parse']>['rootNode']; + +/** Declaration anchors that carry function-like arity metadata. */ +const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.function'] as const; + +/** tree-sitter-php node types that the method extractor accepts. */ +const FUNCTION_NODE_TYPES = [ + 'method_declaration', + 'function_definition', + 'anonymous_function', + 'arrow_function', +] as const; + +export function emitPhpScopeCaptures( + sourceText: string, + _filePath: string, + cachedTree?: unknown, +): readonly CaptureMatch[] { + // Skip the parse when the caller already produced a Tree for this source. + // The cachedTree parameter is typed as `unknown` at the LanguageProvider + // contract layer; cast here at the use site. + let tree = cachedTree as ReturnType['parse']> | undefined; + if (tree === undefined) { + tree = parseSourceSafe(getPhpParser(), sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + recordCacheMiss(); + } else { + recordCacheHit(); + } + + const rawMatches = getPhpScopeQuery().matches(tree.rootNode); + const out: CaptureMatch[] = []; + + // Pre-scan: collect anchor node IDs of property_declaration nodes already + // matched by the typed @declaration.property pattern (query.ts ~lines 95–98). + // The untyped @declaration.variable catch-all (query.ts ~lines 101–103) is + // intentionally loose — it has no `type:` constraint, so tree-sitter also + // matches it against typed property declarations and emits a second capture + // for the same property_declaration anchor. Graph-level def-id collision + // currently masks the duplicate at the node-emit layer, but the catch-all + // capture still flows through scope-binding / name-keyed registries with a + // `$`-prefixed name that the typed branch's `$`-strip never normalizes — + // a known vector for receiver-binding lookup pollution. The two patterns + // produce separate rawMatches entries with separate `grouped` maps, so the + // dedup has to be cross-match: build the set here, then skip + // @declaration.variable matches whose anchor is in it (loop below). + const typedPropertyAnchorIds = new Set(); + for (const m of rawMatches) { + for (const c of m.captures) { + if (c.name === 'declaration.property') { + typedPropertyAnchorIds.add(c.node.id); + break; + } + } + } + + for (const m of rawMatches) { + // Group captures by their tag name. Tree-sitter strips the leading + // `@`; we put it back so the central extractor's prefix lookups work. + const grouped: Record = {}; + for (const c of m.captures) { + const tag = '@' + c.name; + grouped[tag] = nodeToCapture(tag, c.node); + } + if (Object.keys(grouped).length === 0) continue; + + // Cross-match dedup for the typed-property double-match described above: + // skip @declaration.variable matches whose anchor was already captured as + // @declaration.property in an earlier match. + if (grouped['@declaration.variable'] !== undefined) { + const varCap = m.captures.find((c) => c.name === 'declaration.variable'); + if (varCap !== undefined && typedPropertyAnchorIds.has(varCap.node.id)) continue; + } + + // Normalize PHP property declarations: strip leading `$` from + // `@declaration.name` for @declaration.property matches. PHP stores + // field names WITHOUT the `$` sigil in the graph so that member access + // lookups like `$user->address` can find the property named `address` + // (not `$address`). `@type-binding.annotation` already strips `$` in + // `interpretPhpTypeBinding`; this mirrors that for the declaration side. + // + // Only applies to `@declaration.property` — typed class properties and + // constructor-promoted parameters. Untyped `@declaration.variable` keeps + // its `$` prefix (those defs are Variable type and not in the field + // registry, so their name doesn't affect member lookup). + if ( + grouped['@declaration.property'] !== undefined && + grouped['@declaration.name'] !== undefined + ) { + const nameCap = grouped['@declaration.name']; + if (nameCap.text.startsWith('$')) { + grouped['@declaration.name'] = { ...nameCap, text: nameCap.text.slice(1) }; + } + } + + // Normalize PHP receiver expressions so the compound-receiver resolver + // can walk chains expressed with `->` (PHP) as if they used `.` (the + // resolver's canonical separator). Without this, `$user->address->save()` + // has receiver text `$user->address` — the resolver sees no `.` separator, + // treats it as a bare identifier, and cannot walk field types. + // + // Transformation applied to `@reference.receiver` captures: + // 1. Replace `->` with `.` ($user->address → $user.address) + // 2. Strip leading `$` from each segment ($user.address → user.address) + // 3. Strip trailing `?` on null-safe receivers ($user? → user) + // + // This is a PHP-local normalization — no shared pipeline code is changed. + if (grouped['@reference.receiver'] !== undefined) { + const recvCap = grouped['@reference.receiver']!; + const normalized = normalizePhpReceiver(recvCap.text); + if (normalized !== recvCap.text) { + grouped['@reference.receiver'] = { ...recvCap, text: normalized }; + } + } + + // Normalize static property write: strip leading `$` from `@reference.name` + // so `User::$count` resolves to property `count` (stored without `$` in graph). + if (grouped['@reference.write.static'] !== undefined) { + const nameCap = grouped['@reference.name']; + if (nameCap !== undefined && nameCap.text.startsWith('$')) { + grouped['@reference.name'] = { + ...nameCap, + text: nameCap.text.slice(1), + }; + } + // Re-tag as @reference.write.member so downstream passes see a uniform write kind. + grouped['@reference.write.member'] = grouped['@reference.write.static']!; + delete grouped['@reference.write.static']; + } + + // Decompose each `namespace_use_declaration` so `interpretPhpImport` + // sees the kind/source/name/alias markers it consumes. + if (grouped['@import.statement'] !== undefined) { + const stmtCapture = grouped['@import.statement']; + const stmtNode = findNodeAtRange( + tree.rootNode, + stmtCapture.range, + 'namespace_use_declaration', + ); + if (stmtNode !== null) { + const decomposed = splitNamespaceUseDeclaration(stmtNode); + if (decomposed.length > 0) { + for (const d of decomposed) out.push(d); + continue; + } + } + // Defensive fallback: emit the raw match. + out.push(grouped); + continue; + } + + // Synthesize `$this` / `parent` receiver type-bindings on every + // non-static method-like. Mirrors C#'s `this` / `base` synthesis. + if (grouped['@scope.function'] !== undefined) { + out.push(grouped); + const anchor = grouped['@scope.function']!; + const fnNode = findFunctionNode(tree.rootNode, anchor.range); + if (fnNode !== null) { + for (const synth of synthesizePhpReceiverBinding(fnNode)) { + out.push(synth); + } + // Synthesize PHPDoc @param and @return type bindings for this fn. + for (const synth of synthesizePhpDocBindings(fnNode)) { + out.push(synth); + } + // Synthesize foreach loop variable bindings inside this fn body. + for (const synth of synthesizeForeachBindings(fnNode)) { + out.push(synth); + } + } + continue; + } + + // Synthesize arity metadata on function-like declarations so the + // registry can narrow overloads. + const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined); + if (declTag !== undefined) { + const anchor = grouped[declTag]!; + const fnNode = findFunctionNode(tree.rootNode, anchor.range); + if (fnNode !== null) { + const arity = computePhpArityMetadata(fnNode); + if (arity.parameterCount !== undefined) { + grouped['@declaration.parameter-count'] = syntheticCapture( + '@declaration.parameter-count', + fnNode, + String(arity.parameterCount), + ); + } + if (arity.requiredParameterCount !== undefined) { + grouped['@declaration.required-parameter-count'] = syntheticCapture( + '@declaration.required-parameter-count', + fnNode, + String(arity.requiredParameterCount), + ); + } + if (arity.parameterTypes !== undefined) { + grouped['@declaration.parameter-types'] = syntheticCapture( + '@declaration.parameter-types', + fnNode, + JSON.stringify(arity.parameterTypes), + ); + } + } + } + + // Synthesize `@reference.arity` on every call site so the registry's + // arity filter can narrow overloads. Count the `argument` children of + // the backing `arguments` node. Mirrors C#'s pattern (csharp/captures.ts + // lines 149-186). PHP needs this for arity-based dispatch (Cluster H). + const callTag = ( + ['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const + ).find((t) => grouped[t] !== undefined); + if (callTag !== undefined && grouped['@reference.arity'] === undefined) { + const anchor = grouped[callTag]!; + const callNode = + findNodeAtRange(tree.rootNode, anchor.range, 'function_call_expression') ?? + findNodeAtRange(tree.rootNode, anchor.range, 'member_call_expression') ?? + findNodeAtRange(tree.rootNode, anchor.range, 'nullsafe_member_call_expression') ?? + findNodeAtRange(tree.rootNode, anchor.range, 'scoped_call_expression') ?? + findNodeAtRange(tree.rootNode, anchor.range, 'object_creation_expression'); + if (callNode !== null) { + const argList = callNode.childForFieldName('arguments'); + const args: SyntaxNode[] = []; + if (argList !== null) { + for (let i = 0; i < argList.namedChildCount; i++) { + const child = argList.namedChild(i); + if (child !== null && child.type === 'argument') args.push(child); + } + } + grouped['@reference.arity'] = syntheticCapture( + '@reference.arity', + callNode, + String(args.length), + ); + // Infer argument types from literal nodes for type-based narrowing. + // Non-literal arguments emit empty string ("unknown" = any-match). + const argTypes = args.map((arg) => inferPhpArgType(arg)); + grouped['@reference.parameter-types'] = syntheticCapture( + '@reference.parameter-types', + callNode, + JSON.stringify(argTypes), + ); + } + } + + out.push(grouped); + } + + return out; +} + +/** Find the first PHP function-like node at the given range. */ +function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null { + for (const nodeType of FUNCTION_NODE_TYPES) { + const n = findNodeAtRange(rootNode, range, nodeType); + if (n !== null) return n as SyntaxNode; + } + return null; +} + +// ─── PHP receiver normalization ────────────────────────────────────────────── + +/** + * Normalize a PHP receiver expression so the language-agnostic + * compound-receiver resolver (which splits on `.`) can walk field-type chains. + * + * The compound-receiver resolver: + * - splits on `.` to get chain segments + * - looks up the first segment in `typeBindings` (keyed with `$` for variables) + * - walks subsequent segments as field names (stored without `$` in the graph) + * + * Transformation: + * 1. Replace `->` and `?->` with `.` so the resolver's splitter works + * 2. Strip any bare `?` fragment left by null-safe chain ends + * 3. Strip `$` from all segments EXCEPT the first (which is a variable + * and must keep `$` for typeBindings lookup — e.g. `$user → User`) + * + * Examples: + * `$user` → `$user` (bare variable — unchanged) + * `$user->address` → `$user.address` + * `$user->address->city` → `$user.address.city` + * `$user?` → `$user` (null-safe trailing `?` stripped) + * `$this` → `$this` (receiverBinding uses `$this`) + * `parent` → `parent` (super-receiver check) + */ +function normalizePhpReceiver(raw: string): string { + // Keep `$this`, `parent`, and `self` as-is. + if (raw === '$this' || raw === 'parent' || raw === 'self') return raw; + + // Replace `?->` (null-safe) and plain `->` with `.`. + let text = raw.replace(/\?->/g, '.').replace(/->/g, '.'); + // Strip a trailing `?` (null-safe fragment on the last object node). + text = text.replace(/\?$/, ''); + // Collapse any doubled dots from `?->` where `?` was on its own. + text = text.replace(/\.{2,}/g, '.'); + // Strip trailing dot. + text = text.replace(/\.$/, ''); + + // Split on `.` and strip `$` from all segments EXCEPT the first. + // The first segment is a PHP variable (typeBinding key includes `$`). + // Subsequent segments are property/method names (stored without `$`). + const segments = text.split('.'); + for (let i = 1; i < segments.length; i++) { + const s = segments[i]; + if (s !== undefined && s.startsWith('$')) segments[i] = s.slice(1); + } + return segments.join('.'); +} + +// ─── PHP argument type inference ───────────────────────────────────────────── + +/** + * Infer the PHP type of a call argument from its literal shape. + * Returns an empty string for non-literals (treated as "unknown" = any-match). + * Mirrors C#'s `inferArgType` helper. + */ +function inferPhpArgType(argNode: SyntaxNode): string { + // argument node wraps the actual expression + const expr = argNode.firstNamedChild ?? argNode; + switch (expr.type) { + case 'integer': + return 'int'; + case 'float': + return 'float'; + case 'string': + case 'encapsed_string': + case 'heredoc': + case 'nowdoc': + return 'string'; + case 'boolean': + case 'true': + case 'false': + return 'bool'; + case 'null': + return 'null'; + default: + return ''; + } +} + +// ─── PHPDoc synthesis ───────────────────────────────────────────────────────── + +/** PHP 8+ attribute_list nodes that appear between PHPDoc and method. */ +const SKIP_SIBLING_TYPES = new Set(['attribute_list', 'attribute', 'comment']); + +/** Regex for PHPDoc @param: standard `@param Type $name` */ +const PHPDOC_PARAM_RE = /@param\s+(\S+)\s+\$(\w+)/g; +/** Regex for PHPDoc @param: alternate `@param $name Type` */ +const PHPDOC_PARAM_ALT_RE = /@param\s+\$(\w+)\s+(\S+)/g; +/** Regex for PHPDoc @return: `@return Type` */ +const PHPDOC_RETURN_RE = /@return\s+(\S+)/; + +/** + * Normalize a PHP type string to a simple class name for binding purposes. + * Returns null for primitives or uninformative types. + * Mirrors `normalizePhpType` in `interpret.ts` but operates on raw PHPDoc strings. + */ +function normalizePhpDocType(raw: string): string | null { + let type = raw.trim(); + // Strip nullable prefix + if (type.startsWith('?')) type = type.slice(1).trim(); + // Strip array suffix: User[] → User + if (type.endsWith('[]')) type = type.slice(0, -2).trim(); + // Strip union with null/false/void + if (type.includes('|')) { + const parts = type + .split('|') + .map((p) => p.trim()) + .filter((p) => p !== 'null' && p !== 'false' && p !== 'void' && p !== 'mixed' && p !== ''); + if (parts.length !== 1) return null; + type = parts[0]; + } + // Strip intersection: take first part + if (type.includes('&')) { + const first = type.split('&')[0].trim(); + if (first === '') return null; + type = first; + } + // Strip generic wrapper: Collection → User + const genericMatch = type.match(/^\w[\w\\]*\s*<([^,<>]+)>$/); + if (genericMatch) { + type = genericMatch[1].trim(); + // Strip array suffix again inside generic + if (type.endsWith('[]')) type = type.slice(0, -2).trim(); + } + // Strip namespace qualifier: \App\Models\User → User + if (type.includes('\\')) { + const segs = type.split('\\').filter(Boolean); + type = segs[segs.length - 1] ?? type; + } + // Reject primitives + if (PHP_PRIMITIVES.has(type.toLowerCase())) return null; + // Must be a simple identifier + if (!/^\w+$/.test(type)) return null; + return type; +} + +const PHP_PRIMITIVES = new Set([ + 'int', + 'integer', + 'float', + 'double', + 'string', + 'bool', + 'boolean', + 'array', + 'object', + 'callable', + 'iterable', + 'null', + 'void', + 'never', + 'mixed', + 'false', + 'true', + 'self', + 'static', + 'parent', +]); + +/** + * Collect comment text from siblings immediately before `fnNode`. + * Skips PHP 8+ attribute_list nodes. + */ +function collectPrecedingComments(fnNode: SyntaxNode): string { + const texts: string[] = []; + let sibling = fnNode.previousSibling; + while (sibling !== null) { + if (sibling.type === 'comment') { + texts.unshift(sibling.text); + } else if (sibling.isNamed && !SKIP_SIBLING_TYPES.has(sibling.type)) { + break; + } + sibling = sibling.previousSibling; + } + return texts.join('\n'); +} + +/** + * Synthesize PHPDoc @param and @return type-binding captures for a + * method_declaration or function_definition node. + * + * PHPDoc @param Type $name → `@type-binding.parameter` match (anchored at fn body/return_type). + * PHPDoc @return Type → `@type-binding.return` match (anchored at fn name). + */ +function synthesizePhpDocBindings(fnNode: SyntaxNode): CaptureMatch[] { + if (fnNode.type !== 'method_declaration' && fnNode.type !== 'function_definition') return []; + + const commentBlock = collectPrecedingComments(fnNode); + if (commentBlock === '') return []; + + const out: CaptureMatch[] = []; + + // Anchor for parameter type-bindings: the function body (or return_type as fallback). + // The binding must be inside the function scope so it's visible to body statements. + const bodyNode = fnNode.childForFieldName('body'); + const anchorNode = bodyNode ?? fnNode; + + // ── @param annotations ──────────────────────────────────────────────────── + PHPDOC_PARAM_RE.lastIndex = 0; + let m: RegExpExecArray | null; + const seenParams = new Set(); + + while ((m = PHPDOC_PARAM_RE.exec(commentBlock)) !== null) { + const rawType = m[1]; + const paramName = '$' + m[2]; + const typeName = normalizePhpDocType(rawType); + if (typeName === null) continue; + seenParams.add(paramName); + out.push({ + '@type-binding.parameter': nodeToCapture('@type-binding.parameter', anchorNode), + '@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, paramName), + '@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeName), + }); + } + + // Also check alternate PHPDoc order: @param $name Type + PHPDOC_PARAM_ALT_RE.lastIndex = 0; + while ((m = PHPDOC_PARAM_ALT_RE.exec(commentBlock)) !== null) { + const paramName = '$' + m[1]; + if (seenParams.has(paramName)) continue; // standard format takes priority + const rawType = m[2]; + const typeName = normalizePhpDocType(rawType); + if (typeName === null) continue; + out.push({ + '@type-binding.parameter': nodeToCapture('@type-binding.parameter', anchorNode), + '@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, paramName), + '@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeName), + }); + } + + // ── @return annotation ──────────────────────────────────────────────────── + const returnMatch = PHPDOC_RETURN_RE.exec(commentBlock); + if (returnMatch !== null) { + const rawType = returnMatch[1]; + const typeName = normalizePhpDocType(rawType); + if (typeName !== null) { + // @return bindings must be anchored at the method name and hoisted to Module scope + // by phpBindingScopeFor (which checks for @type-binding.return presence). + // Use the function_definition/method_declaration node itself as the anchor — it + // coincides with the innermost scope's range, so auto-hoist kicks in. + const nameNode = fnNode.childForFieldName('name') ?? fnNode; + out.push({ + '@type-binding.return': nodeToCapture('@type-binding.return', fnNode), + '@type-binding.name': syntheticCapture('@type-binding.name', nameNode, nameNode.text), + '@type-binding.type': syntheticCapture('@type-binding.type', nameNode, typeName), + }); + } + } + + return out; +} + +// ─── Foreach synthesis ─────────────────────────────────────────────────────── + +/** + * Walk all `foreach_statement` nodes inside `fnNode` and synthesize + * `@type-binding.alias` captures binding the loop variable to the + * element type of the iterable. + * + * Supports: + * - `foreach ($users as $user)` — simple iterable variable + * - `foreach ($users as $k => $user)` — key→value pair + * - `foreach ($this->users as $user)` — member access iterable + * - `foreach (getUsers() as $user)` — NOT yet supported (needs return type) + * + * The element type is resolved by: + * 1. Looking up the iterable name in PHPDoc @param bindings already + * collected for this function (passed via typeBindingsByName). + * 2. Direct resolution when iterable's env type IS the element type + * (because PHPDoc normalizes `User[]` → `User` already). + */ +function synthesizeForeachBindings(fnNode: SyntaxNode): CaptureMatch[] { + if ( + fnNode.type !== 'method_declaration' && + fnNode.type !== 'function_definition' && + fnNode.type !== 'anonymous_function' && + fnNode.type !== 'arrow_function' + ) { + return []; + } + + const out: CaptureMatch[] = []; + + // Build a mini type map from the function's PHPDoc @param annotations. + // This is re-parsed here (not cached from synthesizePhpDocBindings) for simplicity; + // the cost is negligible given the small comment sizes. + const commentBlock = collectPrecedingComments(fnNode); + const paramTypeMap = buildParamTypeMap(commentBlock); + + // Walk the function body for foreach_statement nodes. + const bodyNode = fnNode.childForFieldName('body'); + if (bodyNode === null) return []; + collectForeachBindings(bodyNode, fnNode, paramTypeMap, out); + + return out; +} + +/** Build a map of `$paramName → elementTypeName` from PHPDoc @param in a comment block. */ +function buildParamTypeMap(commentBlock: string): Map { + const map = new Map(); + if (commentBlock === '') return map; + + PHPDOC_PARAM_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = PHPDOC_PARAM_RE.exec(commentBlock)) !== null) { + const rawType = m[1]; + const paramName = '$' + m[2]; + const typeName = normalizePhpDocType(rawType); + if (typeName !== null) map.set(paramName, typeName); + } + PHPDOC_PARAM_ALT_RE.lastIndex = 0; + while ((m = PHPDOC_PARAM_ALT_RE.exec(commentBlock)) !== null) { + const paramName = '$' + m[1]; + if (map.has(paramName)) continue; + const rawType = m[2]; + const typeName = normalizePhpDocType(rawType); + if (typeName !== null) map.set(paramName, typeName); + } + return map; +} + +/** + * Walk a subtree and collect foreach_statement bindings. + * Recursively descends into all child nodes. + */ +function collectForeachBindings( + node: SyntaxNode, + fnNode: SyntaxNode, + paramTypeMap: Map, + out: CaptureMatch[], +): void { + if (node.type === 'foreach_statement') { + const synth = synthesizeSingleForeach(node, fnNode, paramTypeMap); + if (synth !== null) out.push(synth); + } + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child !== null) { + collectForeachBindings(child, fnNode, paramTypeMap, out); + } + } +} + +/** + * Synthesize a single `@type-binding.alias` match for a `foreach_statement`. + * + * AST structure for foreach_statement (tree-sitter-php): + * foreach ( as ) + * Named children (excluding body): first = iterable, second = value or pair. + */ +function synthesizeSingleForeach( + foreachNode: SyntaxNode, + fnNode: SyntaxNode, + paramTypeMap: Map, +): CaptureMatch | null { + // Collect non-body named children: [iterable, value_or_pair] + const bodyNode = foreachNode.childForFieldName('body'); + const children: SyntaxNode[] = []; + for (let i = 0; i < foreachNode.namedChildCount; i++) { + const child = foreachNode.namedChild(i); + if (child !== null && child !== bodyNode) children.push(child); + } + if (children.length < 2) return null; + + const iterableNode = children[0]; + const valueOrPair = children[1]; + + // Determine the loop variable node + let loopVarNode: SyntaxNode; + if (valueOrPair.type === 'pair') { + // $key => $value — use the last named child of the pair + const lastChild = valueOrPair.namedChild(valueOrPair.namedChildCount - 1); + if (lastChild === null) return null; + loopVarNode = + lastChild.type === 'by_ref' ? (lastChild.firstNamedChild ?? lastChild) : lastChild; + } else { + loopVarNode = + valueOrPair.type === 'by_ref' ? (valueOrPair.firstNamedChild ?? valueOrPair) : valueOrPair; + } + + // Loop variable must be a variable_name + if (loopVarNode.type !== 'variable_name') return null; + const loopVarName = loopVarNode.text; // e.g. '$user' + + // Resolve the element type from the iterable + let elementType: string | null = null; + + if (iterableNode.type === 'variable_name') { + // foreach ($users as $user) — look up $users in param map + const iterableName = iterableNode.text; // e.g. '$users' + elementType = paramTypeMap.get(iterableName) ?? null; + } else if (iterableNode.type === 'member_access_expression') { + // foreach ($this->users as $user) — property name is the field + const propNameNode = iterableNode.childForFieldName('name'); + if (propNameNode !== null) { + // Property stored with $ prefix in paramTypeMap (rare for $this->prop patterns) + // Try both with and without $ prefix + const propKey = '$' + propNameNode.text; + elementType = paramTypeMap.get(propKey) ?? null; + if (elementType === null) { + // Try to find the property type from the enclosing class + elementType = findClassPropertyElementType(iterableNode, fnNode); + } + } + } else if (iterableNode.type === 'function_call_expression') { + // foreach (getUsers() as $user) — use the function name as a type alias. + // The function's @return annotation produces a @type-binding.return binding + // in the Module scope (e.g. getUsers → User). The scope-extractor's + // followChainedRef will resolve $user → getUsers → User. + const funcNode = iterableNode.childForFieldName('function'); + if (funcNode !== null && funcNode.type === 'name') { + elementType = funcNode.text; // e.g. 'getUsers' — chain will be resolved later + } + } else if (iterableNode.type === 'member_call_expression') { + // foreach ($this->getUsers() as $user) — use the method name as a type alias. + const methodNameNode = iterableNode.childForFieldName('name'); + if (methodNameNode !== null) { + elementType = methodNameNode.text; // e.g. 'getUsers' + } + } + + if (elementType === null) return null; + + // Anchor the binding inside the foreach body so it's scoped to the loop. + const anchorNode = bodyNode ?? foreachNode; + + return { + '@type-binding.alias': nodeToCapture('@type-binding.alias', anchorNode), + '@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, loopVarName), + '@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, elementType), + }; +} + +/** + * Try to find the element type for `$this->property` member access by walking + * up from the foreach to the enclosing class and scanning the property declaration. + */ +function findClassPropertyElementType( + memberAccessNode: SyntaxNode, + fnNode: SyntaxNode, +): string | null { + const propNameNode = memberAccessNode.childForFieldName('name'); + if (propNameNode === null) return null; + const propName = propNameNode.text; + + // Walk up from fnNode to find the enclosing class declaration + let cur: SyntaxNode | null = fnNode.parent; + while (cur !== null) { + if (cur.type === 'class_declaration' || cur.type === 'trait_declaration') { + break; + } + cur = cur.parent; + } + if (cur === null) return null; + + // Find the property_declaration with matching variable_name '$propName' + const declList = cur.childForFieldName('body'); + if (declList === null) return null; + + for (let i = 0; i < declList.namedChildCount; i++) { + const child = declList.namedChild(i); + if (child === null || child.type !== 'property_declaration') continue; + for (let j = 0; j < child.namedChildCount; j++) { + const elem = child.namedChild(j); + if (elem === null || elem.type !== 'property_element') continue; + const varNameNode = elem.firstNamedChild; + if (varNameNode === null || varNameNode.text !== '$' + propName) continue; + // Found the property — get its element type from @var PHPDoc or native type + return extractPropertyElementType(child); + } + } + return null; +} + +/** Regex for PHPDoc @var: `@var Type` */ +const PHPDOC_VAR_RE = /@var\s+(\S+)/; + +/** + * Extract element type from a property_declaration node: + * 1. PHPDoc @var annotation on a preceding comment sibling + * 2. PHP 7.4+ native type field (non-array) + */ +function extractPropertyElementType(propDecl: SyntaxNode): string | null { + // Strategy 1: PHPDoc @var on a preceding comment sibling + let sibling = propDecl.previousSibling; + while (sibling !== null) { + if (sibling.type === 'comment') { + const m = PHPDOC_VAR_RE.exec(sibling.text); + if (m !== null) return normalizePhpDocType(m[1]); + } else if (sibling.isNamed && !SKIP_SIBLING_TYPES.has(sibling.type)) { + break; + } + sibling = sibling.previousSibling; + } + // Strategy 2: native type field — skip generic 'array' + const typeNode = propDecl.childForFieldName('type'); + if (typeNode === null) return null; + const typeName = typeNode.text.trim(); + if (typeName === 'array' || typeName === '') return null; + return normalizePhpDocType(typeName); +} diff --git a/gitnexus/src/core/ingestion/languages/php/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/php/import-decomposer.ts new file mode 100644 index 000000000..f17456805 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/php/import-decomposer.ts @@ -0,0 +1,304 @@ +/** + * Decompose a PHP `namespace_use_declaration` into one or more + * `CaptureMatch` objects carrying the synthesized markers + * `@import.kind` / `@import.source` / `@import.name` / `@import.alias` + * that `interpretPhpImport` consumes. + * + * PHP import forms handled: + * + * use Foo\Bar; → namespace, localName=Bar + * use Foo\Bar as Baz; → alias, localName=Baz + * use function Foo\bar; → function, localName=bar + * use const Foo\BAR; → const, localName=BAR + * use Foo\{A, B as C}; → grouped: one match per clause + * use function Foo\{f, g as h}; → grouped function variants + * use const Foo\{X, Y as Z}; → grouped const variants + * + * Unlike C#'s decomposer this is 1:N — each grouped use_declaration + * fans out to one CaptureMatch per inner clause. + */ + +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; + +export type PhpImportKind = 'namespace' | 'alias' | 'function' | 'const'; + +interface PhpImportSpec { + readonly kind: PhpImportKind; + /** Full backslash-separated path (backslashes intact): `Foo\Bar\Baz`. */ + readonly source: string; + /** Local binding name — last source segment for plain imports, the + * alias identifier for aliased imports. */ + readonly name: string; + /** Present iff kind === 'alias'. */ + readonly alias?: string; + /** Anchor node for synthesized captures (range-wise). */ + readonly atNode: SyntaxNode; +} + +/** + * Decompose a `namespace_use_declaration` node into one `CaptureMatch` + * per logical import. Returns `[]` when the node is unrecognized or + * carries no resolvable clauses. + */ +export function splitNamespaceUseDeclaration(stmtNode: SyntaxNode): CaptureMatch[] { + if (stmtNode.type !== 'namespace_use_declaration') return []; + + // Detect qualifier keyword: `use function` / `use const` + // tree-sitter-php uses a `use_type` or `function`/`const` keyword + // child to distinguish them. We scan the raw text before the first + // backslash-path child. + const qualifier = detectQualifier(stmtNode); + + // Grouped use: `use Foo\{A, B as C}` — find namespace_use_group child. + const groupNode = findNamedChild(stmtNode, 'namespace_use_group'); + if (groupNode !== null) { + return decomposeGrouped(stmtNode, groupNode, qualifier); + } + + // Single use clause (possibly aliased). + const spec = parseSingleUseClause(stmtNode, qualifier); + if (spec === null) return []; + return [buildImportMatch(stmtNode, spec)]; +} + +// ── Qualifier detection ──────────────────────────────────────────────────── + +/** + * Return the qualifier keyword appearing after `use`: + * `'function'`, `'const'`, or `null` for plain namespace use. + * + * tree-sitter-php emits the qualifier as a `name` node with text + * "function" or "const" (not a keyword token in recent grammars), + * or as a dedicated `use_type` node. We inspect the node's raw text + * to be grammar-version-agnostic. + */ +function detectQualifier(node: SyntaxNode): PhpImportKind { + const raw = node.text; + // Match `use function` or `use const` at the start (after optional whitespace) + if (/^\s*use\s+function\s/i.test(raw)) return 'function'; + if (/^\s*use\s+const\s/i.test(raw)) return 'const'; + return 'namespace'; +} + +// ── Single clause parsing ────────────────────────────────────────────────── + +function parseSingleUseClause(node: SyntaxNode, qualifier: PhpImportKind): PhpImportSpec | null { + // A plain `namespace_use_declaration` has one or more + // `namespace_use_clause` named children (each clause is one import, + // comma-separated for multiple). For the single case there is one. + const clause = findNamedChild(node, 'namespace_use_clause'); + if (clause !== null) return parseUseClause(clause, qualifier); + + // Older grammar versions may put the qualified_name directly under + // the declaration node. Check for a qualified_name or name child. + const qualName = findNamedChild(node, 'qualified_name') ?? findNamedChild(node, 'name'); + if (qualName === null) return null; + const source = qualName.text.trim(); + if (source === '') return null; + return { + kind: qualifier, + source, + name: lastSegment(source), + atNode: node, + }; +} + +function parseUseClause(clause: SyntaxNode, qualifier: PhpImportKind): PhpImportSpec | null { + // namespace_use_clause: + // qualified_name (or name) + // optional: alias_clause → "as" name (some grammar versions) + // optional: bare name node (tree-sitter-php ≥ 0.22 emits the + // alias as a sibling `name` node + // directly, not inside alias_clause) + const qualName = findNamedChild(clause, 'qualified_name') ?? findNamedChild(clause, 'name'); + if (qualName === null) return null; + const source = qualName.text.trim(); + if (source === '') return null; + + // Strategy 1: explicit alias_clause wrapper (older grammar versions). + const aliasClause = findNamedChild(clause, 'alias_clause'); + if (aliasClause !== null) { + // alias_clause: "as" name + const aliasName = findNamedChild(aliasClause, 'name') ?? aliasClause.firstNamedChild; + const alias = aliasName?.text.trim() ?? ''; + if (alias === '') return null; + return { + kind: 'alias', + source, + name: alias, + alias, + atNode: clause, + }; + } + + // Strategy 2: bare sibling `name` node after the qualified_name. + // tree-sitter-php (≥ 0.22) emits `use Foo\Bar as Baz` as: + // namespace_use_clause + // qualified_name "Foo\Bar" + // name "Baz" ← alias, no alias_clause wrapper + // Detect by: clause has ≥2 named children AND the last named child is + // a `name` node that differs from the qualName node. + if (clause.namedChildCount >= 2) { + const lastChild = clause.namedChild(clause.namedChildCount - 1); + if (lastChild !== null && lastChild !== qualName && lastChild.type === 'name') { + const alias = lastChild.text.trim(); + if (alias !== '') { + return { + kind: 'alias', + source, + name: alias, + alias, + atNode: clause, + }; + } + } + } + + return { + kind: qualifier, + source, + name: lastSegment(source), + atNode: clause, + }; +} + +// ── Grouped use decomposition ────────────────────────────────────────────── + +/** + * Decompose `use Foo\Bar\{A, B as C, function f, const X}` into one + * `CaptureMatch` per inner clause. + * + * The leading prefix (`Foo\Bar`) is prepended to each inner path. + * Inner clauses can override the qualifier with their own `function` / + * `const` keyword inside the group. + */ +function decomposeGrouped( + stmtNode: SyntaxNode, + groupNode: SyntaxNode, + outerQualifier: PhpImportKind, +): CaptureMatch[] { + // The prefix is the qualified_name that precedes the `{...}` group. + const prefixNode = findNamedChild(stmtNode, 'qualified_name') ?? findNamedChild(stmtNode, 'name'); + const prefix = prefixNode?.text.trim() ?? ''; + + const out: CaptureMatch[] = []; + + for (let i = 0; i < groupNode.namedChildCount; i++) { + const child = groupNode.namedChild(i); + if (child === null) continue; + + // Each child in a group may be: + // namespace_use_clause — plain or aliased + // namespace_use_type — `function` or `const` qualifier inside group + // We detect an inline qualifier by checking the raw text of the clause. + if (child.type !== 'namespace_use_clause') continue; + + const innerQualifier = detectInnerQualifier(child) ?? outerQualifier; + const spec = parseInnerClause(child, prefix, innerQualifier); + if (spec !== null) { + out.push(buildImportMatch(stmtNode, spec)); + } + } + + return out; +} + +/** + * Detect an inline qualifier keyword inside a grouped clause. + * e.g. `use Foo\{function bar, const BAZ}` — each clause may start with + * `function` or `const`. + */ +function detectInnerQualifier(clause: SyntaxNode): PhpImportKind | null { + const raw = clause.text.trim(); + if (/^function\s/i.test(raw)) return 'function'; + if (/^const\s/i.test(raw)) return 'const'; + return null; +} + +function parseInnerClause( + clause: SyntaxNode, + prefix: string, + qualifier: PhpImportKind, +): PhpImportSpec | null { + const qualName = findNamedChild(clause, 'qualified_name') ?? findNamedChild(clause, 'name'); + if (qualName === null) return null; + + // Strip inline `function` / `const` text prefix if present in the text. + let innerPath = qualName.text.trim(); + innerPath = innerPath.replace(/^(?:function|const)\s+/i, '').trim(); + if (innerPath === '') return null; + + const source = prefix !== '' ? `${prefix}\\${innerPath}` : innerPath; + + // Strategy 1: explicit alias_clause wrapper (older grammar versions). + const aliasClause = findNamedChild(clause, 'alias_clause'); + if (aliasClause !== null) { + const aliasName = findNamedChild(aliasClause, 'name') ?? aliasClause.firstNamedChild; + const alias = aliasName?.text.trim() ?? ''; + if (alias === '') return null; + return { + kind: 'alias', + source, + name: alias, + alias, + atNode: clause, + }; + } + + // Strategy 2: bare sibling `name` node after the qualified_name (tree-sitter-php ≥ 0.22). + if (clause.namedChildCount >= 2) { + const lastChild = clause.namedChild(clause.namedChildCount - 1); + if (lastChild !== null && lastChild !== qualName && lastChild.type === 'name') { + const alias = lastChild.text.trim(); + if (alias !== '') { + return { + kind: 'alias', + source, + name: alias, + alias, + atNode: clause, + }; + } + } + } + + return { + kind: qualifier, + source, + name: lastSegment(innerPath), + atNode: clause, + }; +} + +// ── CaptureMatch builder ─────────────────────────────────────────────────── + +function buildImportMatch(stmtNode: SyntaxNode, spec: PhpImportSpec): CaptureMatch { + const m: Record = { + '@import.statement': nodeToCapture('@import.statement', stmtNode), + '@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind), + '@import.source': syntheticCapture('@import.source', spec.atNode, spec.source), + '@import.name': syntheticCapture('@import.name', spec.atNode, spec.name), + }; + if (spec.alias !== undefined) { + m['@import.alias'] = syntheticCapture('@import.alias', spec.atNode, spec.alias); + } + return m; +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** Last backslash-separated segment: `Foo\Bar\Baz` → `Baz`. */ +function lastSegment(path: string): string { + const parts = path.split('\\').filter(Boolean); + return parts[parts.length - 1] ?? path; +} + +/** Find the first named child with a given node type. */ +function findNamedChild(node: SyntaxNode, type: string): SyntaxNode | null { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child !== null && child.type === type) return child; + } + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/php/import-target.ts b/gitnexus/src/core/ingestion/languages/php/import-target.ts new file mode 100644 index 000000000..ebf7938b3 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/php/import-target.ts @@ -0,0 +1,140 @@ +/** + * Adapter from `(ParsedImport, WorkspaceIndex)` → concrete file path. + * + * Delegates to the existing `resolvePhpImportInternal` (PSR-4 via + * composer.json + suffix matching fallback). The `WorkspaceIndex` is + * opaque at this layer; consumers wire a `PhpResolveContext` shape + * carrying `fromFile` + `allFilePaths`. + * + * `loadPhpComposerConfig` is the `ScopeResolver.loadResolutionConfig` + * implementation — it loads `composer.json` once per workspace pass and + * threads the parsed config into every subsequent `resolveImportTarget` + * call via the opaque `resolutionConfig` parameter. + * + * Returning `null` lets the finalize algorithm mark the edge as + * `linkStatus: 'unresolved'`. + */ + +import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; +import { resolvePhpImportInternal } from '../../import-resolvers/php.js'; +import type { ComposerConfig } from '../../language-config.js'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export interface PhpResolveContext { + readonly fromFile: string; + readonly allFilePaths: ReadonlySet; +} + +// ─── loadResolutionConfig ────────────────────────────────────────────────── + +/** + * Load and parse `composer.json` from the repo root. Returns a + * `ComposerConfig` object (PSR-4 namespace → directory mappings) or + * `null` when no `composer.json` is present or it cannot be parsed. + * + * The result is threaded into each `resolvePhpImportInternal` call as + * the `composerConfig` argument. + */ +export function loadPhpComposerConfig(repoPath: string): ComposerConfig | null { + try { + const composerPath = join(repoPath, 'composer.json'); + const raw = readFileSync(composerPath, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== 'object' || parsed === null) return null; + + const composer = parsed as Record; + const autoload = composer['autoload'] as Record | undefined; + if (autoload === undefined) return null; + + const psr4Raw = (autoload['psr-4'] ?? {}) as Record; + const psr4 = new Map(); + + for (const [ns, dirs] of Object.entries(psr4Raw)) { + // namespace prefix ends with `\` — keep as-is; resolver strips it + const normalizedNs = ns.replace(/\\$/, ''); + const dir = Array.isArray(dirs) ? dirs[0] : dirs; + if (typeof dir === 'string') { + // Normalize directory path (strip trailing slash) + const normalizedDir = dir.replace(/\/+$/, ''); + psr4.set(normalizedNs, normalizedDir); + } + } + + return { psr4 }; + } catch { + return null; + } +} + +// ─── resolvePhpImportTarget ──────────────────────────────────────────────── + +/** + * LanguageProvider-shaped adapter: `(ParsedImport, WorkspaceIndex) → string | null`. + * + * The `WorkspaceIndex` is `unknown` in the shared contract. The scope-resolution + * orchestrator hands us a `PhpResolveContext`-shaped object; narrow structurally + * rather than via a cast chain so unexpected shapes return `null` cleanly. + */ +export function resolvePhpImportTarget( + parsedImport: ParsedImport, + workspaceIndex: WorkspaceIndex, +): string | null { + const ctx = workspaceIndex as PhpResolveContext | undefined; + if ( + ctx === undefined || + typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' || + !((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set) + ) { + return null; + } + if (parsedImport.kind === 'dynamic-unresolved') return null; + if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null; + + const allFiles = ctx.allFilePaths as Set; + const normalizedFileList = [...allFiles].map((f) => f.replace(/\\/g, '/')); + const allFileList = [...allFiles]; + + return resolvePhpImportInternal( + parsedImport.targetRaw, + null, // composerConfig not available through LanguageProvider path + allFiles, + normalizedFileList, + allFileList, + undefined, + ); +} + +/** + * ScopeResolver-shaped adapter: `(targetRaw, fromFile, allFilePaths, resolutionConfig?) → string | null`. + * + * Used inside `scope-resolver.ts`. Accepts the optional `resolutionConfig` + * (a `ComposerConfig | null` loaded once per workspace by + * `loadPhpComposerConfig`) and threads it into `resolvePhpImportInternal`. + */ +export function resolvePhpImportTargetInternal( + targetRaw: string, + _fromFile: string, + allFilePaths: ReadonlySet, + resolutionConfig?: unknown, +): string | null { + if (targetRaw === '') return null; + + const composerConfig = + resolutionConfig !== undefined && resolutionConfig !== null + ? (resolutionConfig as ComposerConfig) + : null; + + const allFiles = allFilePaths as Set; + const normalizedFileList = [...allFiles].map((f) => f.replace(/\\/g, '/')); + const allFileList = [...allFiles]; + + return resolvePhpImportInternal( + targetRaw, + composerConfig, + allFiles, + normalizedFileList, + allFileList, + undefined, + ); +} diff --git a/gitnexus/src/core/ingestion/languages/php/index.ts b/gitnexus/src/core/ingestion/languages/php/index.ts new file mode 100644 index 000000000..9b549bc3d --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/php/index.ts @@ -0,0 +1,73 @@ +/** + * PHP scope-resolution hooks (RFC #909 Ring 3 LANG-php, #938). + * + * Public API barrel. Consumers should import from this file rather than + * the individual modules. + * + * Module layout (each file is a single concern): + * + * - `query.ts` — tree-sitter query + lazy parser/query singletons + * - `captures.ts` — `emitPhpScopeCaptures` orchestrator + * - `import-decomposer.ts` — each `namespace_use_declaration` → ParsedImport captures + * - `interpret.ts` — capture-match → `ParsedImport` / `ParsedTypeBinding` + * - `simple-hooks.ts` — small/no-op hooks made explicit + * - `receiver-binding.ts` — synthesize `$this` / `parent` type-bindings on + * instance-method entry + * - `merge-bindings.ts` — PHP `use` precedence (local > import > wildcard) + * - `arity.ts` — PHP arity compatibility (variadic, defaults) + * - `arity-metadata.ts` — synthesize arity metadata from declarations + * - `import-target.ts` — `(ParsedImport, WorkspaceIndex) → file path` adapter + * wrapping `resolvePhpImportInternal` (PSR-4 + composer.json) + * - `scope-resolver.ts` — `ScopeResolver` registered in `SCOPE_RESOLVERS` + * - `cache-stats.ts` — PROF_SCOPE_RESOLUTION cache hit/miss counters + * + * ## Known limitations + * + * The PHP registry-primary path intentionally does NOT resolve the following. + * Each is a conscious trade-off at migration time. + * + * 1. **Trait `$this` → using-class binding** — for methods defined in a + * trait, `$this` is synthesized as a binding to the trait itself. + * Resolving `$this` to the actual using-class type requires cross-file + * analysis of all `use TraitName;` declarations in class bodies. + * Deferred to a follow-up; trait method resolution falls back to the + * trait scope. + * + * 2. **Anonymous classes** — `new class extends Foo { }` have no stable + * class name and are skipped by receiver-binding synthesis. The class + * body is still scoped; member lookups inside it will fall back to + * free-call resolution. + * + * 3. **Dynamic property/method access** — `$obj->{$name}()` and + * `$$varName` are not followed. The dynamic receiver is ignored and + * the call falls through to the shared free-call resolver. + * + * 4. **Magic methods** — `__get`, `__set`, `__call`, `__callStatic` are + * not modeled as virtual dispatch; they appear as regular method + * declarations in the graph but calls that would route through them + * at runtime are not distinguished. + * + * 5. **Laravel facade magic** — `App::make(...)`, `Cache::get(...)` etc. + * resolve statically to the Facade class rather than the underlying + * bound implementation. Deferred to a Laravel-specific plugin. + * + * 6. **Intersection types in parameters** — `T&U $param` takes the first + * named part (`T`). This matches the legacy type-extractor's behavior. + * + * Shadow-harness corpus parity is the authoritative signal for which of + * these matter in practice. The CI parity gate blocks any PR that regresses + * either the legacy or registry-primary run of + * `test/integration/resolvers/php.test.ts`. + */ + +export { emitPhpScopeCaptures } from './captures.js'; +export { getPhpCaptureCacheStats, resetPhpCaptureCacheStats } from './cache-stats.js'; +export { interpretPhpImport, interpretPhpTypeBinding } from './interpret.js'; +export { phpMergeBindings } from './merge-bindings.js'; +export { phpArityCompatibility } from './arity.js'; +export { resolvePhpImportTarget, type PhpResolveContext } from './import-target.js'; +export { phpBindingScopeFor, phpImportOwningScope, phpReceiverBinding } from './simple-hooks.js'; +// NOTE: phpScopeResolver is intentionally NOT re-exported from this barrel. +// Importing it here would create a circular dependency: +// php.ts → php/index.js → php/scope-resolver.js → ../php.js +// Registry and other consumers must import directly from './php/scope-resolver.js'. diff --git a/gitnexus/src/core/ingestion/languages/php/interpret.ts b/gitnexus/src/core/ingestion/languages/php/interpret.ts new file mode 100644 index 000000000..8aa07a736 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/php/interpret.ts @@ -0,0 +1,250 @@ +/** + * Capture-match → semantic-shape interpreters for PHP. + * + * - `interpretPhpImport` → `ParsedImport` + * - `interpretPhpTypeBinding` → `ParsedTypeBinding` + * + * Import matches arrive pre-decomposed by `emitPhpScopeCaptures` (one + * CaptureMatch per logical import, with synthesized `@import.kind / + * source / name / alias` markers). Type-binding matches arrive from + * the raw query captures — each `@type-binding.*` anchor carries + * `@type-binding.name` + `@type-binding.type`. + */ + +import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared'; + +// ─── interpretImport ────────────────────────────────────────────────────── + +export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null { + const kindCap = captures['@import.kind']; + const sourceCap = captures['@import.source']; + const nameCap = captures['@import.name']; + const aliasCap = captures['@import.alias']; + + const kind = kindCap?.text; + if (kind === undefined || sourceCap === undefined) return null; + + const source = sourceCap.text.trim(); + if (source === '') return null; + + switch (kind) { + case 'namespace': { + // `use Foo\Bar;` — PHP `use` is a NAMED import (binds the class + // `Bar`, not the namespace `Foo`). This differs from C# `using`, + // which is a true namespace import. Producing 'named' here makes + // `new Bar()` resolve to the imported class def. + const localName = nameCap?.text.trim() ?? lastSegment(source); + return { + kind: 'named', + localName, + importedName: localName, + targetRaw: source, + }; + } + case 'alias': { + // `use Foo\Bar as Baz;` + if (aliasCap === undefined) return null; + const alias = aliasCap.text.trim(); + if (alias === '') return null; + const importedName = lastSegment(source); + return { + kind: 'alias', + localName: alias, + importedName, + alias, + targetRaw: source, + }; + } + case 'function': { + // `use function Foo\bar;` — treat as named import; importedName is + // the function name (last segment). targetRaw is the full path. + const localName = nameCap?.text.trim() ?? lastSegment(source); + return { + kind: 'named', + localName, + importedName: localName, + targetRaw: source, + }; + } + case 'const': { + // `use const Foo\BAR;` — same shape as function. + const localName = nameCap?.text.trim() ?? lastSegment(source); + return { + kind: 'named', + localName, + importedName: localName, + targetRaw: source, + }; + } + default: + return null; + } +} + +// ─── interpretTypeBinding ───────────────────────────────────────────────── + +export function interpretPhpTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null { + const nameCap = captures['@type-binding.name']; + const typeCap = captures['@type-binding.type']; + if (nameCap === undefined || typeCap === undefined) return null; + + // Determine source from anchor captures. Order: most-specific first. + 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.annotation'] !== undefined) source = 'annotation'; + else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred'; + else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation'; + + let rawType: string | null; + + if (source === 'assignment-inferred') { + // `@type-binding.alias` captures cover several assignment RHS shapes: + // - `$alias = $u` → rawType = '$u' (variable alias) + // - `$u = getUser()` → rawType = 'getUser' (callable alias) + // - `$u = new User()` → rawType = 'User' (constructor — via @type-binding.constructor; handled below) + // - `$role = UserRole::Viewer` → rawType = 'UserRole' (enum/class constant) + // + // For variable aliases (`$u`), `normalizePhpType` returns null because + // `$` is not a word character. We must preserve the raw `$`-prefixed name + // so `followChainedRef` can walk the chain `$alias → $u → User`. + // For callable/class names, `normalizePhpType` strips qualifiers correctly. + const rawText = typeCap.text.trim(); + if (rawText.startsWith('$')) { + // Variable alias: keep as-is for chain-following. + rawType = rawText; + } else { + rawType = normalizePhpType(rawText); + } + } else { + // All other sources: strip PHP type decoration to get the simple class name: + // ?User → User (nullable prefix) + // User|null → User (union with null/false/void) + // User&Loggable → User (intersection — take first meaningful) + // Collection → User (PHPDoc generic wrapper) + // User[] → User (array suffix) + // \App\Models\User → User (backslash qualifier) + rawType = normalizePhpType(typeCap.text.trim()); + } + + if (rawType === null) return null; + + // PHP variable names include the `$` sigil (e.g. `$user`). Most + // bindings keep it because they are looked up via the variable + // (`$user->method()` finds binding `$user`). Property field bindings + // are different: `$user->address` looks up `address` (no sigil) on + // the User class. Property declarations carry source `'annotation'`, + // so we strip the leading `$` for that source only. + let boundName = nameCap.text.trim(); + if (source === 'annotation' && boundName.startsWith('$')) { + boundName = boundName.slice(1); + } + + return { boundName, rawTypeName: rawType, source }; +} + +// ─── Type normalization ─────────────────────────────────────────────────── + +/** + * Normalize a PHP type string to a simple class identifier, or `null` + * when the type is uninformative (primitive, void, mixed, self, etc.). + * + * Rules applied in order: + * 1. Strip nullable prefix `?` + * 2. Split on `|` (union) — keep only if exactly one non-null part + * 3. Take first part of `&` intersection + * 4. Strip array suffix `[]` + * 5. Strip generic wrapper `Collection` → `User` + * 6. Canonicalize leading backslash off: `\App\Models\User` → `App\Models\User` + * 7. Reject PHP primitive / pseudo types + * + * The qualified form is preserved on `TypeRef.rawName` so downstream PHP + * receiver resolution can distinguish `\App\Other\User` from a same-simple-name + * `User` reachable via `use`. Without this, fully-qualified type hints collapse + * to ambiguous simple names and resolve against the caller's scope chain + * instead of the explicit target the source named (Codex PR #1497 review, + * finding 1). + */ +export function normalizePhpType(raw: string): string | null { + // 1. Strip nullable prefix + let type = raw.startsWith('?') ? raw.slice(1).trim() : raw; + + // 2. Union type — keep only if one non-null/false/void part remains + if (type.includes('|')) { + const parts = type + .split('|') + .map((p) => p.trim()) + .filter((p) => p !== 'null' && p !== 'false' && p !== 'void' && p !== 'mixed' && p !== ''); + if (parts.length !== 1) return null; + type = parts[0]; + } + + // 3. Intersection type — take the first part + if (type.includes('&')) { + const first = type.split('&')[0].trim(); + if (first === '') return null; + type = first; + } + + // 4. Strip array suffix + if (type.endsWith('[]')) type = type.slice(0, -2).trim(); + + // 5. Strip single-arg generic wrapper: Collection → User + // Qualified inner types (Collection<\App\Models\User>) survive — the + // capture group preserves whatever the writer named. + const genericMatch = type.match(/^\w[\w\\]*\s*<([^,<>]+)>$/); + if (genericMatch) { + type = genericMatch[1].trim(); + } + + // 6. Canonicalize leading backslash off — keep the qualified path intact. + // `\App\Models\User` → `App\Models\User`. `App\Models\User` → unchanged. + // Unqualified `User` stays as `User`. The qualified form is the lookup + // key into the workspace QualifiedNameIndex (PHP defs are indexed by + // namespace-joined qualifiedName); the leading-backslash distinction in + // source is only an "absolute path" anchor, not part of the canonical key. + if (type.startsWith('\\')) type = type.replace(/^\\+/, ''); + + // 7. Reject primitives / pseudo-types + if (isPrimitiveOrPseudo(type)) return null; + + // Must be a (possibly qualified) PHP identifier — segments of word chars + // separated by single backslashes. Empty segments (consecutive backslashes, + // trailing backslash) are rejected. + if (!/^\w+(?:\\\w+)*$/.test(type)) return null; + + return type; +} + +const PHP_PRIMITIVE_TYPES = new Set([ + 'int', + 'integer', + 'float', + 'double', + 'string', + 'bool', + 'boolean', + 'array', + 'object', + 'callable', + 'iterable', + 'null', + 'void', + 'never', + 'mixed', + 'false', + 'true', + 'self', + 'static', + 'parent', +]); + +function isPrimitiveOrPseudo(type: string): boolean { + return PHP_PRIMITIVE_TYPES.has(type.toLowerCase()); +} + +/** Last backslash-separated segment: `Foo\Bar\Baz` → `Baz`. */ +function lastSegment(path: string): string { + const parts = path.split('\\').filter(Boolean); + return parts[parts.length - 1] ?? path; +} diff --git a/gitnexus/src/core/ingestion/languages/php/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/php/merge-bindings.ts new file mode 100644 index 000000000..257496550 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/php/merge-bindings.ts @@ -0,0 +1,51 @@ +/** + * PHP shadowing precedence for the `mergeBindings` hook. + * + * Tier ranking (lower wins in shadowing): + * + * - 0: `local` — a class member, method, local variable, or parameter + * declared in this scope. + * - 1: `import` / `namespace` / `reexport` — `use Foo\Bar;`, + * `use Foo\Bar as Baz;`, `use function`, `use const`. + * All use-statement flavors that introduce a name sit at this tier. + * - 2: `wildcard` — grouped uses / wildcard imports (deferred; mapped + * here for completeness). + * + * Within a surviving tier we de-dup by `DefId`, last-write-wins so a + * `use` re-declared further down the file cleanly replaces the earlier + * binding. + */ + +import type { BindingRef } from 'gitnexus-shared'; + +const TIER_LOCAL = 0; +const TIER_IMPORT = 1; +const TIER_WILDCARD = 2; +const TIER_UNKNOWN = 3; + +function tierOf(b: BindingRef): number { + switch (b.origin) { + case 'local': + return TIER_LOCAL; + case 'reexport': + case 'import': + case 'namespace': + return TIER_IMPORT; + case 'wildcard': + return TIER_WILDCARD; + default: + return TIER_UNKNOWN; + } +} + +export function phpMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] { + if (bindings.length === 0) return bindings; + + let bestTier = Number.POSITIVE_INFINITY; + for (const b of bindings) bestTier = Math.min(bestTier, tierOf(b)); + const survivors = bindings.filter((b) => tierOf(b) === bestTier); + + const seen = new Map(); + for (const b of survivors) seen.set(b.def.nodeId, b); + return [...seen.values()]; +} diff --git a/gitnexus/src/core/ingestion/languages/php/namespace-siblings.ts b/gitnexus/src/core/ingestion/languages/php/namespace-siblings.ts new file mode 100644 index 000000000..20b99d971 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/php/namespace-siblings.ts @@ -0,0 +1,335 @@ +/** + * PHP same-namespace cross-file visibility. + * + * In PHP, every class declared in `namespace Foo\Bar` is visible to all + * other files in the same namespace WITHOUT an explicit `use` statement. + * Without this pass, `Service.php` (namespace `App\Services`) can't see + * `User` declared in `Models.php` (namespace `App\Models`) unless + * `UserService.php` has an explicit `use App\Models\User` statement. + * + * More importantly, A.php (namespace `App\Models`) can return `Greeting` + * (same namespace `App\Models`) without importing it, and the compound- + * receiver resolver needs to find `Greeting` as a class binding in the + * scope chain. + * + * Implementation mirrors C#'s `namespace-siblings.ts`: + * 1. Extract the declared namespace from each PHP file's source. + * 2. Group class-like defs by namespace. + * 3. Inject sibling class defs into each file's Module scope's + * `bindingAugmentations` with `origin: 'namespace'`. + * 4. Also mirror return-type bindings from same-namespace siblings + * so cross-file chain-follow finds return types without explicit imports. + * + * Uses the PHP tree-sitter parser (via the lazy singleton in `query.ts`) + * to extract namespace declarations — same AST that `extractParsedFile` + * already parsed, reused via `treeCache` to avoid double-parsing. + */ + +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 ────────────────────────────────────────── + +interface PhpFileStructure { + /** The declared namespace (backslash-separated), or '' for global namespace. */ + readonly namespace: string; +} + +type PhpTree = ReturnType['parse']>; + +/** + * Extract the declared namespace from a PHP file's source. + * Uses the cached AST tree when available to avoid re-parsing. + */ +function extractPhpFileStructure(content: string, cachedTree: unknown): PhpFileStructure { + const tree = + (cachedTree as PhpTree | undefined) ?? + parseSourceSafe(getPhpParser(), content, undefined, { + bufferSize: getTreeSitterBufferSize(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; + for (let i = 0; i < root.namedChildCount; i++) { + const child = root.namedChild(i); + if (child === null) continue; + if (child.type === 'namespace_definition') { + const nameNode = child.childForFieldName('name'); + if (nameNode !== null) { + return { namespace: nameNode.text }; + } + } + } + + return { namespace: '' }; +} + +// ─── Augmentation bucket helper ───────────────────────────────────────────── + +function getAugmentationBucket( + augmentations: Map>, + scopeId: ScopeId, + name: string, +): BindingRef[] { + let scopeBindings = augmentations.get(scopeId); + if (scopeBindings === undefined) { + scopeBindings = new Map(); + augmentations.set(scopeId, scopeBindings); + } + let bucket = scopeBindings.get(name); + if (bucket === undefined) { + bucket = []; + scopeBindings.set(name, bucket); + } + return bucket; +} + +function isClassLikeDef(def: SymbolDefinition): boolean { + return ( + def.type === 'Class' || + def.type === 'Interface' || + def.type === 'Struct' || + def.type === 'Enum' || + def.type === 'Trait' + ); +} + +// ─── Public entry point ────────────────────────────────────────────────────── + +export interface PhpSiblingInputs { + readonly fileContents: ReadonlyMap; + readonly treeCache?: { get(filePath: string): unknown }; +} + +/** + * Side-channel cache populated by `populatePhpNamespaceSiblings` so that + * later visibility-check hooks (e.g., `isCallableVisibleFromCaller`) can + * look up a file's PHP namespace without re-parsing. Cleared at the start + * of every populate run so stale entries don't leak across resolutions. + */ +const namespaceByFilePath = new Map(); + +/** + * Read the cached PHP namespace for a given filePath. Returns `''` (global) + * when the file has no namespace_definition or hasn't been processed yet. + * Callers should only consult this AFTER either `populatePhpClassQualifiedNames` + * or `populatePhpNamespaceSiblings` has run for the current resolution. + */ +export function getPhpNamespaceForFile(filePath: string): string { + return namespaceByFilePath.get(filePath) ?? ''; +} + +/** + * Inject same-namespace class defs and return-type bindings into each + * PHP file's Module scope's `bindingAugmentations`. This makes classes + * in the same PHP namespace visible to each other without explicit `use` + * statements, mirroring PHP's actual runtime behavior. + * + * Uses `origin: 'namespace'` so `phpMergeBindings` tiers it below + * explicit `use` imports (`origin: 'import'`) and local declarations. + */ +export function populatePhpNamespaceSiblings( + parsedFiles: readonly ParsedFile[], + indexes: ScopeResolutionIndexes, + inputs: PhpSiblingInputs, +): void { + // Step 1: extract namespace structure for each file. Also seed the + // side-channel cache used by visibility-check hooks downstream. + namespaceByFilePath.clear(); + const structureByFile = new Map(); + for (const parsed of parsedFiles) { + const content = inputs.fileContents.get(parsed.filePath); + if (content === undefined) continue; + const cachedTree = inputs.treeCache?.get(parsed.filePath); + const struct = extractPhpFileStructure(content, cachedTree); + structureByFile.set(parsed.filePath, struct); + namespaceByFilePath.set(parsed.filePath, struct.namespace); + } + + // Step 2: group class-like defs and module scopes by namespace. + interface NamespaceBucket { + readonly scopes: { filePath: string; scopeId: ScopeId; scope: Scope }[]; + readonly classDefs: SymbolDefinition[]; + } + const buckets = new Map(); + const getBucket = (ns: string): NamespaceBucket => { + let b = buckets.get(ns); + if (b === undefined) { + b = { scopes: [], classDefs: [] }; + buckets.set(ns, b); + } + return b; + }; + + for (const parsed of parsedFiles) { + const struct = structureByFile.get(parsed.filePath); + if (struct === undefined) continue; + const ns = struct.namespace; + const bucket = getBucket(ns); + + // Register the file's module scope in the bucket. + const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); + if (moduleScope !== undefined) { + bucket.scopes.push({ + filePath: parsed.filePath, + scopeId: moduleScope.id, + scope: moduleScope, + }); + } + + // Collect class-like defs declared at the top-level of this file + // (defs in Class or Module scopes, excluding nested inner classes). + for (const scope of parsed.scopes) { + if (scope.kind !== 'Class') continue; + // Only top-level class scopes (parent is Module or Namespace scope). + if (scope.parent === null) continue; + const parentScope = parsed.scopes.find((s) => s.id === scope.parent); + if ( + parentScope === undefined || + (parentScope.kind !== 'Module' && parentScope.kind !== 'Namespace') + ) { + continue; + } + for (const def of scope.ownedDefs) { + if (isClassLikeDef(def)) { + bucket.classDefs.push(def); + break; // one class-like per scope + } + } + } + } + + const augmentations = indexes.bindingAugmentations as Map>; + + // Step 3: For each namespace bucket, inject sibling class bindings + // into every file's Module scope (that is NOT the declaring file). + for (const [, bucket] of buckets) { + // Build name → def map (simple name of qualifiedName). + const defsByName = new Map(); + for (const def of bucket.classDefs) { + const q = def.qualifiedName ?? ''; + const simpleName = q.includes('.') + ? q.slice(q.lastIndexOf('.') + 1) + : q.includes('\\') + ? q.slice(q.lastIndexOf('\\') + 1) + : q; + if (simpleName === '') continue; + const arr = defsByName.get(simpleName) ?? []; + arr.push(def); + defsByName.set(simpleName, arr); + } + + for (const { filePath, scopeId, scope } of bucket.scopes) { + for (const [name, defs] of defsByName) { + // Skip if already locally declared (origin: 'local' wins). + const local = scope.bindings.get(name); + if (local !== undefined && local.some((b) => b.origin === 'local')) continue; + + for (const def of defs) { + if (def.filePath === filePath) continue; // don't self-inject + const arr = getAugmentationBucket(augmentations, scopeId, name); + if (arr.some((b) => b.def.nodeId === def.nodeId)) continue; + arr.push({ def, origin: 'namespace' }); + } + } + } + } + + // 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. + // + // 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' }); + } + } + } + + // Step 4: Mirror return-type bindings from same-namespace sibling files. + // This enables chain-follow like `$c->greet()->save()` where `greet()` + // returns `Greeting` (declared in A.php, same namespace) and `Greeting` + // isn't imported in the calling file. Without this, the compound-receiver + // resolver can't resolve `Greeting` as a class binding in the importer's + // scope chain. + // + // Additionally, mirror from files that are imported via `use` (different + // namespace) so return types from dependencies are chain-followable too. + for (const parsed of parsedFiles) { + const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); + if (moduleScope === undefined) continue; + const moduleTypeBindings = moduleScope.typeBindings as Map< + string, + import('gitnexus-shared').TypeRef + >; + + const struct = structureByFile.get(parsed.filePath); + const ownNs = struct?.namespace ?? ''; + + // Collect namespaces accessible from this file: + // 1. Own namespace (same-ns siblings) + // 2. Namespaces of directly imported files (via parsedImports → targetRaw → PSR-4 namespace) + const accessibleFiles = new Set(); + + // Same-namespace siblings. + const sameBucket = buckets.get(ownNs); + if (sameBucket !== undefined) { + for (const { filePath } of sameBucket.scopes) { + if (filePath !== parsed.filePath) accessibleFiles.add(filePath); + } + } + + // Files directly imported by this file (finalized import edges). + const ownModuleScopeBindings = indexes.bindings.get(moduleScope.id); + if (ownModuleScopeBindings !== undefined) { + for (const [, refs] of ownModuleScopeBindings) { + for (const ref of refs) { + if (ref.origin === 'import' || ref.origin === 'namespace') { + const importFilePath = ref.def.filePath; + if (importFilePath !== parsed.filePath) { + accessibleFiles.add(importFilePath); + } + } + } + } + } + + // Mirror return-type bindings from accessible files. + for (const srcFilePath of accessibleFiles) { + const srcParsed = parsedFiles.find((p) => p.filePath === srcFilePath); + if (srcParsed === undefined) continue; + const srcModuleScope = srcParsed.scopes.find((s) => s.kind === 'Module'); + if (srcModuleScope === undefined) continue; + for (const [boundName, typeRef] of srcModuleScope.typeBindings) { + if (moduleTypeBindings.has(boundName)) continue; + moduleTypeBindings.set(boundName, typeRef); + } + } + } +} diff --git a/gitnexus/src/core/ingestion/languages/php/query.ts b/gitnexus/src/core/ingestion/languages/php/query.ts new file mode 100644 index 000000000..fa84c911f --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/php/query.ts @@ -0,0 +1,332 @@ +/** + * Tree-sitter query for PHP scope captures (RFC #909 Ring 3 LANG-php). + * + * Captures the structural skeleton the generic scope-resolution pipeline + * consumes: scopes (program/namespace/class/function), declarations + * (class-likes, method-likes, properties, variables), imports + * (namespace_use_declaration), type bindings (parameter annotations, + * property types, constructor-inferred locals, return types), and + * references (call sites, member writes). + * + * PHP specifics that shape this query: + * + * - `namespace_use_declaration` is an import only at top level / inside + * namespace blocks. Class-body `use_declaration` (trait-use) is a + * different node type and is NOT captured here. + * + * - `object_creation_expression` has `name` and `qualified_name` as + * direct children (no wrapping node). + * + * - `method_declaration` exposes a `return_type:` named field containing + * a `type` node, which may be `named_type`, `optional_type`, etc. + * + * - `property_element` has a `name:` field of type `variable_name`. + * + * - `variable_name` nodes always include the `$` sigil in their text. + * + * Exposes lazy `Parser` and `Query` singletons so callers don't pay + * tree-sitter init cost per file. + */ + +import Parser from 'tree-sitter'; +import Php from 'tree-sitter-php'; + +// tree-sitter-php exports `{ php, php_only, html }` in recent versions, or the +// language directly in older versions. +// +// IMPORTANT: must match the grammar used by the central parse phase +// (`src/core/tree-sitter/parser-loader.ts` line: `[SupportedLanguages.PHP]: PHP.php_only`). +// Using a different grammar variant causes tree-sitter to throw when running +// a query built against grammar A on a tree parsed by grammar B — this error +// is swallowed by `scope-extractor-bridge.ts`, producing silent empty results. +const Php_typed = Php as unknown as { php_only?: unknown; php?: unknown }; +const PHP_LANG = Php_typed.php_only ?? Php_typed.php ?? Php; + +const PHP_SCOPE_QUERY = ` +;; ── Scopes ──────────────────────────────────────────────────────────────── + +(program) @scope.module + +;; Both block-scoped and statement-scoped namespace declarations. +(namespace_definition) @scope.namespace + +(class_declaration) @scope.class +(interface_declaration) @scope.class +(trait_declaration) @scope.class +(enum_declaration) @scope.class + +(method_declaration) @scope.function +(function_definition) @scope.function +(anonymous_function) @scope.function +(arrow_function) @scope.function + +;; ── Declarations — types ────────────────────────────────────────────────── + +(class_declaration + name: (name) @declaration.name) @declaration.class + +(interface_declaration + name: (name) @declaration.name) @declaration.interface + +(trait_declaration + name: (name) @declaration.name) @declaration.trait + +(enum_declaration + name: (name) @declaration.name) @declaration.enum + +;; ── Declarations — methods / functions / constructors ───────────────────── + +(method_declaration + name: (name) @declaration.name) @declaration.method + +(function_definition + name: (name) @declaration.name) @declaration.function + +;; ── Declarations — properties ───────────────────────────────────────────── + +;; PHP 7.4+ typed property: private UserRepo $repo; +;; property_element has name: (variable_name) field. +;; Emits BOTH a declaration (so SemanticModel registers the property) AND a type-binding. +(property_declaration + type: (_) @type-binding.type + (property_element + name: (variable_name) @type-binding.name)) @type-binding.annotation + +(property_declaration + type: (_) + (property_element + name: (variable_name) @declaration.name)) @declaration.property + +;; Untyped property: public $id; — capture as plain declaration. +(property_declaration + (property_element + name: (variable_name) @declaration.name)) @declaration.variable + +;; ── Imports — namespace_use_declaration ─────────────────────────────────── +;; +;; Captures ALL forms: plain, alias, function/const qualifiers, and grouped. +;; The import-decomposer in captures.ts fans out grouped uses. +;; +;; NOTE: class-body use_declaration = trait-use, NOT an import. +;; Only namespace_use_declaration (top-level / namespace scope) is an import. + +(namespace_use_declaration) @import.statement + +;; ── Type bindings — parameters ──────────────────────────────────────────── + +;; simple_parameter with a type hint: function f(User $u) +;; type field is a 'type' supertype (named_type, optional_type, union_type, etc.) +(simple_parameter + type: (_) @type-binding.type + name: (variable_name) @type-binding.name) @type-binding.parameter + +;; property_promotion_parameter: function __construct(private User $u) +;; Emits type-binding so the constructor body can resolve $u as the typed param. +(property_promotion_parameter + type: (_) @type-binding.type + name: (variable_name) @type-binding.name) @type-binding.parameter + +;; Also emit a @type-binding.annotation for the promoted parameter so that +;; phpBindingScopeFor can hoist it to the Class scope (stripping the $ sigil). +;; This enables compound-receiver resolution: $user->address->save() resolves +;; address → Address via the Class scope's typeBindings. +;; The @type-binding.parameter above stays for constructor-body resolution ($address). +(property_promotion_parameter + type: (_) @type-binding.type + name: (variable_name) @type-binding.name) @type-binding.annotation + +;; Also emit a @declaration.property so SemanticModel registers the promoted +;; parameter as a class-owned property (enabling $obj->propName lookups). +(property_promotion_parameter + name: (variable_name) @declaration.name) @declaration.property + +;; ── Type bindings — local assignment: $u = new User() ───────────────────── + +;; new ClassName() — name is a direct child of object_creation_expression +(assignment_expression + left: (variable_name) @type-binding.name + right: (object_creation_expression + (name) @type-binding.type)) @type-binding.constructor + +;; new Foo\Bar\ClassName() — qualified_name wraps name +(assignment_expression + left: (variable_name) @type-binding.name + right: (object_creation_expression + (qualified_name + (name) @type-binding.type))) @type-binding.constructor + +;; ── Type bindings — $alias = $u (identifier alias) ─────────────────────── + +(assignment_expression + left: (variable_name) @type-binding.name + right: (variable_name) @type-binding.type) @type-binding.alias + +;; ── Type bindings — $u = factory() (free call return alias) ────────────── + +(assignment_expression + left: (variable_name) @type-binding.name + right: (function_call_expression + function: (name) @type-binding.type)) @type-binding.alias + +;; ── Type bindings — $u = $svc->getUser() (method call return alias) ─────── + +(assignment_expression + left: (variable_name) @type-binding.name + right: (member_call_expression + name: (name) @type-binding.type)) @type-binding.alias + +;; ── Type bindings — method return type ─────────────────────────────────── + +;; method_declaration exposes return_type: field (type node supertype). +;; named_type wraps the class name: function getUser(): User +(method_declaration + name: (name) @type-binding.name + return_type: (named_type + (name) @type-binding.type)) @type-binding.return + +;; nullable return type via optional_type: function getUser(): ?User +(method_declaration + name: (name) @type-binding.name + return_type: (optional_type + (named_type + (name) @type-binding.type))) @type-binding.return + +;; function_definition (top-level or namespace-level) return type: User +;; Enables cross-file return-type propagation for free functions. +(function_definition + name: (name) @type-binding.name + return_type: (named_type + (name) @type-binding.type)) @type-binding.return + +;; nullable return type for function_definition: ?User +(function_definition + name: (name) @type-binding.name + return_type: (optional_type + (named_type + (name) @type-binding.type))) @type-binding.return + +;; ── References — free calls: foo() ─────────────────────────────────────── + +(function_call_expression + function: (name) @reference.name) @reference.call.free + +;; ── References — member calls: $obj->method() ──────────────────────────── +;; +;; SAFETY-INVARIANT (Finding 1 of PR #1497 adversarial review): the name: +;; field is constrained to (name), NOT (_) — tree-sitter-php emits +;; variable_name nodes for dynamic method names ($obj->$method(), +;; $obj->{$method}()). Keeping the pattern at (name) is what suppresses +;; capture of those dynamic shapes. The resolver is structural-only and +;; cannot infer the bound method name from runtime values; relaxing this +;; pattern to (_) would silently emit zero-confidence false-positive +;; edges. Regression: test/fixtures/lang-resolution/php-dynamic-calls/. + +(member_call_expression + object: (_) @reference.receiver + name: (name) @reference.name) @reference.call.member + +;; ── References — null-safe member calls: $obj?->method() (PHP 8+) ───────── + +(nullsafe_member_call_expression + object: (_) @reference.receiver + name: (name) @reference.name) @reference.call.member + +;; ── References — static calls: X::method() ─────────────────────────────── +;; +;; Same SAFETY-INVARIANT as member_call_expression above: name: (name) +;; deliberately excludes variable_name so Class::$method() and +;; $className::$method() shapes do not capture. The receiver field uses +;; (_) because static dispatch on a variable receiver +;; ($className::method()) IS captured — but resolution falls through +;; harmlessly when $className has no class type binding. See +;; php-dynamic-calls/ regression suite. + +(scoped_call_expression + scope: (_) @reference.receiver + name: (name) @reference.name) @reference.call.member + +;; ── Type bindings — $x = X::Constant or $x = X::CASE (enum case) ───────── +;; Binds the variable to the class name X so member calls on $x dispatch +;; to X's methods (e.g. UserRole::Viewer → label()). +;; +;; tree-sitter-php emits class_constant_access_expression with two name +;; children: [0]=class/enum name, [1]=constant/case name. The dot-anchor +;; before (name) matches only the FIRST name child (the class). + +(assignment_expression + left: (variable_name) @type-binding.name + right: (class_constant_access_expression + . (name) @type-binding.type)) @type-binding.alias + +(assignment_expression + left: (variable_name) @type-binding.name + right: (class_constant_access_expression + (qualified_name + (name) @type-binding.type))) @type-binding.alias + +;; ── Type bindings — $x = SomeClass::staticFactory() ────────────────────── +;; Binds $x to the type returned by the static factory method, anchored on +;; the method name (chain-follow resolves the actual return type later). + +(assignment_expression + left: (variable_name) @type-binding.name + right: (scoped_call_expression + name: (name) @type-binding.type)) @type-binding.alias + +;; ── Type bindings — null-safe member-call result: $x = $a?->getY() ─────── + +(assignment_expression + left: (variable_name) @type-binding.name + right: (nullsafe_member_call_expression + name: (name) @type-binding.type)) @type-binding.alias + +;; ── References — constructor calls: new User() ─────────────────────────── + +(object_creation_expression + (name) @reference.name) @reference.call.constructor + +(object_creation_expression + (qualified_name + (name) @reference.name)) @reference.call.constructor + +;; ── References — member writes: $obj->prop = $x ────────────────────────── + +(assignment_expression + left: (member_access_expression + object: (_) @reference.receiver + name: (name) @reference.name)) @reference.write.member + +;; ── References — static property writes: User::$count = $x ────────────── +;; Uses @reference.write.static anchor so captures.ts can strip the leading +;; $ from the variable_name capture (static props are stored without $ in graph). +;; +;; SAFETY-INVARIANT (Finding 2 of PR #1497 adversarial review): no +;; read-access property capture exists in this query — dynamic property +;; reads ($obj->$prop, $obj->{$prop}) produce no captures, which is the +;; desired behavior for a structural-only resolver. Adding a read pattern +;; in the future MUST keep name: (name) (not (_)) to preserve the +;; suppression. Regression: php-dynamic-calls/ fixture dynamicPropertyRead. + +(assignment_expression + left: (scoped_property_access_expression + scope: (_) @reference.receiver + name: (variable_name) @reference.name)) @reference.write.static +`; + +let _parser: Parser | null = null; +let _query: Parser.Query | null = null; + +export function getPhpParser(): Parser { + if (_parser === null) { + _parser = new Parser(); + _parser.setLanguage(PHP_LANG as Parameters[0]); + } + return _parser; +} + +export function getPhpScopeQuery(): Parser.Query { + if (_query === null) { + _query = new Parser.Query(PHP_LANG as Parameters[0], PHP_SCOPE_QUERY); + } + return _query; +} diff --git a/gitnexus/src/core/ingestion/languages/php/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/php/receiver-binding.ts new file mode 100644 index 000000000..79709fe61 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/php/receiver-binding.ts @@ -0,0 +1,136 @@ +/** + * Synthesize `@type-binding.self` captures for PHP instance methods — + * one for `$this` (always on non-static methods inside a type + * declaration) and optionally one for `parent` (only on class methods + * when the enclosing class has an explicit `base_clause`). + * + * Mirrors `languages/csharp/receiver-binding.ts` in structure. PHP's + * grammar doesn't give us a clean `.scm` pattern for "implicit receiver + * on every instance method inside an enclosing type" because `$this` is + * not a parameter — it's an implicit receiver. Synthesis in code is the + * same approach C# uses for `this` / `base`. + * + * ## Known limitations + * + * - **Trait `$this`**: for methods defined in a trait, `$this` is + * synthesized as a binding to the trait itself. The actual using-class + * type is not known at single-file parse time. V1 limitation — + * documented in `index.ts`. + * - **Anonymous classes**: skipped (no stable enclosing class name). + */ + +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; + +const TYPE_DECL_NODE_TYPES = new Set([ + 'class_declaration', + 'interface_declaration', + 'trait_declaration', + 'enum_declaration', +]); + +const FUNCTION_NODE_TYPES = new Set([ + 'method_declaration', + 'function_definition', + 'anonymous_function', + 'arrow_function', +]); + +/** Walk up to find the enclosing type declaration. */ +function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null { + let cur: SyntaxNode | null = node.parent; + while (cur !== null) { + if (TYPE_DECL_NODE_TYPES.has(cur.type)) return cur; + cur = cur.parent; + } + return null; +} + +function typeName(typeNode: SyntaxNode): string | null { + return typeNode.childForFieldName('name')?.text ?? null; +} + +/** + * Return the base class name from a `base_clause` child of the class node. + * `base_clause` contains a `qualified_name` or `name` child. + */ +function baseClauseText(typeNode: SyntaxNode): string | null { + for (let i = 0; i < typeNode.namedChildCount; i++) { + const child = typeNode.namedChild(i); + if (child === null || child.type !== 'base_clause') continue; + const nameNode = child.firstNamedChild; + if (nameNode === null) return null; + // Take last segment of qualified name (e.g. \App\Models\BaseModel → BaseModel) + const text = nameNode.text.trim(); + const segments = text.split('\\').filter(Boolean); + return segments[segments.length - 1] ?? text; + } + return null; +} + +/** Check whether this method has a `static_modifier` child. */ +function isStaticMethod(fnNode: SyntaxNode): boolean { + for (let i = 0; i < fnNode.namedChildCount; i++) { + const child = fnNode.namedChild(i); + if (child !== null && child.type === 'static_modifier') return true; + } + return false; +} + +/** + * Build zero, one, or two `@type-binding.self` matches for `fnNode`: + * + * - Returns `[]` if the function is free (no enclosing type), static, + * or the enclosing type has no resolvable name. + * - Returns one match (`$this`) for non-static methods inside a + * class / trait / interface / enum body. + * - Returns two matches (`$this` + `parent`) only when the function + * lives in a `class_declaration` that has an explicit `base_clause`. + * + * The caller is responsible for guaranteeing + * `FUNCTION_NODE_TYPES.has(fnNode.type)`. + */ +export function synthesizePhpReceiverBinding(fnNode: SyntaxNode): CaptureMatch[] { + if (!FUNCTION_NODE_TYPES.has(fnNode.type)) return []; + if (isStaticMethod(fnNode)) return []; + + const enclosingType = findEnclosingTypeDeclaration(fnNode); + if (enclosingType === null) return []; + + // Anonymous class — skip (no stable name). + if (enclosingType.type === 'anonymous_class_declaration') return []; + + const enclosingName = typeName(enclosingType); + if (enclosingName === null) return []; + + // Anchor the synthesized captures to the method body (compound_statement) + // so they land inside the function scope, not at the class scope. + // For interface/abstract methods that have no body, skip. + const bodyNode = + fnNode.childForFieldName('body') ?? + // arrow_function: body is the expression after `=>` + fnNode.childForFieldName('return_value'); + if (bodyNode === null) return []; + + const out: CaptureMatch[] = []; + out.push(buildReceiverMatch(bodyNode, '$this', enclosingName)); + + // `parent` applies only to class methods with an explicit base_clause. + if (enclosingType.type === 'class_declaration') { + const baseText = baseClauseText(enclosingType); + if (baseText !== null) { + out.push(buildReceiverMatch(bodyNode, 'parent', baseText)); + } + } + + return out; +} + +function buildReceiverMatch(anchorNode: SyntaxNode, name: string, typeText: string): CaptureMatch { + const m: Record = { + '@type-binding.self': nodeToCapture('@type-binding.self', anchorNode), + '@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, name), + '@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeText), + }; + return m; +} diff --git a/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts new file mode 100644 index 000000000..8b1fcf41b --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/php/scope-resolver.ts @@ -0,0 +1,421 @@ +/** + * PHP `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by + * the generic `runScopeResolution` orchestrator (RFC #909 Ring 3 LANG-php). + * + * Third migration after Python and C#. See `pythonScopeResolver` for the + * canonical shape. + * + * ## Circular-import avoidance + * + * The old PR had `php/scope-resolver.ts` importing `phpProvider` from + * `../php.js` while `php.ts` imported `phpScopeResolver` from `./php/index.js` + * — undefined at module load. The canonical fix (mirroring C#): + * + * - `scope-resolver.ts` imports `phpProvider` from `../php.js` ✓ + * - `php.ts` imports individual hook FUNCTIONS from `./php/index.js` ✗ + * + * Node's ESM handles the cycle correctly because `phpProvider` is a named + * export that is live-binding — by the time `phpScopeResolver` is first + * read (lazily, at resolution time), `phpProvider` is fully initialized. + */ + +import type { ParsedFile } from 'gitnexus-shared'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; +import { + findReceiverTypeBinding, + populateClassOwnedMembers, +} from '../../scope-resolution/scope/walkers.js'; +import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import { + resolveCallerGraphId, + resolveDefGraphId, +} from '../../scope-resolution/graph-bridge/ids.js'; +import { narrowOverloadCandidates } from '../../scope-resolution/passes/overload-narrowing.js'; +import type { SemanticModel } from '../../model/semantic-model.js'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import type { SymbolDefinition } from 'gitnexus-shared'; +import { phpProvider } from '../php.js'; +import { phpArityCompatibility, phpMergeBindings } from './index.js'; +import { resolvePhpImportTargetInternal, loadPhpComposerConfig } from './import-target.js'; +import { populatePhpNamespaceSiblings, getPhpNamespaceForFile } from './namespace-siblings.js'; + +/** + * PHP MRO builder — extends the generic EXTENDS-only MRO with trait-use + * relationships encoded as IMPLEMENTS edges. + * + * PHP trait-use (`use TraitName;` inside a class body) is recorded in the + * graph as an IMPLEMENTS edge from the using class to the Trait node. The + * generic `buildMro` only walks EXTENDS edges, so trait methods are invisible + * to the MRO-based dispatch index. This variant: + * + * 1. Runs the generic `buildMro` (EXTENDS edges, Class defs only). + * 2. Indexes Trait defs from `parsedFiles` alongside Class defs. + * 3. Walks IMPLEMENTS edges; for each edge whose target resolves to a + * Trait DefId, prepends that Trait DefId to the source class's MRO. + * + * Trait methods are searched BEFORE parent-class methods (PHP semantics: + * a trait method shadows the parent-class method but is overridden by the + * using class's own methods). + */ +/** + * PHP free-call visibility check for `pickUniqueGlobalCallable`. Returns + * true when the candidate function is reachable from the caller's PHP + * namespace context, false when the cross-namespace bridge would be a + * false positive (e.g., `\App\Utils\format` is not visible from `\App` + * without an explicit `use function App\Utils\format;`). + * + * Rules (PHP semantics): + * 1. Same-namespace candidates are always visible. + * 2. Global-namespace candidates (no namespace prefix) are visible from + * every caller — PHP's global fallback for functions/constants. + * 3. Candidates in a different namespace are visible only when the + * caller has a `use function` import that matches the candidate's + * fully-qualified name. + */ +function phpIsCallableVisibleFromCaller(ctx: { + callerParsed: ParsedFile; + candidate: SymbolDefinition; +}): boolean { + const { callerParsed, candidate } = ctx; + const callerNs = getPhpNamespaceForFile(callerParsed.filePath); + const candNs = getPhpNamespaceForFile(candidate.filePath); + + // Global-namespace candidate: PHP falls back to global for functions + // and constants when the local namespace doesn't define them. + if (candNs === '') return true; + + // Same-namespace: caller can see the candidate without an explicit use. + if (candNs === callerNs) return true; + + // Cross-namespace: require an explicit `use function` import in the + // caller's parsedImports that matches the candidate's fully-qualified + // name. interpret.ts maps `use function Foo\bar` to a named import with + // localName = 'bar' and targetRaw = 'Foo\\bar'. + const candQualified = + candidate.qualifiedName === undefined + ? '' + : candNs !== '' && !candidate.qualifiedName.includes('\\') + ? `${candNs}\\${candidate.qualifiedName}` + : candidate.qualifiedName; + if (candQualified === '') return false; + return callerParsed.parsedImports.some( + (imp) => + imp.kind === 'named' && + imp.targetRaw.replace(/^\\+/, '') === candQualified.replace(/^\\+/, ''), + ); +} + +/** + * Compute the EXTENDS-only ancestor chain for every class — no trait + * augmentation. PHP semantics: `parent::method()` walks this view so + * that `parent::` resolves to the parent class's method, even when a + * composed trait shadows the same name. + * + * Returns the same shape as `buildPhpMro` so callers can swap views + * without changing dispatch logic. Just `buildMro` + `defaultLinearize` + * — no trait IMPLEMENTS edge walk. + */ +function buildPhpExtendsOnlyMro( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, +): Map { + return buildMro(graph, parsedFiles, nodeLookup, defaultLinearize); +} + +function buildPhpMro( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, +): Map { + // Step 1: run generic MRO (Class-only, EXTENDS-only). + const mro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize); + + // Step 2: build a graphId → defId map for ALL class-like defs including Traits. + // After the `isLinkableLabel` fix, Trait nodes are now indexed in nodeLookup. + const defIdByGraphId = new Map(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + if (def.type !== 'Class' && def.type !== 'Trait') continue; + const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup); + if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId); + } + } + + // Step 2b: build a Set of Trait defIds for O(1) trait-vs-interface checks. + const traitDefIds = new Set(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + if (def.type === 'Trait') traitDefIds.add(def.nodeId); + } + } + + // Step 3: collect direct trait-use edges (IMPLEMENTS where target is a Trait). + // Maps class/trait defId → [traitDefId, ...] for direct `use TraitName;`. + const directTraitUse = new Map(); + for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) { + const sourceDefId = defIdByGraphId.get(rel.sourceId); + if (sourceDefId === undefined) continue; + const targetDefId = defIdByGraphId.get(rel.targetId); + if (targetDefId === undefined) continue; + if (!traitDefIds.has(targetDefId)) continue; + + let list = directTraitUse.get(sourceDefId); + if (list === undefined) { + list = []; + directTraitUse.set(sourceDefId, list); + } + if (!list.includes(targetDefId)) list.push(targetDefId); + } + + // Step 4: augment every class's MRO by prepending the traits used by + // any class in its ancestor chain (transitively closed). PHP semantics: + // a trait used by a parent class is also visible on the child, and a + // trait-using-trait chain is flattened to a single ancestor set. + // + // For each class, walk its (already-computed) EXTENDS-based MRO and + // collect all transitively-used traits via BFS — `trait A { use B; } + // trait B { use C; } class X { use A; }` must include C in X's MRO. + // Prepend them before the EXTENDS ancestors so the method dispatch + // index finds trait methods before falling back to the parent class + // hierarchy. + for (const [classDefId, extendsMro] of mro) { + const ancestorChain = [classDefId, ...extendsMro]; + const seeds: string[] = []; + for (const ancestorId of ancestorChain) { + for (const traitId of directTraitUse.get(ancestorId) ?? []) { + seeds.push(traitId); + } + } + const allTraits = collectTransitiveTraits(seeds, directTraitUse); + + if (allTraits.length > 0) { + // Prepend traits before EXTENDS ancestors: own class's traits first, + // then parent traits (in ancestor order). This ensures trait methods + // are found before falling back to the inheritance chain. + mro.set(classDefId, [...allTraits, ...extendsMro]); + } + } + + // Step 5: also insert Trait-only entries for classes that use traits + // directly but have no EXTENDS parents (not in `mro` yet). + for (const [classDefId, traits] of directTraitUse) { + if (!mro.has(classDefId) && !traitDefIds.has(classDefId)) { + // Class with no EXTENDS but with trait-use — add to MRO map. + const allTraits = collectTransitiveTraits([...traits], directTraitUse); + mro.set(classDefId, allTraits); + } + } + + return mro; +} + +/** + * Collect the transitive closure of traits reachable from the seed set. + * BFS over `directTraitUse` until fixpoint. The `seen` set guards against + * cycles (invalid PHP but defensively handled) and prevents duplicate + * entries when multiple seeds converge on the same trait. Insertion order + * is preserved — first-seen wins for MRO ordering. + */ +function collectTransitiveTraits( + seeds: readonly string[], + directTraitUse: ReadonlyMap, +): string[] { + const out: string[] = []; + const seen = new Set(); + const queue: string[] = [...seeds]; + while (queue.length > 0) { + const t = queue.shift()!; + if (seen.has(t)) continue; + seen.add(t); + out.push(t); + for (const next of directTraitUse.get(t) ?? []) { + if (!seen.has(next)) queue.push(next); + } + } + return out; +} + +/** + * Emit CALLS edges for PHP member-call sites whose receiver has no type + * binding (e.g. `mixed`-typed parameters, untyped variables). + * + * PHP is dynamically typed: a parameter declared as `mixed` (or with no + * type hint) cannot be resolved by the generic receiver-bound pass, which + * requires a `TypeRef` in scope. This hook does a workspace-wide method + * name lookup: when exactly one def in the workspace matches the called + * method name, emit the CALLS edge. + * + * Only fires for sites that are NOT already in `handledSites` and whose + * receiver has no type binding in the scope chain. Unique-name-match + * constraint avoids false positives for common method names. + */ +function phpEmitUnresolvedReceiverEdges( + graph: KnowledgeGraph, + scopes: ScopeResolutionIndexes, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + handledSites: Set, + model: SemanticModel, +): number { + let emitted = 0; + const seen = new Set(); + + for (const parsed of parsedFiles) { + for (const site of parsed.referenceSites) { + if (site.kind !== 'call') continue; + if (site.explicitReceiver === undefined) continue; + + const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`; + if (handledSites.has(siteKey)) continue; + + // Only proceed when the receiver has NO type binding — it's unresolvable + // by the generic pass. This is the `mixed` / unannotated case. + const typeRef = findReceiverTypeBinding(site.inScope, site.explicitReceiver.name, scopes); + if (typeRef !== undefined) continue; + + // Workspace-wide lookup: collect all methods matching the called name. + // Filter out defs with no qualifiedName (legacy parse stubs without full + // metadata) and deduplicate by nodeId so reconcileOwnership double-registration + // doesn't inflate the count. + const allCandidates = model.methods.lookupMethodByName(site.name); + const seen2 = new Set(); + const candidates = allCandidates.filter((c) => { + if (c.qualifiedName === undefined) return false; + if (seen2.has(c.nodeId)) return false; + seen2.add(c.nodeId); + return true; + }); + if (candidates.length !== 1) continue; // ambiguous or missing — skip + + const fnDef = candidates[0]; + if (fnDef === undefined) continue; + + // Apply arity narrowing — a unique method name match is not enough + // when arity says the call is definitively incompatible (e.g., PHP + // f(int $req, ...$rest) called with zero args). This prevents the + // fallback from emitting edges that the receiver-bound pass already + // rejected for arity reasons. + if (narrowOverloadCandidates([fnDef], site.arity, site.argumentTypes).length === 0) { + continue; + } + + // Tighten the fallback further with an EXACT-required-arity gate + // (Finding 8 / U4): the first-stage `narrowOverloadCandidates` + // accepts any argCount in `min..max` (or `>= min` when variadic), + // which over-emits 0.6-confidence edges for common method names + // whose only workspace candidate has optional / defaulted params. + // For the fallback path only, require argCount === required for + // fixed-arity candidates. Variadic candidates keep the relaxed + // `argCount >= required` semantics (already enforced by the first- + // stage check, so no extra work here). + const min = fnDef.requiredParameterCount; + const hasVarArgs = + fnDef.parameterTypes !== undefined && + fnDef.parameterTypes.some((t) => t === '...' || t.startsWith('...')); + if ( + min !== undefined && + Number.isFinite(site.arity) && + site.arity >= 0 && + !hasVarArgs && + site.arity !== min + ) { + continue; + } + + const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup); + if (callerGraphId === undefined) continue; + const tgtGraphId = resolveDefGraphId(fnDef.filePath, fnDef, nodeLookup); + if (tgtGraphId === undefined) continue; + + handledSites.add(siteKey); + const relId = `rel:CALLS:${callerGraphId}->${tgtGraphId}`; + if (seen.has(relId)) continue; + seen.add(relId); + graph.addRelationship({ + id: relId, + sourceId: callerGraphId, + targetId: tgtGraphId, + type: 'CALLS', + confidence: 0.6, + reason: 'php-unresolved-receiver-fallback', + }); + emitted++; + } + } + return emitted; +} + +const phpScopeResolver: ScopeResolver = { + language: SupportedLanguages.PHP, + languageProvider: phpProvider, + importEdgeReason: 'php-scope: use', + + resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => + resolvePhpImportTargetInternal(targetRaw, fromFile, allFilePaths, resolutionConfig), + + loadResolutionConfig: (repoPath) => loadPhpComposerConfig(repoPath), + + // PHP LEGB-like precedence: local > import/namespace/reexport > wildcard. + // The per-scope id is unused by phpMergeBindings (tier ordering computed + // purely from BindingRef.origin), so we don't synthesize a Scope. + mergeBindings: (existing, incoming) => [...phpMergeBindings([...existing, ...incoming])], + + // Adapter: phpArityCompatibility uses (def, callsite); the contract is (callsite, def). + arityCompatibility: (callsite, def) => phpArityCompatibility(def, callsite), + + buildMro: (graph, parsedFiles, nodeLookup) => buildPhpMro(graph, parsedFiles, nodeLookup), + + // PHP-specific: parent::method() must walk inheritance only, skipping + // composed traits. See buildPhpExtendsOnlyMro and the super-branch use + // in `passes/receiver-bound-calls.ts`. + buildExtendsOnlyMro: (graph, parsedFiles, nodeLookup) => + buildPhpExtendsOnlyMro(graph, parsedFiles, nodeLookup), + + // PHP free-call visibility: cross-namespace candidates are blocked + // unless explicitly `use function`-imported by the caller. Prevents + // false-positive CALLS edges between unrelated namespaces sharing a + // function name. Same-namespace and global-namespace candidates pass + // unchanged. + isCallableVisibleFromCaller: phpIsCallableVisibleFromCaller, + + populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed), + + // PHP same-namespace cross-file visibility — classes in the same + // PHP namespace are visible without explicit `use` statements. + // Mirrors C#'s `populateNamespaceSiblings`. + populateNamespaceSiblings: populatePhpNamespaceSiblings, + + // PHP uses `parent` for super-class dispatch (not `super()`). + isSuperReceiver: (text) => text.trim() === 'parent', + + // PHP is dynamically typed — field-fallback heuristic on so that + // method calls on `mixed`-typed receivers (no annotation) fall back + // to a workspace-wide name search rather than silently dropping the edge. + fieldFallbackOnMethodLookup: true, + + // PHP: allow free-call fallback to unique workspace-wide callable when + // lexical/import bindings miss. Needed for two cases: + // 1. `use function` imports where PSR-4 directory resolution is + // non-deterministic (multiple .php files in same namespace dir). + // 2. Unimported free calls within the same namespace (same-namespace + // visibility without an explicit use statement, e.g. test fixtures). + allowGlobalFreeCallFallback: true, + + // Return-type propagation on — PHP method signatures are authoritative + // enough for cross-file chain-follow. + propagatesReturnTypesAcrossImports: true, + + // PHP hoists method return-type bindings to the Module scope so + // `propagateImportedReturnTypes` can pick them up across files. + hoistTypeBindingsToModule: true, + + // PHP recovers member calls on `mixed`/untyped receivers via a + // workspace-wide unique-method-name lookup, mirroring the legacy DAG. + emitUnresolvedReceiverEdges: phpEmitUnresolvedReceiverEdges, +}; + +export { phpScopeResolver }; diff --git a/gitnexus/src/core/ingestion/languages/php/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/php/simple-hooks.ts new file mode 100644 index 000000000..89a9a2658 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/php/simple-hooks.ts @@ -0,0 +1,134 @@ +/** + * Trivial / no-op-ish hooks for the PHP provider. Made explicit so + * reviewers don't have to re-derive the analysis from "absence == default". + */ + +import type { + CaptureMatch, + ParsedImport, + Scope, + ScopeId, + ScopeTree, + TypeRef, +} from 'gitnexus-shared'; + +// ─── bindingScopeFor ────────────────────────────────────────────────────── + +/** + * PHP method return-type bindings (`@type-binding.return`) must hoist + * to the enclosing Module scope so `propagateImportedReturnTypes` can + * mirror them across files. Without this hoist, the return binding gets + * stuck at the Class scope and is invisible to the cross-file propagation + * pass that reads only `sourceModule.typeBindings`. + * + * All other bindings delegate to the default "innermost scope" rule. + */ +export function phpBindingScopeFor( + decl: CaptureMatch, + innermost: Scope, + tree: ScopeTree, +): ScopeId | null { + if (decl['@type-binding.return'] !== undefined) { + let cur: Scope | undefined = innermost; + while (cur !== undefined && cur.kind !== 'Module') { + const parentId: ScopeId | null = cur.parent ?? null; + if (parentId === null) break; + cur = tree.getScope(parentId); + } + if (cur !== undefined && cur.kind === 'Module') return cur.id; + } + + // Constructor-promoted properties (`function __construct(public User $u)`) + // are declared inside the constructor's Function scope in the AST, but they + // are class-owned fields. Hoist the @declaration.property binding to the + // enclosing Class scope so `populateClassOwnedMembers` assigns the correct + // ownerId and `findOwnedMember` can resolve `$obj->u`. + if (decl['@declaration.property'] !== undefined && innermost.kind === 'Function') { + let cur: Scope | undefined = innermost; + while (cur !== undefined && cur.kind !== 'Class') { + const parentId: ScopeId | null = cur.parent ?? null; + if (parentId === null) break; + cur = tree.getScope(parentId); + } + if (cur !== undefined && cur.kind === 'Class') return cur.id; + } + + // Constructor-promoted property TYPE BINDING (`function __construct(public Address $address)`) + // produces both a @type-binding.parameter (stays in Function scope for `$address` lookups + // inside the constructor body) AND a @type-binding.annotation (query.ts). The annotation + // capture is emitted so this hoist branch can place `address → Address` in the CLASS scope. + // + // The compound-receiver resolver (`resolveCompoundReceiverClass`) reads typeBindings from + // the class scope: `cs.typeBindings.get('address')`. Without hoisting, `$user->address->save()` + // fails to resolve `address` because the type binding is in the constructor's Function scope. + // + // `@type-binding.annotation` for a promoted param appears with innermost = Function scope + // (the constructor). Regular typed class properties (`private Address $addr;`) have their + // annotation already in the Class scope, so this branch only fires for promoted params. + if (decl['@type-binding.annotation'] !== undefined && innermost.kind === 'Function') { + let cur: Scope | undefined = innermost; + while (cur !== undefined && cur.kind !== 'Class') { + const parentId: ScopeId | null = cur.parent ?? null; + if (parentId === null) break; + cur = tree.getScope(parentId); + } + if (cur !== undefined && cur.kind === 'Class') return cur.id; + } + + return null; +} + +// ─── importOwningScope ──────────────────────────────────────────────────── + +/** + * Determine which scope owns a `use` import declaration. + * + * - `use` inside `namespace Foo { }` → attach to that Namespace scope. + * - Top-level `use` (no enclosing namespace) → innermost (Module). + * - `use TraitName;` inside a class body → this is a trait-use + * (heritage), NOT a namespace import. The grammar emits + * `use_declaration` for trait-use (distinct from + * `namespace_use_declaration`). Our query only captures + * `namespace_use_declaration`, so trait-use never reaches this hook + * in practice. Returning `null` here is a safety fallback. + */ +export function phpImportOwningScope( + _imp: ParsedImport, + innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + // Namespace-scoped or module-scoped imports attach to the innermost scope + // (either Namespace or Module). Class-scoped imports should not occur for + // namespace_use_declaration; if they do, attach to the class scope. + if ( + innermost.kind === 'Namespace' || + innermost.kind === 'Module' || + innermost.kind === 'Class' || + innermost.kind === 'Function' + ) { + return innermost.id; + } + return null; +} + +// ─── receiverBinding ────────────────────────────────────────────────────── + +/** + * Look up `$this` or `parent` in the function scope's type bindings. + * + * Both are synthesized as `@type-binding.self` captures during capture + * emission (`receiver-binding.ts`) — `$this` for every non-static + * method inside a class/trait/interface/enum body, `parent` additionally + * for class methods with an explicit `base_clause`. + * + * Returns `null` for: + * - static methods (no `$this` synthesized) + * - free functions (no enclosing class) + * - non-Function scopes + */ +export function phpReceiverBinding(functionScope: Scope): TypeRef | null { + if (functionScope.kind !== 'Function') return null; + return ( + functionScope.typeBindings.get('$this') ?? functionScope.typeBindings.get('parent') ?? null + ); +} diff --git a/gitnexus/src/core/ingestion/languages/python/captures.ts b/gitnexus/src/core/ingestion/languages/python/captures.ts index be891b5cc..bc4911546 100644 --- a/gitnexus/src/core/ingestion/languages/python/captures.ts +++ b/gitnexus/src/core/ingestion/languages/python/captures.ts @@ -24,6 +24,7 @@ import { synthesizeReceiverTypeBinding } from './receiver-binding.js'; import { computePythonArityMetadata } from './arity-metadata.js'; import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; import { getTreeSitterBufferSize } from '../../constants.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { pythonFunctionDefinitionLabel } from './simple-hooks.js'; export function emitPythonScopeCaptures( @@ -39,7 +40,7 @@ export function emitPythonScopeCaptures( let tree = cachedTree as ReturnType['parse']> | undefined; if (tree === undefined) { try { - tree = getPythonParser().parse(sourceText, undefined, { + tree = parseSourceSafe(getPythonParser(), sourceText, undefined, { bufferSize: getTreeSitterBufferSize(sourceText), }); } catch (err) { diff --git a/gitnexus/src/core/ingestion/languages/typescript/captures.ts b/gitnexus/src/core/ingestion/languages/typescript/captures.ts index 9d083c15c..b82edcff8 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/captures.ts @@ -38,6 +38,7 @@ import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; import { synthesizeTsReceiverBinding } from './receiver-binding.js'; import { computeTsArityMetadata } from './arity-metadata.js'; import { getTreeSitterBufferSize } from '../../constants.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; /** tree-sitter-typescript node types for function-like scopes that may * carry a synthesized `this` binding. Kept in sync with the @@ -134,7 +135,7 @@ export function emitTsScopeCaptures( tree = undefined; } if (tree === undefined) { - tree = getTsParser(filePath).parse(sourceText, undefined, { + tree = parseSourceSafe(getTsParser(filePath), sourceText, undefined, { bufferSize: getTreeSitterBufferSize(sourceText), }); recordCacheMiss(); diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 98036fbe8..04a17db4f 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -5,12 +5,11 @@ import { loadParser, loadLanguage, isLanguageAvailable } from '../tree-sitter/pa import { getProvider } from './languages/index.js'; import { generateId } from '../../lib/utils.js'; import type { SymbolTableReader, SymbolTableWriter, ExtractedHeritage } from './model/index.js'; -// SymbolTableReader is used for the FieldExtractorContext stub; the -// parsing functions themselves need Writer because they call .add(). import { ASTCache } from './ast-cache.js'; import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared'; import { extractVueScript, isVueSetupTopLevel } from './vue-sfc-extractor.js'; import { yieldToEventLoop } from './utils/event-loop.js'; +import { parseSourceSafe } from '../tree-sitter/safe-parse.js'; import { isVerboseIngestionEnabled } from './utils/verbose.js'; import { getDefinitionNodeFromCaptures, @@ -83,6 +82,88 @@ export interface WorkerExtractedData { // Worker-based parallel parsing // ============================================================================ +/** + * Merge a list of `ParseWorkerResult`s into the running graph + symbol + * table state and produce the chunk-aggregated `WorkerExtractedData`. + * + * Extracted from `processParsingWithWorkers` so the same merge logic can + * be applied to both freshly-parsed worker output AND cached worker + * output replayed during incremental analyze. Idempotent on the + * accumulator fields (push-only); idempotent on graph if the caller + * starts from a clean graph (otherwise duplicate `addNode` calls are + * silently no-op'd by `KnowledgeGraph`). + */ +export const mergeChunkResults = ( + graph: KnowledgeGraph, + symbolTable: SymbolTableWriter, + chunkResults: readonly ParseWorkerResult[], +): WorkerExtractedData => { + const allImports: ExtractedImport[] = []; + const allCalls: ExtractedCall[] = []; + const allAssignments: ExtractedAssignment[] = []; + const allHeritage: ExtractedHeritage[] = []; + const allRoutes: ExtractedRoute[] = []; + const allFetchCalls: ExtractedFetchCall[] = []; + const allDecoratorRoutes: ExtractedDecoratorRoute[] = []; + const allToolDefs: ExtractedToolDef[] = []; + const allORMQueries: ExtractedORMQuery[] = []; + const allConstructorBindings: FileConstructorBindings[] = []; + const fileScopeBindingsByFile: FileScopeBindings[] = []; + const allParsedFiles: ParsedFile[] = []; + + for (const result of chunkResults) { + for (const node of result.nodes) { + graph.addNode({ + id: node.id, + label: node.label as NodeLabel, + properties: node.properties, + }); + } + for (const rel of result.relationships) { + graph.addRelationship(rel); + } + for (const sym of result.symbols) { + symbolTable.add(sym.filePath, sym.name, sym.nodeId, sym.type, { + parameterCount: sym.parameterCount, + requiredParameterCount: sym.requiredParameterCount, + parameterTypes: sym.parameterTypes, + returnType: sym.returnType, + declaredType: sym.declaredType, + ownerId: sym.ownerId, + qualifiedName: sym.qualifiedName, + }); + } + for (const item of result.imports) allImports.push(item); + for (const item of result.calls) allCalls.push(item); + for (const item of result.assignments) allAssignments.push(item); + for (const item of result.heritage) allHeritage.push(item); + for (const item of result.routes) allRoutes.push(item); + for (const item of result.fetchCalls) allFetchCalls.push(item); + for (const item of result.decoratorRoutes) allDecoratorRoutes.push(item); + for (const item of result.toolDefs) allToolDefs.push(item); + if (result.ormQueries) for (const item of result.ormQueries) allORMQueries.push(item); + for (const item of result.constructorBindings) allConstructorBindings.push(item); + if (result.fileScopeBindings) + for (const item of result.fileScopeBindings) fileScopeBindingsByFile.push(item); + if (result.parsedFiles) for (const item of result.parsedFiles) allParsedFiles.push(item); + } + + return { + imports: allImports, + calls: allCalls, + assignments: allAssignments, + heritage: allHeritage, + routes: allRoutes, + fetchCalls: allFetchCalls, + decoratorRoutes: allDecoratorRoutes, + toolDefs: allToolDefs, + ormQueries: allORMQueries, + constructorBindings: allConstructorBindings, + fileScopeBindings: fileScopeBindingsByFile, + parsedFiles: allParsedFiles, + }; +}; + const processParsingWithWorkers = async ( graph: KnowledgeGraph, files: { path: string; content: string }[], @@ -90,6 +171,14 @@ const processParsingWithWorkers = async ( astCache: ASTCache, workerPool: WorkerPool, onFileProgress?: FileProgressCallback, + /** + * When provided, populated with the raw worker results before merging. + * Used by the incremental-indexing parse cache to capture the per-chunk + * worker output for caching across runs. The mutation happens in-place + * so the caller (parse-impl) can keep a reference. See + * `gitnexus/src/storage/parse-cache.ts`. + */ + outRawResults?: ParseWorkerResult[], ): Promise => { // Filter to parseable files only const parseableFiles: ParseWorkerInput[] = []; @@ -124,63 +213,16 @@ const processParsingWithWorkers = async ( }, ); - // Merge results from all workers into graph and symbol table - const allImports: ExtractedImport[] = []; - const allCalls: ExtractedCall[] = []; - const allAssignments: ExtractedAssignment[] = []; - const allHeritage: ExtractedHeritage[] = []; - const allRoutes: ExtractedRoute[] = []; - const allFetchCalls: ExtractedFetchCall[] = []; - const allDecoratorRoutes: ExtractedDecoratorRoute[] = []; - const allToolDefs: ExtractedToolDef[] = []; - const allORMQueries: ExtractedORMQuery[] = []; - const allConstructorBindings: FileConstructorBindings[] = []; - const fileScopeBindingsByFile: FileScopeBindings[] = []; - const allParsedFiles: ParsedFile[] = []; - for (const result of chunkResults) { - for (const node of result.nodes) { - graph.addNode({ - id: node.id, - label: node.label as NodeLabel, - properties: node.properties, - }); - } - - for (const rel of result.relationships) { - graph.addRelationship(rel); - } - - for (const sym of result.symbols) { - symbolTable.add(sym.filePath, sym.name, sym.nodeId, sym.type, { - parameterCount: sym.parameterCount, - requiredParameterCount: sym.requiredParameterCount, - parameterTypes: sym.parameterTypes, - returnType: sym.returnType, - declaredType: sym.declaredType, - ownerId: sym.ownerId, - qualifiedName: sym.qualifiedName, - }); - } - - for (const item of result.imports) allImports.push(item); - for (const item of result.calls) allCalls.push(item); - for (const item of result.assignments) allAssignments.push(item); - for (const item of result.heritage) allHeritage.push(item); - for (const item of result.routes) allRoutes.push(item); - for (const item of result.fetchCalls) allFetchCalls.push(item); - for (const item of result.decoratorRoutes) allDecoratorRoutes.push(item); - for (const item of result.toolDefs) allToolDefs.push(item); - if (result.ormQueries) for (const item of result.ormQueries) allORMQueries.push(item); - for (const item of result.constructorBindings) allConstructorBindings.push(item); - if (result.fileScopeBindings) - for (const item of result.fileScopeBindings) fileScopeBindingsByFile.push(item); - // RFC #909 Ring 2: aggregate per-file scope artifacts. Tolerant of - // workers that don't emit the field yet (older worker builds or - // partial rollouts), since the additive contract means undefined = - // "this worker produced no ParsedFiles for this chunk". - if (result.parsedFiles) for (const item of result.parsedFiles) allParsedFiles.push(item); + // Capture the raw chunk results for the incremental parse cache before + // merging — the cache stores the unmerged worker output so a future run + // can re-merge them into a fresh graph state. + if (outRawResults) { + for (const r of chunkResults) outRawResults.push(r); } + // Merge results from all workers into graph and symbol table. + const merged = mergeChunkResults(graph, symbolTable, chunkResults); + // Merge and log skipped languages from workers const skippedLanguages = new Map(); for (const result of chunkResults) { @@ -197,20 +239,7 @@ const processParsingWithWorkers = async ( // Final progress onFileProgress?.(total, total, 'done'); - return { - imports: allImports, - calls: allCalls, - assignments: allAssignments, - heritage: allHeritage, - routes: allRoutes, - fetchCalls: allFetchCalls, - decoratorRoutes: allDecoratorRoutes, - toolDefs: allToolDefs, - ormQueries: allORMQueries, - constructorBindings: allConstructorBindings, - fileScopeBindings: fileScopeBindingsByFile, - parsedFiles: allParsedFiles, - }; + return merged; }; // ============================================================================ @@ -384,7 +413,7 @@ const processParsingSequential = async ( let tree: Parser.Tree; try { - tree = parser.parse(parseContent, undefined, { + tree = parseSourceSafe(parser, parseContent, undefined, { bufferSize: getTreeSitterBufferSize(parseContent), }); } catch (parseError) { @@ -733,6 +762,14 @@ export const processParsing = async ( scopeTreeCache: ASTCache | undefined, onFileProgress?: FileProgressCallback, workerPool?: WorkerPool, + /** + * Optional out-parameter for the incremental parse cache. When + * provided AND the worker-pool path runs successfully, populated + * with the raw `ParseWorkerResult[]` from the workers (pre-merge). + * Stays empty for the sequential fallback path (no per-chunk + * artifact to cache there). See `gitnexus/src/storage/parse-cache.ts`. + */ + outRawResults?: ParseWorkerResult[], ): Promise => { let lastProgress = 0; const reportProgress: FileProgressCallback | undefined = onFileProgress @@ -760,6 +797,7 @@ export const processParsing = async ( astCache, workerPool, reportProgress, + outRawResults, ); } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index bd39a4330..17cfaab3f 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -17,7 +17,10 @@ import { enrichExportedTypeMap, type BindingEntry, } from '../binding-accumulator.js'; -import { processParsing } from '../parsing-processor.js'; +import { processParsing, mergeChunkResults } from '../parsing-processor.js'; +import { fileContentHash, computeChunkHash } from '../../../storage/parse-cache.js'; +import type { ParseWorkerResult } from '../workers/parse-worker.js'; +import type { WorkerExtractedData } from '../parsing-processor.js'; import { processImports, processImportsFromExtracted, @@ -72,8 +75,21 @@ import { extractORMQueriesInline } from './orm-extraction.js'; import { logger } from '../../logger.js'; // ── Constants ────────────────────────────────────────────────────────────── -/** Max bytes of source content to load per parse chunk. */ -const CHUNK_BYTE_BUDGET = 20 * 1024 * 1024; // 20MB +/** Max bytes of source content to load per parse chunk. + * + * Memory bound for the worker pool dispatch + a granularity knob for + * the parse cache. A single file change invalidates only its enclosing + * chunk, so smaller budgets → finer-grained invalidation. + * + * Override via GITNEXUS_CHUNK_BYTE_BUDGET (bytes) — the default of 2MB + * gives a useful invalidation floor (~1/N chunks on a multi-MB repo) + * while keeping worker dispatch overhead under 5% on cold runs. + */ +const CHUNK_BYTE_BUDGET = (() => { + const env = Number(process.env.GITNEXUS_CHUNK_BYTE_BUDGET); + if (Number.isFinite(env) && env > 0) return env; + return 2 * 1024 * 1024; +})(); // ── Main parse + resolve function ────────────────────────────────────────── @@ -119,6 +135,11 @@ export async function runChunkedParseAndResolve( * source. See plan * docs/plans/2026-04-20-002-perf-parse-heritage-mro-plan.md (Unit 4). */ scopeTreeCache: ASTCache; + /** Worker-produced ParsedFile artifacts aggregated across chunks. + * Threaded into scope-resolution as a re-extract cache so the warm- + * cache analyze run can skip the dominant `extractParsedFile` cost + * (otherwise ~58s on a 1000-file repo). */ + parsedFiles: import('gitnexus-shared').ParsedFile[]; }> { const ctx = createResolutionContext(); const symbolTable = ctx.model.symbols; @@ -142,6 +163,15 @@ export async function runChunkedParseAndResolve( ); } + // Sort parseableScanned alphabetically for stable chunk membership + // across runs (Finding 4). Without this, filesystem-scan order can + // shift between runs (notably on macOS APFS where directory entry + // order can change after modifications) — different files in the + // same chunk → different chunk hash → cache miss even when no file + // content changed. The cache also becomes platform-specific: a + // Linux-built cache misses on macOS for the same repo. + parseableScanned.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + const totalParseable = parseableScanned.length; if (totalParseable === 0) { @@ -271,6 +301,20 @@ export async function runChunkedParseAndResolve( const deferredWorkerHeritage: ExtractedHeritage[] = []; const deferredConstructorBindings: FileConstructorBindings[] = []; const deferredAssignments: ExtractedAssignment[] = []; + // Aggregated per-file ParsedFile artifacts produced by workers' calls + // to `extractParsedFile`. Threaded through to the scope-resolution + // phase so it can SKIP its own re-extraction on cache hits — this is + // the second-half of the parse-cache speedup since scope-resolution's + // re-parse otherwise dominates the warm-cache wall-clock time. + const allParsedFiles: import('gitnexus-shared').ParsedFile[] = []; + + // Incremental parse cache (Option B): chunk-level content-addressed. + // When the chunk's (filePath, content-hash) signature matches a prior + // run's, replay the cached ParseWorkerResult[] instead of dispatching + // to workers. See gitnexus/src/storage/parse-cache.ts. + const parseCache = options?.parseCache; + let chunkCacheHits = 0; + let chunkCacheMisses = 0; try { for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) { @@ -281,29 +325,89 @@ export async function runChunkedParseAndResolve( .filter((p) => chunkContents.has(p)) .map((p) => ({ path: p, content: chunkContents.get(p)! })); - const chunkWorkerData = await processParsing( - graph, - chunkFiles, - symbolTable, - astCache, - scopeTreeCache, - (current, _total, filePath) => { - const globalCurrent = filesParsedSoFar + current; - const parsingProgress = 20 + (globalCurrent / totalParseable) * 62; - onProgress({ - phase: 'parsing', - percent: Math.round(parsingProgress), - message: `Parsing chunk ${chunkIdx + 1}/${numChunks}...`, - detail: filePath, - stats: { - filesProcessed: globalCurrent, - totalFiles: totalParseable, - nodesCreated: graph.nodeCount, - }, - }); - }, - workerPool, - ); + // Compute the chunk's content-hash signature (if cache available). + let chunkHash: string | null = null; + if (parseCache) { + const entries = chunkFiles.map((f) => ({ + filePath: f.path, + contentHash: fileContentHash(f.content), + })); + chunkHash = computeChunkHash(entries); + } + + let chunkWorkerData: WorkerExtractedData | null; + const cachedRaw = chunkHash ? parseCache!.entries.get(chunkHash) : undefined; + + // Track every chunk hash we touched so the orchestrator can + // prune stale entries (chunks whose composition no longer + // corresponds to a live chunk in the current scan) before saving. + if (parseCache && chunkHash) parseCache.usedKeys.add(chunkHash); + + if (cachedRaw && cachedRaw.length > 0) { + // Cache hit: replay the cached worker output through the same + // merge logic the live worker path uses. + chunkCacheHits++; + chunkWorkerData = mergeChunkResults(graph, symbolTable, cachedRaw); + if (isDev) { + logger.info( + `📦 parse-cache HIT: chunk ${chunkIdx + 1}/${numChunks} (${chunkFiles.length} files, ${chunkHash!.slice(0, 8)})`, + ); + } + // Progress update so UI advances even on a cache hit. + const cachedFiles = chunkFiles.length; + onProgress({ + phase: 'parsing', + percent: Math.round(20 + ((filesParsedSoFar + cachedFiles) / totalParseable) * 62), + message: `Parsing chunk ${chunkIdx + 1}/${numChunks} (cache)...`, + stats: { + filesProcessed: filesParsedSoFar + cachedFiles, + totalFiles: totalParseable, + nodesCreated: graph.nodeCount, + }, + }); + } else { + // Cache miss: dispatch to workers, capture the raw results, store + // them under the chunk hash for the next run. + chunkCacheMisses++; + const rawResults: ParseWorkerResult[] = []; + chunkWorkerData = await processParsing( + graph, + chunkFiles, + symbolTable, + astCache, + scopeTreeCache, + (current, _total, filePath) => { + const globalCurrent = filesParsedSoFar + current; + const parsingProgress = 20 + (globalCurrent / totalParseable) * 62; + onProgress({ + phase: 'parsing', + percent: Math.round(parsingProgress), + message: `Parsing chunk ${chunkIdx + 1}/${numChunks}...`, + detail: filePath, + stats: { + filesProcessed: globalCurrent, + totalFiles: totalParseable, + nodesCreated: graph.nodeCount, + }, + }); + }, + workerPool, + // Capture raw results only when we have a cache to write to — + // otherwise we'd retain extra arrays for nothing. + parseCache && chunkHash ? rawResults : undefined, + ); + // Persist the raw results for this chunk hash. Sequential path + // doesn't populate rawResults (it writes directly to graph), so + // small repos without worker pool simply don't cache. That's fine. + if (parseCache && chunkHash && rawResults.length > 0) { + parseCache.entries.set(chunkHash, rawResults); + if (isDev) { + logger.info( + `📦 parse-cache MISS+store: chunk ${chunkIdx + 1}/${numChunks} (${chunkFiles.length} files, ${chunkHash.slice(0, 8)})`, + ); + } + } + } const chunkBasePercent = 20 + (filesParsedSoFar / totalParseable) * 62; @@ -349,6 +453,12 @@ export async function runChunkedParseAndResolve( for (const item of chunkWorkerData.heritage) deferredWorkerHeritage.push(item); for (const item of chunkWorkerData.constructorBindings) 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). + if (chunkWorkerData.parsedFiles?.length) { + for (const item of chunkWorkerData.parsedFiles) allParsedFiles.push(item); + } if (chunkWorkerData.assignments?.length) { for (const item of chunkWorkerData.assignments) deferredAssignments.push(item); } @@ -422,6 +532,12 @@ export async function runChunkedParseAndResolve( astCache.clear(); } + if (isDev && parseCache && (chunkCacheHits > 0 || chunkCacheMisses > 0)) { + logger.info( + `📦 parse-cache summary: ${chunkCacheHits} chunk hit(s), ${chunkCacheMisses} miss(es) across ${numChunks} chunk(s)`, + ); + } + const fullWorkerHeritageMap = deferredWorkerHeritage.length > 0 ? buildHeritageMap(deferredWorkerHeritage, ctx, getHeritageStrategyForLanguage) @@ -621,5 +737,12 @@ export async function runChunkedParseAndResolve( // chunk-local `astCache` above is intentionally NOT exposed // because parse-impl clears it between chunks. scopeTreeCache, + // Per-file ParsedFile artifacts produced by workers' calls to + // `extractParsedFile`. Empty when only the sequential path ran + // (sequential doesn't go through the worker, and extracts ParsedFile + // inline rather than emitting it). Consumed by scope-resolution as + // a re-extraction cache: when the file's ParsedFile is here, + // scope-resolution skips its own `extractParsedFile` call. + parsedFiles: allParsedFiles, }; } diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse.ts index a20d1e4b0..a3fa81be7 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse.ts @@ -20,6 +20,7 @@ import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js'; import { getPhaseOutput } from './types.js'; import type { StructureOutput } from './structure.js'; import type { BindingAccumulator } from '../binding-accumulator.js'; +import type { ParsedFile } from 'gitnexus-shared'; import type { ExtractedFetchCall, ExtractedRoute, @@ -81,6 +82,19 @@ export interface ParseOutput { * `scopeTreeCache.clear()` after its extract loop finishes. */ readonly scopeTreeCache: ASTCache; + /** + * Per-file `ParsedFile` artifacts produced by workers' calls to + * `extractParsedFile`. Threaded through to `scopeResolutionPhase` + * as a re-extraction cache: when a file's ParsedFile is present here, + * scope-resolution can skip its own `extractParsedFile` (which would + * otherwise re-parse the file with tree-sitter on the main thread, + * costing ~58s on a 1000-file repo). + * + * Empty for files that went through the sequential parse fallback — + * sequential doesn't emit ParsedFile artifacts; scope-resolution + * falls back to a fresh extract for those. + */ + readonly parsedFiles: readonly ParsedFile[]; } export const parsePhase: PipelinePhase = { diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index c220ea224..1ee8e102f 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -55,6 +55,19 @@ export interface PipelineOptions { minFiles?: number; minBytes?: number; }; + /** + * Incremental-indexing parse cache. When provided: + * - The parse phase looks up each chunk's content hash in + * `parseCache.entries`. On hit, it replays the cached + * `ParseWorkerResult[]` instead of dispatching to workers. + * - On miss, it runs the workers as today and stores the new + * results in `parseCache.entries` keyed by chunk hash. + * The caller (`run-analyze.ts`) is responsible for loading the cache + * before the pipeline runs and persisting it after. Cache survives + * `--force` because keys are content-addressed. + * See `gitnexus/src/storage/parse-cache.ts`. + */ + parseCache?: import('../../storage/parse-cache.js').ParseCache; } // ── Phase registry ───────────────────────────────────────────────────────── diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index 713b32c07..6818007a3 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -71,6 +71,8 @@ export const MIGRATED_LANGUAGES: ReadonlySet = new Set; + /** + * Optional parallel MRO that EXCLUDES mixin-like augmentation (e.g., PHP + * traits). Returns the inheritance-only ancestor chain — the same kind + * of map as `buildMro` but built only from inheritance edges (EXTENDS). + * + * Used by the shared super-branch dispatch in `receiver-bound-calls` + * so that `parent::method()` walks the inheritance chain only, not the + * trait-augmented one. PHP semantics: `parent::` explicitly bypasses + * traits, even when a composed trait shadows a same-named parent method. + * + * Languages without mixin-like semantics leave this undefined — callers + * fall back to `buildMro`/`mroFor`, which for those languages is already + * the inheritance chain. + */ + readonly buildExtendsOnlyMro?: ( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + ) => Map; + /** * Mutate `parsed.localDefs[i].ownerId` to point at the structural * owner. Python's rule: methods (Function defs whose parent scope @@ -472,6 +492,38 @@ export interface ScopeResolver { */ readonly allowGlobalFreeCallFallback?: boolean; + /** + * Optional predicate to identify definitions with file-local linkage + * (e.g. C `static` functions). When provided, `pickUniqueGlobalCallable` + * excludes defs where `isFileLocalDef(def) === true` and the def lives + * in a different file from the caller. This prevents the global free-call + * fallback from creating CALLS edges to file-local symbols that are + * logically invisible from the caller's translation unit. + * + * Languages without file-local linkage semantics leave this undefined. + */ + readonly isFileLocalDef?: (def: SymbolDefinition) => boolean; + + /** + * Optional predicate to gate free-call fallback emission by caller-side + * visibility. When provided, `pickUniqueGlobalCallable` rejects candidates + * the caller cannot legally reach — e.g., a PHP function in a different + * namespace with no `use function` import, which PHP runtime would treat + * as `Call to undefined function`. Returning `false` blocks the candidate; + * returning `true` allows it; undefined-default keeps current behavior + * (no visibility filtering, equivalent to "all candidates visible"). + * + * The hook receives the caller's `ParsedFile` (so it can consult + * `parsedImports`, `moduleScope`, etc.) and the candidate `SymbolDefinition`. + * The predicate must be pure: same inputs → same answer. + * + * Languages without namespace-scoped function resolution leave this undefined. + */ + readonly isCallableVisibleFromCaller?: (ctx: { + readonly callerParsed: ParsedFile; + readonly candidate: SymbolDefinition; + }) => boolean; + /** * Optional post-finalize hook to inject cross-file bindings that * aren't modeled via explicit imports. Runs after @@ -564,4 +616,32 @@ export interface ScopeResolver { readonly treeCache?: { get(filePath: string): unknown }; }, ) => void; + + /** + * Optional post-resolution pass: emit CALLS edges for member-call sites + * whose receiver cannot be typed by the scope chain (no `TypeRef`). + * Dynamically-typed languages with untyped/`mixed`/`Any` parameters use + * this hook to recover the call edge via workspace-wide method-name + * lookup, mirroring what their legacy resolvers did. + * + * Runs AFTER `emitReceiverBoundCalls` and BEFORE `emitFreeCallFallback`. + * Implementations MUST: + * - Skip sites already in `handledSites` (Invariant I2). + * - Add resolved site keys to `handledSites` before returning. + * - Stay narrow: a unique workspace-wide match is the safe baseline. + * Multi-candidate fallbacks should narrow by arity / argument types + * before emitting to keep false-positive rate bounded. + * + * Returns the number of edges emitted (for telemetry). + * + * Default: undefined (no unresolved-receiver fallback). + */ + readonly emitUnresolvedReceiverEdges?: ( + graph: KnowledgeGraph, + scopes: ScopeResolutionIndexes, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + handledSites: Set, + model: SemanticModel, + ) => number; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/method-dispatch.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/method-dispatch.ts index 164147ac6..419ab478f 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/method-dispatch.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/method-dispatch.ts @@ -22,8 +22,9 @@ const EMPTY_DEFS: readonly string[] = Object.freeze([]); export function buildPopulatedMethodDispatch( mroByDefId: ReadonlyMap, + extendsOnlyMroByDefId?: ReadonlyMap, ): MethodDispatchIndex { - return { + const base: MethodDispatchIndex = { mroByOwnerDefId: mroByDefId, implsByInterfaceDefId: new Map(), mroFor(ownerDefId) { @@ -33,4 +34,14 @@ export function buildPopulatedMethodDispatch( return EMPTY_DEFS; }, }; + if (extendsOnlyMroByDefId !== undefined) { + return { + ...base, + extendsOnlyMroByOwnerDefId: extendsOnlyMroByDefId, + extendsOnlyMroFor(ownerDefId) { + return extendsOnlyMroByDefId.get(ownerDefId) ?? EMPTY_DEFS; + }, + }; + } + return base; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts index 70aa875fb..c3b53c6f7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts @@ -117,6 +117,11 @@ export function isLinkableLabel(label: NodeLabel): boolean { label === 'Interface' || label === 'Struct' || label === 'Enum' || + // Trait nodes are linkable so MRO builders can bridge PHP/Rust trait + // defs between scope-resolution DefIds and the graph's node ids. + // IMPLEMENTS edges from classes to traits are otherwise invisible to + // the scope-resolution MRO pass. + label === 'Trait' || // Variable / Property are linkable too — receiver-bound write/read // ACCESSES edges target field nodes (e.g. `user.name = "x"` → // ACCESSES edge to User's `name` Variable/Property node). diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index 248ee439f..eb6dd71fb 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -36,7 +36,14 @@ export function emitFreeCallFallback( handledSites: Set, model: SemanticModel, workspaceIndex: WorkspaceResolutionIndex, - options: { readonly allowGlobalFallback?: boolean } = {}, + options: { + readonly allowGlobalFallback?: boolean; + readonly isFileLocalDef?: (def: SymbolDefinition) => boolean; + readonly isCallableVisibleFromCaller?: (ctx: { + readonly callerParsed: ParsedFile; + readonly candidate: SymbolDefinition; + }) => boolean; + } = {}, ): number { let emitted = 0; const seen = new Set(); @@ -73,7 +80,18 @@ export function emitFreeCallFallback( // the caller does not import the target package. Same-package calls are // caught by findCallableBindingInScope above before reaching here. if (fnDef === undefined && options.allowGlobalFallback === true) { - fnDef = pickUniqueGlobalCallable(site.name, model, scopes); + fnDef = pickUniqueGlobalCallable( + site.name, + model, + scopes, + parsed.filePath, + options.isFileLocalDef, + site.arity, + options.isCallableVisibleFromCaller !== undefined + ? (candidate) => + options.isCallableVisibleFromCaller!({ callerParsed: parsed, candidate }) + : undefined, + ); } if (fnDef === undefined) continue; const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup); @@ -107,6 +125,10 @@ function pickUniqueGlobalCallable( name: string, model: SemanticModel, scopes: ScopeResolutionIndexes, + callerFilePath: string, + isFileLocalDef?: (def: SymbolDefinition) => boolean, + callArity?: number, + isCallerVisible?: (candidate: SymbolDefinition) => boolean, ): SymbolDefinition | undefined { const scopeDefs: SymbolDefinition[] = []; const scopeSeen = new Set(); @@ -114,6 +136,18 @@ function pickUniqueGlobalCallable( const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName; if (simple !== name) continue; if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue; + // Skip file-local defs (e.g. C `static` functions) that live in a + // different file from the caller — they are logically invisible. + if (isFileLocalDef !== undefined && def.filePath !== callerFilePath && isFileLocalDef(def)) { + continue; + } + // Caller-side visibility filter (e.g., PHP namespace + use-function + // import gating). When defined, blocks candidates the caller cannot + // legally reach. Languages without namespace-scoped function resolution + // leave this undefined → no filtering. + if (isCallerVisible !== undefined && !isCallerVisible(def)) { + continue; + } const key = logicalCallableKey(def); if (scopeSeen.has(key)) continue; scopeSeen.add(key); @@ -121,10 +155,29 @@ function pickUniqueGlobalCallable( } if (scopeDefs.length === 1) return scopeDefs[0]; + // When multiple scope-index candidates exist, attempt arity narrowing + // before falling back to the semantic-model lookup. This handles + // registry-primary languages where the model is not populated for the + // migrated language's files (call-processor skips them). + if (scopeDefs.length > 1 && callArity !== undefined) { + const arityMatch = narrowByArity(scopeDefs, callArity); + if (arityMatch !== undefined) return arityMatch; + } + const defs: SymbolDefinition[] = []; const seen = new Set(); const push = (pool: readonly SymbolDefinition[]): void => { for (const def of pool) { + // Apply the same file-local linkage filter as Phase 1 — + // cross-file static defs must never leak through the + // SemanticModel fallback path. + if (isFileLocalDef !== undefined && def.filePath !== callerFilePath && isFileLocalDef(def)) { + continue; + } + // Same caller-visibility filter applied to the model-side pool. + if (isCallerVisible !== undefined && !isCallerVisible(def)) { + continue; + } const key = logicalCallableKey(def); if (seen.has(key)) continue; seen.add(key); @@ -135,7 +188,35 @@ function pickUniqueGlobalCallable( push(model.symbols.lookupCallableByName(name)); push(model.methods.lookupMethodByName(name)); - return defs.length === 1 ? defs[0] : undefined; + if (defs.length === 1) return defs[0]; + + // When multiple candidates exist and the call site has a known arity, + // narrow by parameter count. + if (defs.length > 1 && callArity !== undefined) { + const arityMatch = narrowByArity(defs, callArity); + if (arityMatch !== undefined) return arityMatch; + } + + return undefined; +} + +/** + * Narrow a list of callable candidates by call-site arity. + * A def is compatible when `requiredParameterCount <= arity <= parameterCount`. + * Defs with `parameterCount === undefined` (variadic/unknown) are always kept. + * Returns the single compatible def, or `undefined` when zero or multiple match. + */ +function narrowByArity( + defs: readonly SymbolDefinition[], + callArity: number, +): SymbolDefinition | undefined { + const compatible = defs.filter((d) => { + const total = d.parameterCount; + if (total === undefined) return true; // unknown arity — keep + const required = d.requiredParameterCount ?? total; + return required <= callArity && callArity <= total; + }); + return compatible.length === 1 ? compatible[0] : undefined; } function logicalCallableKey(def: SymbolDefinition): string { @@ -167,10 +248,18 @@ function pickConstructorOrClass( /** Walk up from the call-site scope to the enclosing class scope, * pick a method member by name with overload narrowing on arity + - * argument types. Returns undefined if there's no enclosing class - * or no matching method. Used for implicit-this calls inside a - * class body where multiple overloads share the call name. */ -function pickImplicitThisOverload( + * argument types. Returns undefined if there's no enclosing class, + * no matching method, OR narrowing leaves multiple compatible + * candidates — in the multi-candidate case, picking + * `candidates[0]` would emit a high-confidence CALLS edge whose + * target depends on registration order rather than a defensible + * resolution. Mirrors `pickUniqueGlobalCallable`'s uniqueness check + * in the same file (Codex PR #1497 review, finding 2). + * + * Exported for unit testing — language-agnostic logic, exercised + * via synthetic stubs in `pick-implicit-this-overload.test.ts`. The + * production call site is `applyFreeCallFallback` immediately above. */ +export function pickImplicitThisOverload( site: { readonly inScope: ScopeId; readonly name: string; @@ -203,6 +292,11 @@ function pickImplicitThisOverload( if (overloads.length === 0) return undefined; if (overloads.length === 1) return overloads[0]; + // Narrow on arity + argument types. Require a UNIQUE survivor — + // ambiguous narrowing (multiple compatible candidates with no + // disambiguating signal) leaves the call unresolved rather than + // routing to an arbitrary first overload by registration order. const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes); + if (candidates.length !== 1) return undefined; return candidates[0]; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts index 922afb36c..f36287052 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts @@ -13,9 +13,13 @@ * 2. Exact-required-match wins over variadic. Variadic is detected * via a `parameterTypes` entry equal to `'params'` or starting * with `'params '` (C# `params` / variadic marker). - * 3. If the arity filter empties the set, fall back to the full - * overload list rather than returning nothing — the caller still - * needs a best-effort candidate. + * 3. If the arity filter empties the set AND any candidate had + * unknown bounds (both `parameterCount` and `requiredParameterCount` + * undefined), fall back to the full overload list — the empty + * result may be due to missing metadata rather than a real mismatch. + * If EVERY rejected candidate had definite arity bounds, trust the + * filter and return empty — the call is genuinely arity-incompatible + * (e.g., PHP `f(int $req, ...$rest)` called with zero args). * 4. If `argTypes` is present, filter further by per-slot type * equality. An empty string in `argTypes[i]` means "unknown" and * counts as a match. Mismatches disqualify. A non-empty typed @@ -39,6 +43,16 @@ export function narrowOverloadCandidates( const max = d.parameterCount; const min = d.requiredParameterCount; if (max !== undefined && argCount > max) { + // Variadic marker check is C#-specific (the 'params' keyword). + // Other languages use their own marker — PHP uses '...' (see + // `languages/php/arity-metadata.ts:46`), Python uses '*args'- + // shaped metadata that lives outside `parameterTypes` entirely. + // This branch is dead code for those languages because they + // set `parameterCount = undefined` for variadic functions, + // which keeps `max` undefined and skips this check entirely. + // Adding new variadic markers here changes behavior for those + // other languages too — don't extend without auditing each + // adapter's `arity-metadata.ts`. Finding 9 of PR #1497. const variadic = d.parameterTypes !== undefined && d.parameterTypes.some((t) => t === 'params' || t.startsWith('params ')); @@ -48,8 +62,16 @@ export function narrowOverloadCandidates( return true; }); + // When the arity filter empties the set, only fall back to the full + // overload list if some candidate had unknown bounds — otherwise the + // empty result is authoritative (every candidate definitively failed + // arity, e.g., PHP variadic with required-prefix called with too few + // args). + const anyUnknownBounds = overloads.some( + (d) => d.parameterCount === undefined && d.requiredParameterCount === undefined, + ); const candidates: readonly SymbolDefinition[] = - arityMatches.length > 0 ? arityMatches : overloads; + arityMatches.length > 0 ? arityMatches : anyUnknownBounds ? overloads : []; if (argTypes !== undefined && argTypes.length > 0) { const typed = candidates.filter((d) => { diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 925151b6c..0cb544db9 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -165,7 +165,16 @@ export function emitReceiverBoundCalls( if (provider.isSuperReceiver(receiverName)) { const enclosingClass = findEnclosingClassDef(site.inScope, scopes); if (enclosingClass !== undefined) { - const ancestors = scopes.methodDispatch.mroFor(enclosingClass.nodeId); + // For super-receiver dispatch (`parent::`, `base.`, `super()`), + // walk the inheritance-only ancestor chain when the language + // exposes it. PHP's `parent::` semantically bypasses composed + // traits; other languages without mixin augmentation have no + // `extendsOnlyMroFor` and fall back to `mroFor`. + const extendsOnly = scopes.methodDispatch.extendsOnlyMroFor; + const ancestors = + extendsOnly !== undefined + ? extendsOnly(enclosingClass.nodeId) + : scopes.methodDispatch.mroFor(enclosingClass.nodeId); let memberDef: SymbolDefinition | undefined; for (const ownerId of ancestors) { memberDef = findOwnedMember(ownerId, memberName, model); @@ -283,7 +292,21 @@ export function emitReceiverBoundCalls( let memberDef: SymbolDefinition | undefined; for (const ownerId of chain) { memberDef = findOwnedMember(ownerId, memberName, model); - if (memberDef !== undefined) break; + if (memberDef !== undefined) { + // The MRO chain is most-derived-first ([classDef, ...ancestors]). + // If the most-derived definition is arity-incompatible with the + // call site, PHP throws ArgumentCountError at runtime — it does + // NOT silently dispatch to an ancestor. Terminate the chain walk + // so no edge is emitted, rather than falling through to an + // arity-compatible ancestor (which would be a false positive). + if ( + narrowOverloadCandidates([memberDef], site.arity, site.argumentTypes).length === 0 + ) { + memberDef = undefined; + break; + } + break; + } } if (memberDef !== undefined) { const reason = diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts index c2fda9777..98a9f8994 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts @@ -93,13 +93,25 @@ export const scopeResolutionPhase: PipelinePhase = { // Worker-mode parses leave the cache empty for those files; they // also fall back to a fresh parse — no correctness impact. const parseOutput = getPhaseOutput(deps, 'parse'); - const { scopeTreeCache, resolutionContext } = parseOutput; + const { scopeTreeCache, resolutionContext, parsedFiles: workerParsedFiles } = parseOutput; // SemanticModel populated during `parse`: scope-resolution consumes // TypeRegistry / MethodRegistry / SymbolTable lookups instead of // rebuilding parallel indexes. See ARCHITECTURE.md § "Semantic-model // source of truth". const model = resolutionContext.model; + // Build a per-file lookup of ParsedFile artifacts the workers (or + // sequential extracts) already produced. Threading this into + // `runScopeResolution` lets the per-language extract loop short- + // circuit `extractParsedFile` — the dominant cost on the warm-cache + // path, since workers can't return tree-sitter Trees across the + // MessageChannel and scope-resolution would otherwise re-parse + // every file from scratch on the main thread. + const preExtractedByPath = new Map(); + for (const pf of workerParsedFiles) { + preExtractedByPath.set(pf.filePath, pf); + } + let totalFiles = 0; let totalImports = 0; let totalRefs = 0; @@ -143,6 +155,7 @@ export const scopeResolutionPhase: PipelinePhase = { files, treeCache: scopeTreeCache, resolutionConfig, + preExtractedParsedFiles: preExtractedByPath, onWarn: (msg) => { if (isSemanticModelValidatorEnabled()) { logger.warn(`[scope-resolution:${lang}] ${msg}`); diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts index 3d9915fd7..c606661c8 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts @@ -15,6 +15,9 @@ import { pythonScopeResolver } from '../../languages/python/scope-resolver.js'; import { csharpScopeResolver } from '../../languages/csharp/scope-resolver.js'; import { typescriptScopeResolver } from '../../languages/typescript/scope-resolver.js'; import { goScopeResolver } from '../../languages/go/scope-resolver.js'; +import { javaScopeResolver } from '../../languages/java/scope-resolver.js'; +import { cScopeResolver } from '../../languages/c/scope-resolver.js'; +import { phpScopeResolver } from '../../languages/php/scope-resolver.js'; /** Map of `SupportedLanguages` → `ScopeResolver`. The phase iterates * this map intersected with `MIGRATED_LANGUAGES` (the per-language @@ -28,4 +31,7 @@ export const SCOPE_RESOLVERS: ReadonlyMap = n [SupportedLanguages.CSharp, csharpScopeResolver], [SupportedLanguages.TypeScript, typescriptScopeResolver], [SupportedLanguages.Go, goScopeResolver], + [SupportedLanguages.Java, javaScopeResolver], + [SupportedLanguages.C, cScopeResolver], + [SupportedLanguages.PHP, phpScopeResolver], ]); diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 558c9ef30..47f8a3551 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -72,6 +72,22 @@ interface RunScopeResolutionInput { * provider doesn't supply a config loader. */ readonly resolutionConfig?: unknown; + /** + * Pre-extracted ParsedFile artifacts keyed by file path. When a + * file is present here, the extract loop reuses it directly and + * skips `extractParsedFile` (which would re-parse the file with + * tree-sitter on the main thread). Only files matching the + * provider's language are honored — the loop verifies this + * implicitly by language filter at the call-site (scopeResolution + * phase). + * + * Worker-mode parses produce these ParsedFile artifacts as a side + * effect of `extractParsedFile` running inside the worker; threading + * them here is what lets the warm-cache analyze run skip the ~58s + * scope-resolution re-parse loop on a multi-thousand-file repo. + * Cache miss is safe — falls back to fresh extract. + */ + readonly preExtractedParsedFiles?: ReadonlyMap; } interface RunScopeResolutionStats { @@ -104,22 +120,37 @@ export function runScopeResolution( const parsedFiles: ParsedFile[] = []; let filesSkipped = 0; const treeCache = input.treeCache; + const preExtracted = input.preExtractedParsedFiles; + let preExtractedHits = 0; for (const file of files) { - const cachedTree = treeCache?.get(file.path); - const parsed = extractParsedFile( - provider.languageProvider, - file.content, - file.path, - onWarn, - cachedTree, - ); + let parsed: ParsedFile | undefined; + // Fast path: a worker (during the parse phase) already produced a + // ParsedFile for this file via `extractParsedFile`. Reuse it + // directly — skips a tree-sitter re-parse on the main thread. + if (preExtracted !== undefined) { + parsed = preExtracted.get(file.path); + if (parsed !== undefined) preExtractedHits++; + } if (parsed === undefined) { - filesSkipped++; - continue; + const cachedTree = treeCache?.get(file.path); + parsed = extractParsedFile( + provider.languageProvider, + file.content, + file.path, + onWarn, + cachedTree, + ); + if (parsed === undefined) { + filesSkipped++; + continue; + } } provider.populateOwners(parsed); parsedFiles.push(parsed); } + if (PROF && preExtracted !== undefined) { + logger.warn(`[scope-resolution prof] pre-extracted hits: ${preExtractedHits}/${files.length}`); + } provider.populateWorkspaceOwners?.(parsedFiles, { fileContents: getFileContents() }); // Reconcile scope-resolution's ownership view into the SemanticModel. @@ -153,6 +184,7 @@ export function runScopeResolution( const allFilePaths = new Set(parsedFiles.map((f) => f.filePath)); const nodeLookup = buildGraphNodeLookup(graph); const mroByClassDefId = provider.buildMro(graph, parsedFiles, nodeLookup); + const extendsOnlyMroByClassDefId = provider.buildExtendsOnlyMro?.(graph, parsedFiles, nodeLookup); const resolutionConfig = input.resolutionConfig; const finalized = finalizeScopeModel(parsedFiles, { @@ -174,7 +206,7 @@ export function runScopeResolution( // the type system. const indexes = { ...finalized, - methodDispatch: buildPopulatedMethodDispatch(mroByClassDefId), + methodDispatch: buildPopulatedMethodDispatch(mroByClassDefId, extendsOnlyMroByClassDefId), }; // Build the workspace resolution index ONCE — scope-valued lookups @@ -252,6 +284,17 @@ export function runScopeResolution( workspaceIndex, readonlyModel, ); + const unresolvedReceiverExtras = + provider.emitUnresolvedReceiverEdges !== undefined + ? provider.emitUnresolvedReceiverEdges( + graph, + indexes, + parsedFiles, + nodeLookup, + handledSites, + readonlyModel, + ) + : 0; const freeCallExtras = emitFreeCallFallback( graph, indexes, @@ -261,7 +304,11 @@ export function runScopeResolution( handledSites, readonlyModel, workspaceIndex, - { allowGlobalFallback: provider.allowGlobalFreeCallFallback === true }, + { + allowGlobalFallback: provider.allowGlobalFreeCallFallback === true, + isFileLocalDef: provider.isFileLocalDef, + isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller, + }, ); const { emitted, skipped } = emitReferencesViaLookup( graph, @@ -296,7 +343,7 @@ export function runScopeResolution( filesSkipped, importsEmitted, resolve: resolveStats, - referenceEdgesEmitted: emitted + receiverExtras + freeCallExtras, + referenceEdgesEmitted: emitted + receiverExtras + unresolvedReceiverExtras + freeCallExtras, referenceSkipped: skipped, }; } diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 8e165a837..d65229808 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -1020,6 +1020,16 @@ export const PHP_QUERIES = ` (use_declaration [(name) (qualified_name)] @heritage.trait))) @heritage +; ── Heritage: trait uses another trait (transitive trait composition) ──────── +; PHP allows a trait body to contain "use OtherTrait;". The trait-uses-trait +; IMPLEMENTS edge is required by buildPhpMro to compute the full transitive +; trait closure (depth 3+ chains). +(trait_declaration + name: (name) @heritage.class + body: (declaration_list + (use_declaration + [(name) (qualified_name)] @heritage.trait))) @heritage + ; PHP HTTP consumers: file_get_contents('/path'), curl_init('/path') (function_call_expression function: (name) @_php_http (#match? @_php_http "^(file_get_contents|curl_init)$") diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 4442b5a65..def4e1299 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -20,6 +20,7 @@ import { getTreeSitterContentByteLength, TREE_SITTER_MAX_BUFFER, } from '../constants.js'; +import { parseSourceSafe } from '../../tree-sitter/safe-parse.js'; import type { SymbolTableReader } from '../model/symbol-table.js'; import type { ExtractedHeritage } from '../model/heritage-map.js'; @@ -1416,7 +1417,7 @@ const processFileGroup = ( let tree; try { - tree = parser.parse(parseContent, undefined, { + tree = parseSourceSafe(parser, parseContent, undefined, { bufferSize: getTreeSitterBufferSize(parseContent), }); } catch (err) { diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index cb88986ca..cf8f1cb71 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -218,6 +218,65 @@ const runWithSessionLock = async (operation: () => Promise): Promise => const normalizeCopyPath = (filePath: string): string => filePath.replace(/\\/g, '/'); +const closeQueryResult = async (result: lbug.QueryResult): Promise => { + try { + await result.close(); + } catch { + // Best-effort cleanup only. + } +}; + +const drainQueryResult = async ( + queryResult: lbug.QueryResult | lbug.QueryResult[], +): Promise => { + const results = Array.isArray(queryResult) ? queryResult : [queryResult]; + let firstError: unknown; + let hasError = false; + for (const result of results) { + try { + await result.getAll(); + } catch (err) { + if (!hasError) { + firstError = err; + hasError = true; + } + } finally { + await closeQueryResult(result); + } + } + if (hasError) throw firstError; +}; + +const readQueryRows = async ( + queryResult: lbug.QueryResult | lbug.QueryResult[], +): Promise => { + const results = Array.isArray(queryResult) ? queryResult : [queryResult]; + let rows: any[] = []; + let firstError: unknown; + let hasError = false; + for (let i = 0; i < results.length; i++) { + const result = results[i]; + try { + const resultRows = await result.getAll(); + if (i === 0) rows = resultRows; + } catch (err) { + if (!hasError) { + firstError = err; + hasError = true; + } + } finally { + await closeQueryResult(result); + } + } + if (hasError) throw firstError; + return rows; +}; + +const queryAndDrain = async (targetConn: lbug.Connection, cypher: string): Promise => { + const queryResult = await targetConn.query(cypher); + await drainQueryResult(queryResult); +}; + export const initLbug = async (dbPath: string) => { return runWithSessionLock(() => ensureLbugInitialized(dbPath)); }; @@ -319,7 +378,7 @@ const doInitLbug = async (dbPath: string) => { for (const schemaQuery of SCHEMA_QUERIES) { try { - await conn.query(schemaQuery); + await queryAndDrain(conn, schemaQuery); } catch (err) { const msg = err instanceof Error ? err.message : String(err); // Suppression list: @@ -384,14 +443,14 @@ export const loadGraphToLbug = async ( const copyQuery = getCopyQuery(table, normalizedPath); try { - await conn.query(copyQuery); + await queryAndDrain(conn, copyQuery); } catch (err) { try { const retryQuery = copyQuery.replace( 'auto_detect=false)', 'auto_detect=false, IGNORE_ERRORS=true)', ); - await conn.query(retryQuery); + await queryAndDrain(conn, retryQuery); } catch (retryErr) { const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr); throw new Error(`COPY failed for ${table}: ${retryMsg.slice(0, 200)}`); @@ -433,14 +492,14 @@ export const loadGraphToLbug = async ( } try { - await conn.query(copyQuery); + await queryAndDrain(conn, copyQuery); } catch (err) { try { const retryQuery = copyQuery.replace( 'auto_detect=false)', 'auto_detect=false, IGNORE_ERRORS=true)', ); - await conn.query(retryQuery); + await queryAndDrain(conn, retryQuery); } catch (retryErr) { const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr); warnings.push(`${fromLabel}->${toLabel} (${rows} edges): ${retryMsg.slice(0, 80)}`); @@ -562,11 +621,14 @@ const fallbackRelationshipInserts = async ( const esc = (s: string) => s.replace(/'/g, "''").replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/\r/g, '\\r'); - await conn.query(` + await queryAndDrain( + conn, + ` MATCH (a:${escapeLabel(fromLabel)} {id: '${esc(fromId)}' }), (b:${escapeLabel(toLabel)} {id: '${esc(toId)}' }) CREATE (a)-[:${REL_TABLE_NAME} {type: '${esc(relType)}', confidence: ${confidence}, reason: '${esc(reason)}', step: ${step}}]->(b) - `); + `, + ); } catch { // skip } @@ -679,14 +741,14 @@ export const insertNodeToLbug = async ( if (targetDbPath) { const tempHandle = await openLbugConnection(lbug, targetDbPath); try { - await tempHandle.conn.query(query); + await queryAndDrain(tempHandle.conn, query); return true; } finally { await closeLbugConnection(tempHandle); } } else if (conn) { // Use existing persistent connection (when called from analyze) - await conn.query(query); + await queryAndDrain(conn, query); return true; } @@ -757,7 +819,7 @@ export const batchInsertNodesToLbug = async ( query = `MERGE (n:${t} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.content = ${escapeValue(properties.content || '')}${descPart}`; } - await tempConn.query(query); + await queryAndDrain(tempConn, query); inserted++; } catch (e: any) { // Don't console.error here - it corrupts MCP JSON-RPC on stderr @@ -777,11 +839,7 @@ export const executeQuery = async (cypher: string): Promise => { } const queryResult = await conn.query(cypher); - // LadybugDB uses getAll() instead of hasNext()/getNext() - // Query returns QueryResult for single queries, QueryResult[] for multi-statement - const result = Array.isArray(queryResult) ? queryResult[0] : queryResult; - const rows = await result.getAll(); - return rows; + return await readQueryRows(queryResult); }; export const streamQuery = async ( @@ -793,8 +851,10 @@ export const streamQuery = async ( } const queryResult = await conn.query(cypher); - const result = Array.isArray(queryResult) ? queryResult[0] : queryResult; + const results = Array.isArray(queryResult) ? queryResult : [queryResult]; + const result = results[0]; let rowCount = 0; + let streamError: unknown; try { while (await result.hasNext()) { @@ -803,11 +863,14 @@ export const streamQuery = async ( rowCount++; } return rowCount; + } catch (err) { + streamError = err; + throw err; } finally { try { - await result.close(); - } catch { - // Best-effort cleanup only. + await drainQueryResult(results); + } catch (err) { + if (streamError === undefined) throw err; } } }; @@ -829,8 +892,7 @@ export const executePrepared = async ( throw new Error(`Prepare failed: ${errMsg}`); } const queryResult = await conn.execute(stmt, params); - const result = Array.isArray(queryResult) ? queryResult[0] : queryResult; - return await result.getAll(); + return await readQueryRows(queryResult); }; export const executeWithReusedStatement = async ( @@ -852,7 +914,7 @@ export const executeWithReusedStatement = async ( } try { for (const params of subBatch) { - await conn.execute(stmt, params); + await drainQueryResult(await conn.execute(stmt, params)); } } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -874,8 +936,7 @@ export const getLbugStats = async (): Promise<{ nodes: number; edges: number }> const queryResult = await conn.query( `MATCH (n:${escapeTableName(tableName)}) RETURN count(n) AS cnt`, ); - const nodeResult = Array.isArray(queryResult) ? queryResult[0] : queryResult; - const nodeRows = await nodeResult.getAll(); + const nodeRows = await readQueryRows(queryResult); if (nodeRows.length > 0) { totalNodes += Number(nodeRows[0]?.cnt ?? nodeRows[0]?.[0] ?? 0); } @@ -889,8 +950,7 @@ export const getLbugStats = async (): Promise<{ nodes: number; edges: number }> const queryResult = await conn.query( `MATCH ()-[r:${REL_TABLE_NAME}]->() RETURN count(r) AS cnt`, ); - const edgeResult = Array.isArray(queryResult) ? queryResult[0] : queryResult; - const edgeRows = await edgeResult.getAll(); + const edgeRows = await readQueryRows(queryResult); if (edgeRows.length > 0) { totalEdges = Number(edgeRows[0]?.cnt ?? edgeRows[0]?.[0] ?? 0); } @@ -926,8 +986,7 @@ export const loadCachedEmbeddings = async (): Promise<{ const check = await conn.query( `MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex LIMIT 1`, ); - const checkResult = Array.isArray(check) ? check[0] : check; - await checkResult.getAll(); + await readQueryRows(check); } catch { return { embeddingNodeIds: new Set(), embeddings: [] }; } @@ -951,8 +1010,7 @@ export const loadCachedEmbeddings = async (): Promise<{ throw err; } } - const result = Array.isArray(rows) ? rows[0] : rows; - for (const row of await result.getAll()) { + for (const row of await readQueryRows(rows)) { const nodeId = String(row.nodeId ?? row[0] ?? ''); if (!nodeId) continue; embeddingNodeIds.add(nodeId); @@ -1060,7 +1118,8 @@ export const fetchExistingEmbeddingHashes = async ( export const flushWAL = async (): Promise => { if (!conn) return; try { - await conn.query('CHECKPOINT'); + const checkpointResult = await conn.query('CHECKPOINT'); + await drainQueryResult(checkpointResult); } catch { /* ignore — older LadybugDB or schemaless DB may not accept it */ } @@ -1170,13 +1229,13 @@ export const deleteNodesForFile = async ( const countResult = await targetConn!.query( `MATCH (n:${tn}) WHERE n.filePath = '${escapedPath}' RETURN count(n) AS cnt`, ); - const result = Array.isArray(countResult) ? countResult[0] : countResult; - const rows = await result.getAll(); + const rows = await readQueryRows(countResult); const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0); if (count > 0) { // Delete nodes (and implicitly their relationships via DETACH) - await targetConn!.query( + await queryAndDrain( + targetConn!, `MATCH (n:${tn}) WHERE n.filePath = '${escapedPath}' DETACH DELETE n`, ); deletedNodes += count; @@ -1188,7 +1247,8 @@ export const deleteNodesForFile = async ( // Also delete any embeddings for nodes in this file try { - await targetConn!.query( + await queryAndDrain( + targetConn!, `MATCH (e:${EMBEDDING_TABLE_NAME}) WHERE e.nodeId STARTS WITH '${escapedPath}' DELETE e`, ); } catch { @@ -1204,6 +1264,77 @@ export const deleteNodesForFile = async ( export const getEmbeddingTableName = (): string => EMBEDDING_TABLE_NAME; +/** + * Return the distinct repo-relative paths of files that import + * `targetFilePath` according to the IMPORTS edges currently in the + * DB. Used by the incremental writeback path to expand the + * "files-to-rewrite" set so that files importing a changed file get + * their edges (which may have been refined by cross-file resolution) + * re-emitted, rather than left stale in the DB. + * + * The DB query reads the *previous* run's state — pre-pipeline, before + * any nodes are deleted — so the returned importers are "files that + * USED TO import the target". That's the right set to invalidate: + * those are the files whose edges in the DB might no longer match + * what cross-file resolution produces given the changed file's new + * exports. + */ +export const queryImporters = async (targetFilePath: string): Promise => { + if (!conn) { + throw new Error('LadybugDB not initialized. Call initLbug first.'); + } + const escaped = targetFilePath.replace(/'/g, "''"); + const cypher = ` + MATCH (a)-[r:${REL_TABLE_NAME}]->(b) + WHERE r.type = 'IMPORTS' AND b.filePath = '${escaped}' + RETURN DISTINCT a.filePath AS importer + `; + try { + const queryResult = await conn.query(cypher); + const result = Array.isArray(queryResult) ? queryResult[0] : queryResult; + const rows = await result.getAll(); + const out: string[] = []; + for (const row of rows) { + const v = (row as { importer?: unknown }).importer; + if (typeof v === 'string' && v.length > 0) out.push(v); + } + return out; + } catch { + return []; + } +}; + +/** + * Drop every Community and Process node (and their MEMBER_OF / + * STEP_IN_PROCESS edges via DETACH DELETE). Used at the start of an + * incremental run so the communities and processes phases regenerate + * them from scratch on the merged graph — required for the + * "Leiden runs on the FULL graph" correctness invariant. + */ +export const deleteAllCommunitiesAndProcesses = async (): Promise<{ + nodesDeleted: number; +}> => { + if (!conn) { + throw new Error('LadybugDB not initialized. Call initLbug first.'); + } + let nodesDeleted = 0; + for (const label of ['Community', 'Process']) { + try { + const countResult = await conn.query(`MATCH (n:${label}) RETURN count(n) AS cnt`); + const result = Array.isArray(countResult) ? countResult[0] : countResult; + const rows = await result.getAll(); + const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0); + if (count > 0) { + await conn.query(`MATCH (n:${label}) DETACH DELETE n`); + nodesDeleted += count; + } + } catch { + // Table may not exist yet on a freshly-initialized DB — fine. + } + } + return { nodesDeleted }; +}; + // ============================================================================ // Full-Text Search (FTS) Functions // ============================================================================ @@ -1232,7 +1363,7 @@ export const loadFTSExtension = async ( throw new Error('LadybugDB not initialized. Call initLbug first.'); } - const loaded = await extensionManager.ensure((sql) => c.query(sql), 'fts', 'FTS', opts); + const loaded = await extensionManager.ensure((sql) => queryAndDrain(c, sql), 'fts', 'FTS', opts); if (loaded && useModuleState) ftsLoaded = true; return loaded; }; @@ -1248,6 +1379,13 @@ export const loadVectorExtension = async ( ): Promise => { const useModuleState = targetConn === undefined; if (useModuleState && vectorExtensionLoaded) return true; + // INSTALL VECTOR crashes with SIGSEGV on Windows: the KuzuDB native extension + // installer has an unhandled error path on Windows that raises a fatal signal + // that JS try/catch cannot intercept. Skip loading — vector/embedding search + // is unavailable but all graph index queries still work. Do NOT set + // vectorExtensionLoaded here: the flag means "successfully loaded", and a + // subsequent call would otherwise short-circuit to `return true` at the top. + if (process.platform === 'win32') return false; if (!isVectorExtensionSupportedByPlatform()) return false; const c: lbug.Connection | null = targetConn ?? conn; @@ -1255,7 +1393,12 @@ export const loadVectorExtension = async ( throw new Error('LadybugDB not initialized. Call initLbug first.'); } - const loaded = await extensionManager.ensure((sql) => c.query(sql), 'VECTOR', 'VECTOR', opts); + const loaded = await extensionManager.ensure( + (sql) => queryAndDrain(c, sql), + 'VECTOR', + 'VECTOR', + opts, + ); if (loaded && useModuleState) vectorExtensionLoaded = true; return loaded; }; @@ -1287,7 +1430,7 @@ export const createFTSIndex = async ( const query = `CALL CREATE_FTS_INDEX('${tableName}', '${indexName}', [${propList}], stemmer := '${stemmer}')`; try { - await conn.query(query); + await queryAndDrain(conn, query); ensuredFTSIndexes.add(key); } catch (e: any) { if (e.message?.includes('already exists')) { @@ -1371,8 +1514,7 @@ export const queryFTS = async ( try { const queryResult = await conn.query(cypher); - const result = Array.isArray(queryResult) ? queryResult[0] : queryResult; - const rows = await result.getAll(); + const rows = await readQueryRows(queryResult); return rows.map((row: any) => { const node = row.node || row[0] || {}; @@ -1403,7 +1545,7 @@ export const dropFTSIndex = async (tableName: string, indexName: string): Promis } try { - await conn.query(`CALL DROP_FTS_INDEX('${tableName}', '${indexName}')`); + await queryAndDrain(conn, `CALL DROP_FTS_INDEX('${tableName}', '${indexName}')`); } catch { // Index may not exist } finally { diff --git a/gitnexus/src/core/lbug/pool-adapter.ts b/gitnexus/src/core/lbug/pool-adapter.ts index ed999907e..f18d7fcc3 100644 --- a/gitnexus/src/core/lbug/pool-adapter.ts +++ b/gitnexus/src/core/lbug/pool-adapter.ts @@ -420,7 +420,17 @@ async function doInitLbug(repoId: string, dbPath: string): Promise { // install; analyze owns extension installation. If LOAD fails, search // features degrade gracefully and the user-facing query path proceeds. if (!shared.ftsLoaded) { - shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' }); + // Windows guard: LOAD EXTENSION fts crashes with SIGSEGV on Windows when + // the FTS extension binary is not installed locally (@ladybugdb/core native + // bug — the extension loader hits an unhandled error path that signals SIGSEGV + // rather than throwing a JS exception, so try/catch cannot protect here). + // Skip the load on Windows; bm25-index.js catches the resulting Kuzu catalog + // errors and returns empty BM25 results gracefully. Graph queries are unaffected. + if (process.platform === 'win32') { + shared.ftsLoaded = true; + } else { + shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' }); + } } // Register pool entry only after all connections are pre-warmed and FTS is @@ -484,8 +494,13 @@ export async function initLbugWithDb( // Load FTS extension if not already loaded on this Database. // policy: 'load-only' — same contract as initLbug above; the read pool // must not block on a network install during query execution. + // Windows guard: same SIGSEGV risk as doInitLbug above — skip on Windows. if (!shared.ftsLoaded) { - shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' }); + if (process.platform === 'win32') { + shared.ftsLoaded = true; + } else { + shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' }); + } } pool.set(repoId, { diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 7227fd945..425f18f9a 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -11,6 +11,7 @@ import path from 'path'; import fs from 'fs/promises'; +import { execFileSync } from 'child_process'; import { runPipelineFromRepo } from './ingestion/pipeline.js'; import { initLbug, @@ -20,6 +21,9 @@ import { executeWithReusedStatement, closeLbug, loadCachedEmbeddings, + deleteNodesForFile, + deleteAllCommunitiesAndProcesses, + queryImporters, } from './lbug/lbug-adapter.js'; import { createSearchFTSIndexes } from './search/fts-indexes.js'; import { @@ -29,7 +33,15 @@ import { ensureGitNexusIgnored, registerRepo, cleanupOldKuzuFiles, + INCREMENTAL_SCHEMA_VERSION, } from '../storage/repo-manager.js'; +import { computeFileHashes, diffFileHashes } from '../storage/file-hash.js'; +import { + extractChangedSubgraph, + computeEffectiveWriteSet, +} from './incremental/subgraph-extract.js'; +import { shadowCandidatesFor } from './incremental/shadow-candidates.js'; +import { loadParseCache, saveParseCache, pruneCache } from '../storage/parse-cache.js'; import { getCurrentCommit, getRemoteUrl, @@ -81,6 +93,8 @@ export interface AnalyzeOptions { skipAgentsMd?: boolean; /** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */ noStats?: boolean; + /** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */ + skipSkills?: boolean; /** * User-provided alias for the registry `name` (#829). When set, * forwarded to `registerRepo` so the indexed repo is stored under @@ -176,23 +190,81 @@ export async function runFullAnalysis( const currentCommit = repoHasGit ? getCurrentCommit(repoPath) : ''; const existingMeta = await loadMeta(storagePath); + // ── Crash recovery: dirty flag forces full rebuild ──────────────── + // If the previous incremental run set incrementalInProgress and didn't + // clear it, the on-disk index may be in a half-state. Cheapest path + // back to a known-good index is to wipe + rebuild from scratch. + if (existingMeta?.incrementalInProgress) { + log( + 'Previous incremental run did not complete cleanly (incrementalInProgress flag set); ' + + 'forcing full rebuild to restore a known-good index.', + ); + options = { ...options, force: true }; + // Reload meta after clearing the flag in-memory; we still want fileHashes + // for the post-rebuild meta carry-over, but force=true ensures the + // rebuild path executes. + } + // ── Early-return: already up to date ────────────────────────────── if (existingMeta && !options.force && existingMeta.lastCommit === currentCommit) { // Non-git folders have currentCommit = '' — always rebuild since we can't detect changes if (currentCommit !== '') { - await ensureGitNexusIgnored(repoPath); - return { - // `resolveRepoIdentityRoot` collapses worktree roots to the - // canonical repo basename (#1259) but leaves arbitrary subdirs - // and `--skip-git` paths unchanged (#1232/#1233 intent preserved). - repoName: - options.registryName ?? - getInferredRepoName(repoPath) ?? - path.basename(resolveRepoIdentityRoot(repoPath)), - repoPath, - stats: existingMeta.stats ?? {}, - alreadyUpToDate: true, - }; + // For git repos, even if HEAD matches lastCommit, the working tree + // may have uncommitted changes. Only short-circuit when the working + // tree is also clean — otherwise fall through to the incremental + // path which will hash-diff and update only changed files. + // + // We exclude paths that GitNexus itself writes during analyze: + // .gitnexus/ — db / parse cache / meta.json + // .claude/, .cursor/ — auto-generated agent skill files + // AGENTS.md, CLAUDE.md — auto-updated stats blocks + // Counting them as dirty would perpetually defeat the up-to-date + // fast path because the previous analyze just wrote them + // (regression vs PR #1233 behavior). + const dirty = (() => { + try { + const out = execFileSync( + 'git', + [ + 'status', + '--porcelain', + '--', + '.', + ':(exclude).gitnexus', + ':(exclude).gitnexus/**', + ':(exclude).claude', + ':(exclude).claude/**', + ':(exclude).cursor', + ':(exclude).cursor/**', + ':(exclude)AGENTS.md', + ':(exclude)CLAUDE.md', + ], + { + cwd: repoPath, + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + }, + ); + return out.trim().length > 0; + } catch { + return true; // conservative on git failure + } + })(); + if (!dirty) { + await ensureGitNexusIgnored(repoPath); + return { + // `resolveRepoIdentityRoot` collapses worktree roots to the + // canonical repo basename (#1259) but leaves arbitrary subdirs + // and `--skip-git` paths unchanged (#1232/#1233 intent preserved). + repoName: + options.registryName ?? + getInferredRepoName(repoPath) ?? + path.basename(resolveRepoIdentityRoot(repoPath)), + repoPath, + stats: existingMeta.stats ?? {}, + alreadyUpToDate: true, + }; + } } } @@ -241,6 +313,14 @@ export async function runFullAnalysis( ); } + // We *always* load the embedding cache when one is requested (regardless + // of the predicted `willTryIncremental`). The post-pipeline branch may + // disagree with the prediction (e.g. when the pipeline produces zero + // File nodes, `isIncremental` flips false and the full-rebuild path + // wipes the DB) — loading unconditionally is cheap insurance against + // silently dropping embeddings on a mispredicted run. The re-insert + // step gates itself on the actual `isIncremental` value to avoid + // PK-conflicts when the incremental writeback path keeps the rows. if (shouldLoadCache && existingMeta) { try { progress('embeddings', 0, 'Caching embeddings...'); @@ -268,24 +348,89 @@ export async function runFullAnalysis( } } + // ── Load incremental parse cache ────────────────────────────────── + // Content-addressed: safe to reuse across `--force` runs (chunks whose + // file contents haven't changed produce identical worker output). + // Loaded into a single ParseCache object that the pipeline mutates + // in-place (cache hits leave entries unchanged; misses add new ones). + const parseCache = await loadParseCache(storagePath); + // ── Phase 1: Full Pipeline (0–60%) ──────────────────────────────── - const pipelineResult = await runPipelineFromRepo(repoPath, (p) => { - const phaseLabel = PHASE_LABELS[p.phase] || p.phase; - const scaled = Math.round(p.percent * 0.6); - const message = p.detail ? `${p.message || phaseLabel} (${p.detail})` : p.message || phaseLabel; - progress(p.phase, scaled, message); - }); + const pipelineResult = await runPipelineFromRepo( + repoPath, + (p) => { + const phaseLabel = PHASE_LABELS[p.phase] || p.phase; + const scaled = Math.round(p.percent * 0.6); + const message = p.detail + ? `${p.message || phaseLabel} (${p.detail})` + : p.message || phaseLabel; + progress(p.phase, scaled, message); + }, + { parseCache }, + ); // ── Phase 2: LadybugDB (60–85%) ────────────────────────────────── progress('lbug', 60, 'Loading into LadybugDB...'); - await closeLbug(); - const lbugFiles = [lbugPath, `${lbugPath}.wal`, `${lbugPath}.lock`]; - for (const f of lbugFiles) { - try { - await fs.rm(f, { recursive: true, force: true }); - } catch { - /* swallow */ + // Compute current per-file content hashes from the pipeline's File nodes. + // Used both to drive the incremental DB writeback (when eligible) and to + // populate meta.json.fileHashes for the next run. + const allFilePaths: string[] = []; + pipelineResult.graph.forEachNode((n) => { + if (n.label === 'File') { + const fp = n.properties?.filePath as string | undefined; + if (fp) allFilePaths.push(fp); + } + }); + const newFileHashes = await computeFileHashes(repoPath, allFilePaths); + + // Decide incremental vs full at THIS point (post-pipeline, pre-DB). + // All eligibility conditions are checked here against the actual + // pipeline output — no separate pre-pipeline prediction to desync from + // (Bugbot review on PR #1479: a prediction that flipped post-pipeline + // could skip the embedding cache load and then take the full-rebuild + // path, silently losing embeddings). + const isIncremental = + !options.force && + !!existingMeta && + existingMeta.schemaVersion === INCREMENTAL_SCHEMA_VERSION && + !!existingMeta.fileHashes && + Object.keys(existingMeta.fileHashes).length > 0 && + repoHasGit && + allFilePaths.length > 0; + + const hashDiff = isIncremental + ? diffFileHashes(newFileHashes, existingMeta!.fileHashes) + : undefined; + + if (isIncremental && hashDiff) { + log( + `Incremental: changed=${hashDiff.changed.length}, ` + + `added=${hashDiff.added.length}, ` + + `deleted=${hashDiff.deleted.length} ` + + `(skipping wipe + ${ + allFilePaths.length - hashDiff.toWrite.length + } unchanged file rows preserved)`, + ); + // Set the dirty flag BEFORE any destructive DB mutation. Cleared on + // success at the meta-save step. + await saveMeta(storagePath, { + ...existingMeta!, + incrementalInProgress: { + startedAt: Date.now(), + toWriteCount: hashDiff.toWrite.length, + }, + }); + } else { + // Full rebuild path: wipe DB files first. + await closeLbug(); + const lbugFiles = [lbugPath, `${lbugPath}.wal`, `${lbugPath}.lock`]; + for (const f of lbugFiles) { + try { + await fs.rm(f, { recursive: true, force: true }); + } catch { + /* swallow */ + } } } @@ -296,11 +441,145 @@ export async function runFullAnalysis( // must be released to avoid blocking subsequent invocations. let lbugMsgCount = 0; - await loadGraphToLbug(pipelineResult.graph, pipelineResult.repoPath, storagePath, (msg) => { - lbugMsgCount++; - const pct = Math.min(84, 60 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 24)); - progress('lbug', pct, msg); - }); + if (isIncremental && hashDiff) { + // ── Incremental DB writeback ─────────────────────────────────── + // 0. Expand the writable set with transitive importers of + // changed/deleted files (bounded BFS). + // + // Reason (Bugbot/Claude review on PR #1479): when a barrel / + // re-export file C changes, cross-file resolution may update + // CALLS edges between two unchanged files A and B (A imports + // from C, C re-exports something from B). Those refined edges + // live in `ctx.graph` but would be excluded from the subgraph + // if neither endpoint is in the changed set. To catch this, + // files that imported (directly OR transitively, through + // other unchanged intermediaries) any changed file get pulled + // into the writable set so their rows are deleted + rewritten + // against the refined edges. + // + // BFS bound: MAX_IMPORTER_BFS_DEPTH. Practically sized to + // catch nested barrel chains (e.g. `index.ts → submodule/index.ts + // → submodule/impl.ts`) without ballooning into a near-full- + // rebuild on monorepos with deep re-export pyramids. Beyond + // this depth, the "incremental ≡ full-rebuild" invariant is + // self-acknowledged as best-effort; `--force` remains the + // escape hatch documented in GUARDRAILS.md. + // + // `queryImporters` reads `IMPORTS` from the pre-pipeline DB + // state, so the result is "files that USED TO import the + // target" — exactly the set whose previously-stored edges may + // no longer match what cross-file resolution produces this run. + const MAX_IMPORTER_BFS_DEPTH = 4; + const writableFiles = new Set(hashDiff.toWrite); + const directlyChangedCount = writableFiles.size; + + // Shadow-seed: for ADDED files, queryImporters returns 0 (the new + // file has no IMPORTS rows in the pre-pipeline DB yet). But pre- + // existing unchanged files may have IMPORTS edges whose module- + // resolution claim the newcomer can steal under standard JS/TS + // resolution (Bugbot review on PR #1479). For each added file we + // derive the shadow candidates and, if the candidate was a known + // file in the prior meta, seed it into the BFS frontier so its + // importers — surfaced via queryImporters — get their CALLS edges + // re-resolved against the new file. See shadow-candidates.ts for + // the full pattern catalogue. + const priorFileSet = new Set( + existingMeta?.fileHashes ? Object.keys(existingMeta.fileHashes) : [], + ); + const shadowSeed: string[] = []; + for (const added of hashDiff.added) { + for (const cand of shadowCandidatesFor(added)) { + if (priorFileSet.has(cand) && !writableFiles.has(cand)) { + shadowSeed.push(cand); + } + } + } + + { + let frontier: string[] = [...hashDiff.toWrite, ...hashDiff.deleted, ...shadowSeed]; + for (let depth = 0; depth < MAX_IMPORTER_BFS_DEPTH && frontier.length > 0; depth++) { + const nextFrontier: string[] = []; + for (const f of frontier) { + try { + const importers = await queryImporters(f); + for (const i of importers) { + if (!writableFiles.has(i)) { + writableFiles.add(i); + nextFrontier.push(i); + } + } + } catch { + /* per-file importer query failure → skip; correctness degrades on + that branch, but DB stays writable. */ + } + } + frontier = nextFrontier; + } + } + const importerExpansion = writableFiles.size - directlyChangedCount; + if (importerExpansion > 0) { + log( + `Incremental: +${importerExpansion} importer(s) added to writable set ` + + `(BFS depth ≤ ${MAX_IMPORTER_BFS_DEPTH}` + + (shadowSeed.length > 0 ? `, ${shadowSeed.length} shadow-seed(s)` : '') + + `)`, + ); + } + + // 1. Compute the EFFECTIVE write-set (Finding 1). Two layers, + // composed: + // (a) `writableFiles` — toWrite ∪ transitive importers of + // changed/deleted files (the bounded BFS above, reading + // IMPORTS from the pre-pipeline DB). + // (b) `computeEffectiveWriteSet` — walks the NEW graph's + // edges and pulls in any unchanged-side file that sits + // on a writable-boundary-crossing edge (catches refined + // cross-file CALLS edges that the pre-run DB couldn't + // predict, e.g. a barrel re-export shifting `foo` from + // B to D). + // The composed set is the input to BOTH deleteNodesForFile + // and extractChangedSubgraph — asymmetry between the two would + // leave stale rows or PK-conflict at COPY time. + const effectiveWriteSet = computeEffectiveWriteSet(pipelineResult.graph, writableFiles); + // Deduped: deleted entries may already appear via importer-BFS + // expansion (queryImporters can return a now-deleted path), which + // would otherwise call deleteNodesForFile twice for the same file + // (Bugbot LOW finding on PR #1479). + const filesToDelete = [...new Set([...effectiveWriteSet, ...hashDiff.deleted])]; + for (let i = 0; i < filesToDelete.length; i++) { + const f = filesToDelete[i]; + try { + await deleteNodesForFile(f); + } catch { + /* file may not have rows (e.g. an unparseable file) — fine */ + } + if (i % 20 === 0) { + progress('lbug', 62, `Removing rows for changed files (${i}/${filesToDelete.length})...`); + } + } + // 2. Drop graph-wide nodes (Community, Process). They'll be re-inserted + // from the fresh pipeline output below. Required for the + // "Leiden runs on the FULL graph" correctness invariant. + await deleteAllCommunitiesAndProcesses(); + + // 3. Extract the changed subgraph from the FULL ctx.graph and write + // only that. Unchanged-file rows in the DB stay untouched. Pass + // the SAME effectiveWriteSet so the subgraph and the deletes + // cover identical files (asymmetry would silently corrupt). + const subgraph = extractChangedSubgraph(pipelineResult.graph, effectiveWriteSet); + await loadGraphToLbug(subgraph, pipelineResult.repoPath, storagePath, (msg) => { + lbugMsgCount++; + const pct = Math.min(84, 65 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 19)); + progress('lbug', pct, msg); + }); + } else { + // ── Full rebuild ─────────────────────────────────────────────── + await loadGraphToLbug(pipelineResult.graph, pipelineResult.repoPath, storagePath, (msg) => { + lbugMsgCount++; + const pct = Math.min(84, 60 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 24)); + progress('lbug', pct, msg); + }); + } // ── Phase 3: FTS (85–90%) ───────────────────────────────────────── progress('fts', 85, 'Creating search indexes...'); @@ -308,6 +587,19 @@ export async function runFullAnalysis( progress('fts', 90, 'Search indexes ready'); // ── Phase 3.5: Re-insert cached embeddings ──────────────────────── + // Runs on BOTH the full-rebuild path and the incremental path: + // - Full rebuild: DB was wiped, every cached row needs to come back. + // - Incremental: changed-file rows were just deleted by + // deleteNodesForFile (which cascades to their + // embedding rows) — so their cached vectors need + // to come back too. Unchanged-file rows still + // exist; re-inserting their cached vectors would + // PK-conflict, but the per-batch try/catch below + // silently ignores those (matches the existing + // "some may fail if node was removed, that's + // fine" semantics). Bugbot review on PR #1479 + // flagged that gating this on `!isIncremental` + // silently lost changed-file embeddings. if (cachedEmbeddings.length > 0) { const cachedDims = cachedEmbeddings[0].embedding.length; const { EMBEDDING_DIMS } = await import('./lbug/schema.js'); @@ -454,6 +746,12 @@ export async function runFullAnalysis( const effectiveSemanticMode = semanticMode ?? (runtimeCapabilities.semanticMode === 'vector-index' ? 'vector-index' : 'exact-scan'); + + // Convert the post-run file-hash map to the on-disk Record + // shape consumed by RepoMeta.fileHashes. + const newFileHashesRecord: Record = {}; + for (const [k, v] of newFileHashes) newFileHashesRecord[k] = v; + const meta = { repoPath, lastCommit: currentCommit, @@ -483,8 +781,33 @@ export async function runFullAnalysis( reason: runtimeCapabilities.reason, }, }, + // Incremental-indexing fields. Populated for git repos so the next + // analyze run can take the incremental DB-writeback path. Setting + // incrementalInProgress to undefined explicitly clears any prior + // dirty flag (full and incremental success paths converge here). + schemaVersion: hasGitDir(repoPath) ? INCREMENTAL_SCHEMA_VERSION : undefined, + fileHashes: hasGitDir(repoPath) ? newFileHashesRecord : undefined, + incrementalInProgress: undefined as { startedAt: number; toWriteCount: number } | undefined, }; await saveMeta(storagePath, meta); + + // Persist the incremental parse cache for the next run. Wraps in + // try/catch so a cache-write failure never breaks an otherwise + // successful indexing run. Prune stale chunk-hash entries first so + // the cache file size stays bounded across runs (chunks whose + // composition no longer matches anything in the current scan are + // dead weight; the parse phase populates `usedKeys` as it processes + // chunks). + try { + const pruned = pruneCache(parseCache, parseCache.usedKeys); + if (pruned > 0) { + log(`Parse cache: pruned ${pruned} stale chunk entries`); + } + await saveParseCache(storagePath, parseCache); + } catch (e) { + log(`Warning: could not save parse cache (${(e as Error).message}); continuing.`); + } + // Forward the --name alias and the registry-collision bypass bit. // `allowDuplicateName` is its own concern — independent from the // pipeline `force` above. The CLI maps it from @@ -527,7 +850,11 @@ export async function runFullAnalysis( processes: pipelineResult.processResult?.stats.totalProcesses, }, undefined, - { skipAgentsMd: options.skipAgentsMd, noStats: options.noStats }, + { + skipAgentsMd: options.skipAgentsMd, + skipSkills: options.skipSkills, + noStats: options.noStats, + }, ); } catch { // Best-effort — don't fail the entire analysis for context file issues diff --git a/gitnexus/src/core/search/hybrid-search.ts b/gitnexus/src/core/search/hybrid-search.ts index b76a9f5e9..a2521dd83 100644 --- a/gitnexus/src/core/search/hybrid-search.ts +++ b/gitnexus/src/core/search/hybrid-search.ts @@ -50,9 +50,15 @@ export const mergeWithRRF = ( ): HybridSearchResult[] => { const merged = new Map(); + // Guard against undefined/null inputs (#1489) — when FTS is unavailable + // in the MCP process, bm25Results can arrive as undefined and the + // for-loop would throw "bm25Results is not iterable". + const safeBm25 = bm25Results ?? []; + const safeSemantic = semanticResults ?? []; + // Process BM25 results - for (let i = 0; i < bm25Results.length; i++) { - const r = bm25Results[i]; + for (let i = 0; i < safeBm25.length; i++) { + const r = safeBm25[i]; const rrfScore = 1 / (RRF_K + i + 1); // i+1 because rank starts at 1 merged.set(r.filePath, { @@ -65,8 +71,8 @@ export const mergeWithRRF = ( } // Process semantic results and merge - for (let i = 0; i < semanticResults.length; i++) { - const r = semanticResults[i]; + for (let i = 0; i < safeSemantic.length; i++) { + const r = safeSemantic[i]; const rrfScore = 1 / (RRF_K + i + 1); const existing = merged.get(r.filePath); @@ -149,6 +155,9 @@ export const formatHybridResults = (results: HybridSearchResult[]): string => { * Execute BM25 + semantic search and merge with RRF. * Uses LadybugDB FTS for always-fresh BM25 results (no cached data). * The semanticSearch function is injected to keep this module environment-agnostic. + * + * When FTS is unavailable (e.g. read-only MCP connection, missing indexes), + * falls back to semantic-only results instead of crashing (#1489). */ export const hybridSearch = async ( query: string, @@ -160,8 +169,16 @@ export const hybridSearch = async ( k?: number, ) => Promise, ): Promise => { - // Use LadybugDB FTS for always-fresh BM25 results - const { results: bm25Results } = await searchFTSFromLbug(query, limit); + // Use LadybugDB FTS for always-fresh BM25 results. + // If FTS fails (e.g. extension not loaded in MCP process), fall back to + // semantic-only search instead of crashing with "bm25Results is not iterable". + let bm25Results: BM25SearchResult[] = []; + try { + const ftsResponse = await searchFTSFromLbug(query, limit); + bm25Results = ftsResponse?.results ?? []; + } catch { + // FTS unavailable — continue with semantic-only search + } const semanticResults = await semanticSearch(executeQuery, query, limit); return mergeWithRRF(bm25Results, semanticResults, limit); }; diff --git a/gitnexus/src/core/tree-sitter/safe-parse.ts b/gitnexus/src/core/tree-sitter/safe-parse.ts new file mode 100644 index 000000000..1b3bc4fc5 --- /dev/null +++ b/gitnexus/src/core/tree-sitter/safe-parse.ts @@ -0,0 +1,40 @@ +import type Parser from 'tree-sitter'; + +/** + * tree-sitter 0.21.x's Node native binding crashes (SIGSEGV) on Windows when + * `parser.parse(string, …)` is handed a JS string longer than 32 767 chars. + * The crash happens inside the binding's V8 string-to-buffer conversion and + * cannot be intercepted from JavaScript. The callback (`Parser.Input`) overload + * pulls source in fixed-size chunks via repeated callback invocations and + * bypasses that conversion path entirely. + * + * Chunk size is comfortably below the boundary; any value < 32 767 works. + */ +const SAFE_PARSE_CHUNK_CHARS = 16 * 1024; + +/** + * Files at or below this length skip the callback machinery and use the + * direct string overload — the bug only manifests above the int16 boundary, + * so small inputs save the cost of N callback invocations per parse. + */ +const DIRECT_PARSE_LIMIT_CHARS = 16 * 1024; + +/** + * Parse `sourceText` safely on every platform. See {@link SAFE_PARSE_CHUNK_CHARS} + * for the underlying tree-sitter binding bug this works around. + */ +export function parseSourceSafe( + parser: Parser, + sourceText: string, + oldTree?: Parser.Tree, + options?: Parser.Options, +): Parser.Tree { + if (sourceText.length <= DIRECT_PARSE_LIMIT_CHARS) { + return parser.parse(sourceText, oldTree, options); + } + const input: Parser.Input = (index) => { + if (index >= sourceText.length) return null; + return sourceText.slice(index, index + SAFE_PARSE_CHUNK_CHARS); + }; + return parser.parse(input, oldTree, options); +} diff --git a/gitnexus/src/core/wiki/generator.ts b/gitnexus/src/core/wiki/generator.ts index 9f65d26e8..dc9c2e74e 100644 --- a/gitnexus/src/core/wiki/generator.ts +++ b/gitnexus/src/core/wiki/generator.ts @@ -28,6 +28,7 @@ import { type FileWithExports, } from './graph-queries.js'; import { generateHTMLViewer } from './html-viewer.js'; +import { sanitizeMermaidMarkdown } from './mermaid-sanitizer.js'; import { callLLM, @@ -591,7 +592,7 @@ export class WikiGenerator { const response = await this.invokeLLM(prompt, MODULE_SYSTEM_PROMPT, this.streamOpts(node.name)); // Write page with front matter - const pageContent = `# ${node.name}\n\n${response.content}`; + const pageContent = sanitizeMermaidMarkdown(`# ${node.name}\n\n${response.content}`); await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8'); } @@ -631,7 +632,7 @@ export class WikiGenerator { const response = await this.invokeLLM(prompt, PARENT_SYSTEM_PROMPT, this.streamOpts(node.name)); - const pageContent = `# ${node.name}\n\n${response.content}`; + const pageContent = sanitizeMermaidMarkdown(`# ${node.name}\n\n${response.content}`); await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8'); } @@ -681,7 +682,9 @@ export class WikiGenerator { this.streamOpts('Generating overview', 88), ); - const pageContent = `# ${path.basename(this.repoPath)} — Wiki\n\n${response.content}`; + const pageContent = sanitizeMermaidMarkdown( + `# ${path.basename(this.repoPath)} — Wiki\n\n${response.content}`, + ); await fs.writeFile(path.join(this.wikiDir, 'overview.md'), pageContent, 'utf-8'); } diff --git a/gitnexus/src/core/wiki/html-viewer.ts b/gitnexus/src/core/wiki/html-viewer.ts index f961d36ae..c0fd7323a 100644 --- a/gitnexus/src/core/wiki/html-viewer.ts +++ b/gitnexus/src/core/wiki/html-viewer.ts @@ -7,6 +7,7 @@ import fs from 'fs/promises'; import path from 'path'; +import { sanitizeMermaidMarkdown } from './mermaid-sanitizer.js'; interface ModuleTreeNode { name: string; @@ -42,7 +43,7 @@ export async function generateHTMLViewer(wikiDir: string, projectName: string): const dirEntries = await fs.readdir(wikiDir); for (const f of dirEntries.filter((f) => f.endsWith('.md'))) { const content = await fs.readFile(path.join(wikiDir, f), 'utf-8'); - pages[f.replace(/\.md$/, '')] = content; + pages[f.replace(/\.md$/, '')] = sanitizeMermaidMarkdown(content); } const html = buildHTML(projectName, moduleTree, pages, meta); diff --git a/gitnexus/src/core/wiki/llm-client.ts b/gitnexus/src/core/wiki/llm-client.ts index 37fe7a9f2..40ef831bf 100644 --- a/gitnexus/src/core/wiki/llm-client.ts +++ b/gitnexus/src/core/wiki/llm-client.ts @@ -23,6 +23,10 @@ export interface LLMConfig { apiVersion?: string; /** When true, strips sampling params and uses max_completion_tokens instead of max_tokens */ isReasoningModel?: boolean; + /** Per-attempt fetch timeout in ms (default: 60_000). */ + requestTimeoutMs?: number; + /** Max fetch attempts before giving up (default: 3). */ + maxAttempts?: number; } export interface LLMResponse { @@ -237,12 +241,12 @@ export async function callLLM( // indefinitely on a frozen TCP connection — the per-call // signal is the only timeout `resilientFetch` honors; // `capDelayMs` only bounds the *backoff* between attempts. - // 60s matches typical LLM completion budgets. - signal: AbortSignal.timeout(60_000), + // Default 60s; raise via --timeout for slow models or large pages. + signal: AbortSignal.timeout(config.requestTimeoutMs ?? 60_000), }, { breakerKey: `wiki-llm-${new URL(url).host}`, - retry: { maxAttempts: 3, baseDelayMs: 2_000, capDelayMs: 30_000 }, + retry: { maxAttempts: config.maxAttempts ?? 3, baseDelayMs: 2_000, capDelayMs: 30_000 }, }, ); } catch (err) { diff --git a/gitnexus/src/core/wiki/mermaid-sanitizer.ts b/gitnexus/src/core/wiki/mermaid-sanitizer.ts new file mode 100644 index 000000000..c8d443c78 --- /dev/null +++ b/gitnexus/src/core/wiki/mermaid-sanitizer.ts @@ -0,0 +1,119 @@ +const MERMAID_FENCE_RE = /```mermaid\s*\n([\s\S]*?)```/g; +const NODE_LABEL_RE = + /(\[[^\]\n]*(?:\\n)[^\]\n]*\]|\{[^}\n]*(?:\\n)[^}\n]*\}|\([^)\n]*(?:\\n)[^)\n]*\))/g; +const EDGE_LABEL_RE = /\|([^|\n]+)\|/g; +const UNSAFE_EDGE_LABEL_RE = /[()[\]{}<>]/; +const UNSAFE_NODE_ID_RE = /[^A-Za-z0-9_-]/; +const NODE_ID_RE = /^[A-Za-z0-9_.:/()-]+$/; + +const LINE_PREFIX_RE = /^(\s*(?:(?:[-A-Za-z0-9_]+)\s*:\s*)?)(.*)$/; +const EDGE_RE = + /(\s*(?:[ox])?(?:--+|==+|\.\.+)(?:[>|ox])?\|[^|\n]*\|(?:[>|ox])?|\s*(?:[ox])?(?:--+|==+|\.\.+)(?:[>|ox])?|\s*<--+>?\s*)/g; + +export function sanitizeMermaidMarkdown(markdown: string): string { + return markdown.replace(MERMAID_FENCE_RE, (_match, diagram: string) => { + return '```mermaid\n' + sanitizeMermaidDiagram(diagram) + '```'; + }); +} + +export function sanitizeMermaidDiagram(diagram: string): string { + const aliases = new Map(); + let nextAlias = 1; + + const aliasFor = (id: string): string => { + const existing = aliases.get(id); + if (existing) return existing; + + const base = id.replace(/[^A-Za-z0-9_-]/g, '_').replace(/^_+|_+$/g, '') || 'node'; + let alias = base; + while ([...aliases.values()].includes(alias)) { + nextAlias += 1; + alias = `${base}_${nextAlias}`; + } + aliases.set(id, alias); + return alias; + }; + + return diagram + .split('\n') + .map((line) => sanitizeMermaidLine(line, aliasFor)) + .join('\n'); +} + +function sanitizeMermaidLine(line: string, aliasFor: (id: string) => string): string { + let sanitized = replaceLiteralLineBreaksInLabels(line); + sanitized = quoteUnsafeEdgeLabels(sanitized); + + const prefixMatch = sanitized.match(LINE_PREFIX_RE); + if (!prefixMatch) return sanitized; + + const prefix = prefixMatch[1]; + const body = prefixMatch[2]; + if (isDirectiveLine(body)) return sanitized; + + const parts = body.split(EDGE_RE); + if (parts.length === 1) return sanitized; + + for (let i = 0; i < parts.length; i += 2) { + parts[i] = sanitizeNodeReference(parts[i], aliasFor); + } + + return prefix + parts.join(''); +} + +function replaceLiteralLineBreaksInLabels(line: string): string { + return line.replace(NODE_LABEL_RE, (label) => label.replace(/\\n/g, '
')); +} + +function quoteUnsafeEdgeLabels(line: string): string { + return line.replace(EDGE_LABEL_RE, (match, label: string) => { + const trimmed = label.trim(); + if (!UNSAFE_EDGE_LABEL_RE.test(trimmed)) return match; + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return match; + } + return `|"${escapeMermaidLabel(trimmed)}"|`; + }); +} + +function sanitizeNodeReference(segment: string, aliasFor: (id: string) => string): string { + const match = segment.match(/^(\s*)([A-Za-z0-9_.:/()-]+)(.*?)(\s*)$/); + if (!match) return segment; + + const [, leading, id, suffix, trailing] = match; + if (!NODE_ID_RE.test(id) || !UNSAFE_NODE_ID_RE.test(id)) return segment; + const hasInlineLabel = + suffix.trim().startsWith('[') || suffix.trim().startsWith('(') || suffix.trim().startsWith('{'); + + if (hasInlineLabel) return `${leading}${aliasFor(id)}${suffix}${trailing}`; + + return `${leading}${aliasFor(id)}["${escapeMermaidLabel(id)}"]${suffix}${trailing}`; +} + +function escapeMermaidLabel(label: string): string { + return label.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function isDirectiveLine(line: string): boolean { + const trimmed = line.trim(); + return ( + trimmed === '' || + trimmed.startsWith('%%') || + trimmed.startsWith('graph ') || + trimmed.startsWith('flowchart ') || + trimmed.startsWith('sequenceDiagram') || + trimmed.startsWith('classDiagram') || + trimmed.startsWith('stateDiagram') || + trimmed.startsWith('erDiagram') || + trimmed.startsWith('journey') || + trimmed.startsWith('gantt') || + trimmed.startsWith('pie ') || + trimmed.startsWith('mindmap') || + trimmed.startsWith('timeline') || + trimmed.startsWith('subgraph ') || + trimmed === 'end' + ); +} diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 167c1db81..922a69f85 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -755,8 +755,10 @@ export class LocalBackend { timer.time('vector', this.semanticSearch(repo, searchQuery, searchLimit)), ]); - const bm25Results = bm25SearchResult.results; - const ftsUsed = bm25SearchResult.ftsUsed; + // Guard against undefined results (#1489) — when FTS is entirely + // unavailable the search helper may return an unexpected shape. + const bm25Results = bm25SearchResult?.results ?? []; + const ftsUsed = bm25SearchResult?.ftsUsed ?? false; // Merge via reciprocal rank fusion timer.start('merge'); @@ -774,8 +776,9 @@ export class LocalBackend { } } - for (let i = 0; i < semanticResults.length; i++) { - const result = semanticResults[i]; + const safeSemanticResults = semanticResults ?? []; + for (let i = 0; i < safeSemanticResults.length; i++) { + const result = safeSemanticResults[i]; const key = result.nodeId || result.filePath; const rrfScore = 1 / (60 + i); const existing = scoreMap.get(key); @@ -992,7 +995,17 @@ export class LocalBackend { query: string, limit: number, ): Promise<{ results: any[]; ftsUsed: boolean }> { - const { searchFTSFromLbug } = await import('../../core/search/bm25-index.js'); + let searchFTSFromLbug; + try { + ({ searchFTSFromLbug } = await import('../../core/search/bm25-index.js')); + } catch (err: any) { + // Module import can fail in sandboxed MCP contexts (#1489) + logger.warn( + { err: err?.message }, + 'GitNexus: bm25-index.js import failed — falling back to semantic-only', + ); + return { results: [], ftsUsed: false }; + } let ftsResponse; try { ftsResponse = await searchFTSFromLbug(query, limit, repo.id); @@ -1004,8 +1017,10 @@ export class LocalBackend { return { results: [], ftsUsed: false }; } - const bm25Results = ftsResponse.results; - const ftsUsed = ftsResponse.ftsAvailable; + // Guard against unexpected response shape (#1489) — ftsResponse.results + // could be undefined when the FTS extension is unavailable in the MCP process. + const bm25Results = ftsResponse?.results ?? []; + const ftsUsed = ftsResponse?.ftsAvailable ?? false; const results: any[] = []; diff --git a/gitnexus/src/server/git-clone.ts b/gitnexus/src/server/git-clone.ts index 861a16648..0ced1213a 100644 --- a/gitnexus/src/server/git-clone.ts +++ b/gitnexus/src/server/git-clone.ts @@ -11,6 +11,7 @@ import os from 'os'; import fs from 'fs/promises'; import { isIP } from 'net'; import { logger } from '../core/logger.js'; +import { parseRepoNameFromUrl } from '../storage/git.js'; /** Root directory for all cloned repositories. Targets must resolve inside this. */ const CLONE_ROOT = path.resolve(path.join(os.homedir(), '.gitnexus', 'repos')); @@ -29,20 +30,17 @@ const REPO_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/; * clone root via path traversal. */ export function extractRepoName(url: string): string { - // Strip trailing slashes without a regex to avoid polynomial-ReDoS on - // pathological inputs like `https://x.com/y` + '/'.repeat(1e6). CodeQL's - // js/polynomial-redos flagged `/\/+$/` here. - let end = url.length; - while (end > 0 && url.charCodeAt(end - 1) === 47 /* '/' */) end--; - const cleaned = url.slice(0, end); - - const lastSegment = cleaned.split(/[/:]/).pop() || ''; - const stripped = lastSegment.endsWith('.git') ? lastSegment.slice(0, -4) : lastSegment; - - if (!stripped || stripped === '.' || stripped === '..' || !REPO_NAME_PATTERN.test(stripped)) { + const name = parseRepoNameFromUrl(url); + if ( + !name || + name === '.' || + name === '..' || + name === 'unknown' || + !REPO_NAME_PATTERN.test(name) + ) { throw new Error('Could not extract a valid repository name from URL'); } - return stripped; + return name; } /** Get the clone target directory for a repo name. */ @@ -399,8 +397,8 @@ export async function cloneOrPull( } // Always validate the requested URL — the prior shape only ran this in - // the clone branch, leaving the pull branch as an SSRF / blocked-host - // bypass when an existing clone shared the basename of an attacker URL. + // the code path where the repo was cloned. Now it runs unconditionally, + // preventing SSRF / blocked-host bypasses even when targetDir already exists. validateGitUrl(url); const exists = await fs.access(path.join(safeTarget, '.git')).then( diff --git a/gitnexus/src/storage/file-hash.ts b/gitnexus/src/storage/file-hash.ts new file mode 100644 index 000000000..b39111815 --- /dev/null +++ b/gitnexus/src/storage/file-hash.ts @@ -0,0 +1,104 @@ +/** + * Per-file content hashing for incremental DB writeback. + * + * On every analyze run we compute SHA-256 of every file's content and + * store the map in meta.json. The next run compares disk against the + * stored map and produces: + * - `changed` — content differs (re-emit DB rows for this file) + * - `added` — file is new on disk (insert DB rows) + * - `deleted` — file was in last meta but no longer on disk (drop rows) + * + * The pipeline still parses every file (correctness invariant: cross-file + * resolution needs full data). What this enables is a SELECTIVE DB + * writeback: instead of wipe-and-reload of the whole graph (~50s of CSV + * COPY on a 25K-node repo), we only delete-and-rewrite rows for the + * changed/added/deleted set. + * + * See docs/superpowers/specs/2026-05-10-incremental-indexing-design.md + * (Option B revision). + */ + +import { createHash } from 'crypto'; +import fs from 'fs/promises'; +import path from 'path'; + +/** + * Compute SHA-256 of a single file. Returns null when the file can't be + * read — caller treats that as "no signature, assume changed". + */ +export const computeFileHash = async (absPath: string): Promise => { + try { + const buf = await fs.readFile(absPath); + return createHash('sha256').update(buf).digest('hex'); + } catch { + return null; + } +}; + +/** + * Compute SHA-256 hashes for many files in parallel batches. Files that + * fail to read are omitted from the result map. + */ +export const computeFileHashes = async ( + repoPath: string, + relPaths: readonly string[], +): Promise> => { + const out = new Map(); + const BATCH = 100; + for (let i = 0; i < relPaths.length; i += BATCH) { + const batch = relPaths.slice(i, i + BATCH); + const results = await Promise.all( + batch.map(async (rel) => { + const h = await computeFileHash(path.join(repoPath, rel)); + return h ? ([rel, h] as const) : null; + }), + ); + for (const r of results) if (r) out.set(r[0], r[1]); + } + return out; +}; + +/** Result of comparing the current on-disk hashes against stored ones. */ +export interface FileHashDiff { + /** Files whose content hash differs from stored. */ + changed: string[]; + /** Files in the current scan that weren't in the stored map. */ + added: string[]; + /** Files in the stored map that aren't in the current scan. */ + deleted: string[]; + /** All files whose DB rows must be replaced (changed ∪ added). */ + toWrite: string[]; +} + +/** + * Diff a current hash map against a previously stored one. + * + * Sorted output so two runs produce identical diff arrays for the same + * changes — useful for stable logging / equivalence checks. + */ +export const diffFileHashes = ( + current: ReadonlyMap, + stored: Readonly> | undefined, +): FileHashDiff => { + const storedMap = new Map(stored ? Object.entries(stored) : []); + const changed: string[] = []; + const added: string[] = []; + for (const [p, h] of current) { + const prev = storedMap.get(p); + if (prev === undefined) added.push(p); + else if (prev !== h) changed.push(p); + } + const deleted: string[] = []; + for (const p of storedMap.keys()) { + if (!current.has(p)) deleted.push(p); + } + changed.sort(); + added.sort(); + deleted.sort(); + return { + changed, + added, + deleted, + toWrite: [...changed, ...added].sort(), + }; +}; diff --git a/gitnexus/src/storage/git.ts b/gitnexus/src/storage/git.ts index 4cb0154fe..75e6e91d3 100644 --- a/gitnexus/src/storage/git.ts +++ b/gitnexus/src/storage/git.ts @@ -255,24 +255,63 @@ export const getRemoteOriginUrl = (repoPath: string): string | null => { }; /** - * Parse a repository name out of a git remote URL. Handles the common - * SSH (`git@host:owner/repo.git`), HTTPS (`https://host/owner/repo.git`), - * `git://`, `ssh://`, and `file://` shapes. Returns `null` for empty / - * unparseable input. + * Sanitize a repository name to prevent argument injection and ensure + * cross-platform filesystem compatibility. * - * The heuristic: strip a trailing `.git` and trailing slashes, then - * take the segment after the last `/` or `:`. + * 1. Strips leading dashes to prevent git command-line argument injection + * (e.g., --upload-pack=evil). + * 2. Replaces characters that are unsafe for directory names across + * platforms (Windows/macOS/Linux) with underscores. + * 3. Blocks path traversal segments ("." and "..") and Windows reserved + * names (e.g., CON, NUL) to prevent directory escape. + */ +export const sanitizeRepoName = (name: string): string => { + // 1. Prevent argument injection by stripping leading dashes. + // 2. Remove characters that are not alphanumerics, dots, underscores, or dashes. + const sanitized = name.replace(/^-+/, '').replace(/[^a-zA-Z0-9._-]/g, '_'); + + // 3. Block path traversal segments and Windows reserved names. + // Windows reserved names like CON, PRN, AUX, NUL, COM1-9, LPT1-9 cannot + // be used as directory names on Windows even if they have an extension. + const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/i; + if (!sanitized || sanitized === '.' || sanitized === '..' || reserved.test(sanitized)) { + return 'unknown'; + } + + return sanitized; +}; + +/** + * Parse a repository name out of a git remote URL. Handles common shapes + * including SSH (git@host:owner/repo.git) and HTTPS (https://host/owner/repo.git). + * + * Returns a sanitized, filesystem-safe name or null if no name could be inferred. + * Returning null (rather than 'unknown') allows callers to use ?? null-coalescing + * for fallbacks without risk of registry collisions on 'unknown'. */ export const parseRepoNameFromUrl = (url: string | null | undefined): string | null => { if (!url) return null; const trimmed = url.trim(); if (!trimmed) return null; - // Strip `.git` suffix (case-insensitive) and any trailing slashes. - const withoutSuffix = trimmed.replace(/\.git\/*$/i, '').replace(/\/+$/, ''); - // Last path segment, splitting on either `/` or `:` (covers SSH form). - const m = withoutSuffix.match(/[/:]([^/:]+)$/); - const candidate = m ? m[1] : withoutSuffix; - return candidate || null; + + // Strip trailing slashes without a regex to avoid polynomial-ReDoS on + // pathological inputs like `https://x.com/y` + '/'.repeat(1e6). + let end = trimmed.length; + while (end > 0 && trimmed.charCodeAt(end - 1) === 47 /* '/' */) end--; + let cleaned = trimmed.slice(0, end); + + // Strip trailing .git (case-insensitive) + if (cleaned.toLowerCase().endsWith('.git')) { + cleaned = cleaned.slice(0, -4); + } + + // Last path segment, handling colons for SSH URLs and path traversal. + // Split on both / and : to consistently extract the last part. + const candidate = cleaned.split(/[/:]/).pop() || ''; + if (!candidate) return null; + + const safe = sanitizeRepoName(candidate); + return safe === 'unknown' ? null : safe; }; /** diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts new file mode 100644 index 000000000..a1abf76fa --- /dev/null +++ b/gitnexus/src/storage/parse-cache.ts @@ -0,0 +1,213 @@ +/** + * Chunk-level content-addressed parse cache. + * + * The pipeline always parses every file (correctness invariant: cross-file + * resolution and downstream phases need full graph data). What this cache + * does is skip the tree-sitter worker dispatch when a chunk's contents + * haven't changed since the last run. + * + * Granularity: chunk-level. The parse phase chunks files into ~20MB byte + * budgets. The cache key is `sha256(joined(filePath:contentHash for each + * file in the chunk, sorted))`. A change to a single file invalidates only + * that file's chunk — typically 1 of ~50 chunks on a 1000-file repo. + * + * Why not per-file: + * - Workers process sub-batches and emit aggregated `ParseWorkerResult`s. + * Splitting back to per-file would require reworking the worker contract. + * - Chunk-level invalidation gives a useful speedup floor (98% on a single + * 1-of-50 invalidated chunk) without touching the worker. + * + * Survives `--force` because it's content-addressed: the same bytes always + * produce the same key. `--force` only matters for the LadybugDB writeback; + * the cache itself is always safe to reuse. + */ + +import { createHash } from 'crypto'; +import { createRequire } from 'module'; +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.js'; + +/** + * Cache version composed of: + * - A schema bump knob (`SCHEMA_BUMP`) for hand-controlled invalidation + * when ParseWorkerResult shape or upstream parse semantics change. + * - The current `gitnexus` npm package version, read at module load. + * Any release that ships an updated tree-sitter grammar or revised + * extractor logic implies a version bump in package.json, which + * automatically invalidates the on-disk cache. Without this, a user + * running `npm i -g gitnexus@latest` after a parser-affecting + * release would silently replay pre-upgrade ParseWorkerResults + * against the new graph schema (Bugbot/Claude review on #1479). + * + * On version mismatch, `loadParseCache` returns an empty cache and the + * next save overwrites the on-disk file with the new version baked in. + */ +const SCHEMA_BUMP = 1; +const GITNEXUS_PKG_VERSION = (() => { + try { + // package.json sits at gitnexus/package.json — two levels up from + // gitnexus/src/storage/parse-cache.ts (or its dist/ equivalent). + const here = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.join(here, '..', '..', 'package.json'), // src/storage → gitnexus/ + path.join(here, '..', '..', '..', 'package.json'), // dist/storage → gitnexus/ + ]; + const requireCJS = createRequire(import.meta.url); + for (const c of candidates) { + try { + const pkg = requireCJS(c); + if (typeof pkg?.version === 'string') return pkg.version; + } catch { + /* try next candidate */ + } + } + } catch { + /* fall through to fallback */ + } + return '0.0.0-unknown'; +})(); +export const PARSE_CACHE_VERSION = `${SCHEMA_BUMP}+${GITNEXUS_PKG_VERSION}`; + +const CACHE_FILENAME = 'parse-cache.json'; + +/** On-disk shape. */ +interface ParseCacheFile { + version: string; + /** key = chunk hash (hex) → cached chunk result list. */ + entries: Record; +} + +/** Runtime view: keyed Map for fast lookup; mutated in place during a run. */ +export interface ParseCache { + version: string; + entries: Map; + /** + * Hashes referenced (hit OR miss-and-stored) by the current run. + * The parse phase populates this as it processes chunks; the orchestrator + * uses it as input to `pruneCache` before saving so entries that no + * longer correspond to any chunk in the current scan are discarded. + * Transient — never serialized to disk. + */ + usedKeys: Set; +} + +/** SHA-256 hex of a single string or buffer. */ +const sha256Hex = (input: Buffer | string): string => + createHash('sha256') + .update(typeof input === 'string' ? Buffer.from(input) : input) + .digest('hex'); + +/** Stable hash of a single file's contents — used by callers to compose a chunk hash. */ +export const fileContentHash = (content: Buffer | string): string => sha256Hex(content); + +/** + * Compute the canonical cache key for a chunk's contents. + * + * `entries` is the list of (filePath, file content hash) for every file + * in the chunk. We sort by filePath before hashing so chunks composed of + * the same files in different order produce the same key. + */ +export const computeChunkHash = ( + entries: Array<{ filePath: string; contentHash: string }>, +): string => { + const sorted = [...entries].sort((a, b) => (a.filePath < b.filePath ? -1 : 1)); + const joined = sorted.map((e) => `${e.filePath}:${e.contentHash}`).join('\n'); + return sha256Hex(joined); +}; + +/** + * JSON replacer that round-trips Map/Set instances through plain JSON. + * + * `ParseWorkerResult.parsedFiles[*].scopes[*].typeBindings` is a + * `ReadonlyMap`; without this transform it serializes + * to `{}` and downstream code that iterates / `.get()`s on it crashes + * with "is not iterable". Applied symmetrically by `mapReviver` on + * load so the in-memory shape stays Map-typed. + */ +const MAP_TAG = '__$mapEntries$__'; +const SET_TAG = '__$setValues$__'; + +const mapReplacer = (_key: string, value: unknown): unknown => { + if (value instanceof Map) return { [MAP_TAG]: Array.from(value.entries()) }; + if (value instanceof Set) return { [SET_TAG]: Array.from(value.values()) }; + return value; +}; + +const mapReviver = (_key: string, value: unknown): unknown => { + if (value && typeof value === 'object') { + const v = value as Record; + if (Array.isArray(v[MAP_TAG])) return new Map(v[MAP_TAG] as [unknown, unknown][]); + if (Array.isArray(v[SET_TAG])) return new Set(v[SET_TAG] as unknown[]); + } + return value; +}; + +/** + * Load the parse cache. Returns an empty cache on any failure (missing + * file, corrupt JSON, version mismatch). Never throws on a normal load. + */ +export const loadParseCache = async (storagePath: string): Promise => { + const cachePath = path.join(storagePath, CACHE_FILENAME); + try { + const raw = await fs.readFile(cachePath, 'utf-8'); + const data = JSON.parse(raw, mapReviver) as ParseCacheFile; + if ( + typeof data !== 'object' || + data === null || + data.version !== PARSE_CACHE_VERSION || + typeof data.entries !== 'object' || + data.entries === null + ) { + return emptyCache(); + } + const entries = new Map(); + for (const [k, v] of Object.entries(data.entries)) { + if (Array.isArray(v)) entries.set(k, v as ParseWorkerResult[]); + } + return { version: PARSE_CACHE_VERSION, entries, usedKeys: new Set() }; + } catch { + return emptyCache(); + } +}; + +/** + * Persist the cache to disk atomically (write-and-rename) so a crash + * mid-write doesn't leave a corrupt file. + */ +export const saveParseCache = async (storagePath: string, cache: ParseCache): Promise => { + await fs.mkdir(storagePath, { recursive: true }); + const cachePath = path.join(storagePath, CACHE_FILENAME); + const tmpPath = `${cachePath}.tmp`; + const out: ParseCacheFile = { + version: cache.version, + entries: Object.fromEntries(cache.entries), + }; + // Compact JSON; this file can be tens of MB on a large repo and pretty- + // printing roughly doubles size for no value. + await fs.writeFile(tmpPath, JSON.stringify(out, mapReplacer), 'utf-8'); + await fs.rename(tmpPath, cachePath); +}; + +/** + * Drop entries whose hashes are not in `usedHashes`. Called at the end + * of a run so chunks that no longer correspond to any current chunk + * don't keep their stale entries forever. + */ +export const pruneCache = (cache: ParseCache, usedHashes: ReadonlySet): number => { + let removed = 0; + for (const k of cache.entries.keys()) { + if (!usedHashes.has(k)) { + cache.entries.delete(k); + removed++; + } + } + return removed; +}; + +const emptyCache = (): ParseCache => ({ + version: PARSE_CACHE_VERSION, + entries: new Map(), + usedKeys: new Set(), +}); diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 8c0bda95f..456a6c143 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -71,8 +71,40 @@ export interface RepoMeta { processes?: number; embeddings?: number; }; + /** + * Bumped whenever incremental-indexing invariants change in an + * incompatible way (delete-and-rewrite logic, subgraph extraction, + * graph-wide node handling). On mismatch, runFullAnalysis forces a + * full rebuild rather than risk an inconsistent incremental update. + */ + schemaVersion?: number; + /** + * SHA-256 of every file's content at the time of the last successful + * indexing run. The next run computes current hashes and diffs against + * this map to determine which files' DB rows must be replaced. + * Map keys are repo-relative paths. + */ + fileHashes?: Record; + /** + * Crash-recovery dirty flag. Written to meta.json BEFORE any + * destructive DB mutation in an incremental run; cleared on success + * by overwriting meta.json. If a run crashes between, the next run + * sees the flag and forces a full rebuild — the cheapest path back + * to a known-good index. + */ + incrementalInProgress?: { + /** When the incremental run started (epoch ms). */ + startedAt: number; + /** Number of files in the writable set, for diagnostic logs. */ + toWriteCount: number; + }; } +/** + * Bumped whenever incremental-indexing invariants change incompatibly. + */ +export const INCREMENTAL_SCHEMA_VERSION = 1; + export interface IndexedRepo { repoPath: string; storagePath: string; @@ -186,12 +218,23 @@ export const loadMeta = async (storagePath: string): Promise => }; /** - * Save metadata to storage + * Save metadata to storage. + * + * Atomic via tmp-file + rename (matches `saveParseCache`'s pattern). The + * `incrementalInProgress` dirty flag travels through this file — a crash + * mid-write would leave a corrupt `meta.json` that the next run's + * `loadMeta` would silently treat as "no prior index", losing the dirty + * flag and skipping the recovery full-rebuild. Write-and-rename rules + * that out: the rename is atomic on POSIX and on Windows (`fs.rename` + * on `node:fs/promises` uses `MoveFileEx(REPLACE_EXISTING)`), so either + * the old or the new file is observed at every moment. */ export const saveMeta = async (storagePath: string, meta: RepoMeta): Promise => { await fs.mkdir(storagePath, { recursive: true }); const metaPath = path.join(storagePath, 'meta.json'); - await fs.writeFile(metaPath, JSON.stringify(meta, null, 2), 'utf-8'); + const tmpPath = `${metaPath}.tmp`; + await fs.writeFile(tmpPath, JSON.stringify(meta, null, 2), 'utf-8'); + await fs.rename(tmpPath, metaPath); }; /** diff --git a/gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/BProvider.php b/gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/Models/User.php similarity index 100% rename from gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/BProvider.php rename to gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/Models/User.php diff --git a/gitnexus/test/fixtures/lang-resolution/c-static-isolation/a.c b/gitnexus/test/fixtures/lang-resolution/c-static-isolation/a.c new file mode 100644 index 000000000..02a52c42a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-static-isolation/a.c @@ -0,0 +1,9 @@ +/* a.c — contains a static (file-local) helper function. + * This function must NOT be resolvable from caller.c. */ +static int helper(void) { + return 42; +} + +int public_a(void) { + return helper(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/c-static-isolation/b.c b/gitnexus/test/fixtures/lang-resolution/c-static-isolation/b.c new file mode 100644 index 000000000..231a8e434 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-static-isolation/b.c @@ -0,0 +1,11 @@ +/* b.c — contains a non-static (externally visible) helper function. + * This function SHOULD be resolvable from caller.c. */ +#include "b.h" + +int helper(void) { + return 99; +} + +int public_b(void) { + return helper(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/c-static-isolation/b.h b/gitnexus/test/fixtures/lang-resolution/c-static-isolation/b.h new file mode 100644 index 000000000..bd9b6dd2f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-static-isolation/b.h @@ -0,0 +1,8 @@ +/* b.h — public header for b.c */ +#ifndef B_H +#define B_H + +int helper(void); +int public_b(void); + +#endif diff --git a/gitnexus/test/fixtures/lang-resolution/c-static-isolation/caller.c b/gitnexus/test/fixtures/lang-resolution/c-static-isolation/caller.c new file mode 100644 index 000000000..802e134d9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-static-isolation/caller.c @@ -0,0 +1,9 @@ +/* caller.c — includes only b.h, calls helper(). + * Should resolve to b.c:helper, NOT a.c:static helper. */ +#include "b.h" + +int main(void) { + int x = helper(); + int y = public_b(); + return x + y; +} diff --git a/gitnexus/test/fixtures/lang-resolution/c-structs/main.c b/gitnexus/test/fixtures/lang-resolution/c-structs/main.c new file mode 100644 index 000000000..334f36b16 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-structs/main.c @@ -0,0 +1,9 @@ +#include "service.h" + +int main(void) { + struct Service *svc = create_service(); + service_add_user(svc, "Alice", 25); + service_add_user(svc, "Bob", 32); + destroy_service(svc); + return 0; +} diff --git a/gitnexus/test/fixtures/lang-resolution/c-structs/service.c b/gitnexus/test/fixtures/lang-resolution/c-structs/service.c new file mode 100644 index 000000000..85c4cdef6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-structs/service.c @@ -0,0 +1,20 @@ +#include "service.h" +#include + +struct Service *create_service(void) { + struct Service *svc = malloc(sizeof(struct Service)); + svc->admin = create_user("admin", 30); + svc->user_count = 0; + return svc; +} + +void service_add_user(struct Service *svc, const char *name, int age) { + struct User *user = create_user(name, age); + svc->user_count++; + free_user(user); +} + +void destroy_service(struct Service *svc) { + free_user(svc->admin); + free(svc); +} diff --git a/gitnexus/test/fixtures/lang-resolution/c-structs/service.h b/gitnexus/test/fixtures/lang-resolution/c-structs/service.h new file mode 100644 index 000000000..a5056edc7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-structs/service.h @@ -0,0 +1,15 @@ +#ifndef SERVICE_H +#define SERVICE_H + +#include "user.h" + +struct Service { + struct User *admin; + int user_count; +}; + +struct Service *create_service(void); +void service_add_user(struct Service *svc, const char *name, int age); +void destroy_service(struct Service *svc); + +#endif diff --git a/gitnexus/test/fixtures/lang-resolution/c-structs/user.c b/gitnexus/test/fixtures/lang-resolution/c-structs/user.c new file mode 100644 index 000000000..2f3654913 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-structs/user.c @@ -0,0 +1,18 @@ +#include "user.h" +#include +#include + +struct User *create_user(const char *name, int age) { + struct User *user = malloc(sizeof(struct User)); + strncpy(user->name, name, sizeof(user->name) - 1); + user->age = age; + return user; +} + +void free_user(struct User *user) { + free(user); +} + +int get_user_age(const struct User *user) { + return user->age; +} diff --git a/gitnexus/test/fixtures/lang-resolution/c-structs/user.h b/gitnexus/test/fixtures/lang-resolution/c-structs/user.h new file mode 100644 index 000000000..6e14c2425 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/c-structs/user.h @@ -0,0 +1,13 @@ +#ifndef USER_H +#define USER_H + +struct User { + char name[64]; + int age; +}; + +struct User *create_user(const char *name, int age); +void free_user(struct User *user); +int get_user_age(const struct User *user); + +#endif diff --git a/gitnexus/test/fixtures/lang-resolution/java-variadic-resolution/com/example/app/Main.java b/gitnexus/test/fixtures/lang-resolution/java-variadic-resolution/com/example/app/Main.java index e32e3c07e..88c3b71ac 100644 --- a/gitnexus/test/fixtures/lang-resolution/java-variadic-resolution/com/example/app/Main.java +++ b/gitnexus/test/fixtures/lang-resolution/java-variadic-resolution/com/example/app/Main.java @@ -1,10 +1,25 @@ package com.example.app; import com.example.util.Logger; +import com.example.util.Formatter; public class Main { public void run() { Logger logger = new Logger(); logger.record("hello", "world", "test"); + + Formatter fmt = new Formatter(); + // 2-arg call: satisfies fixed prefix (level) + 1 vararg + fmt.format(1, "hello"); + // 3-arg call: satisfies fixed prefix (level) + 2 varargs + fmt.format(2, "hello", "world"); + } + + public void badCall() { + Formatter fmt = new Formatter(); + // 0-arg call: does NOT satisfy the required fixed prefix (int level) + // This should be rejected by arity — no CALLS edge to format + fmt.format(); } } + diff --git a/gitnexus/test/fixtures/lang-resolution/java-variadic-resolution/com/example/util/Formatter.java b/gitnexus/test/fixtures/lang-resolution/java-variadic-resolution/com/example/util/Formatter.java new file mode 100644 index 000000000..6884acf16 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-variadic-resolution/com/example/util/Formatter.java @@ -0,0 +1,8 @@ +package com.example.util; + +public class Formatter { + /** Varargs with a required fixed prefix — 0-arg calls should be rejected. */ + public void format(int level, String... args) { + for (String a : args) System.out.println(level + ": " + a); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-wildcard-import/com/example/app/Main.java b/gitnexus/test/fixtures/lang-resolution/java-wildcard-import/com/example/app/Main.java new file mode 100644 index 000000000..bb4d88597 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-wildcard-import/com/example/app/Main.java @@ -0,0 +1,10 @@ +package com.example.app; + +import com.example.models.*; + +public class Main { + public void run() { + User user = new User(); + user.save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-wildcard-import/com/example/models/Order.java b/gitnexus/test/fixtures/lang-resolution/java-wildcard-import/com/example/models/Order.java new file mode 100644 index 000000000..2804c749e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-wildcard-import/com/example/models/Order.java @@ -0,0 +1,7 @@ +package com.example.models; + +public class Order { + public void submit() { + System.out.println("submitting order"); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-wildcard-import/com/example/models/User.java b/gitnexus/test/fixtures/lang-resolution/java-wildcard-import/com/example/models/User.java new file mode 100644 index 000000000..910f44884 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-wildcard-import/com/example/models/User.java @@ -0,0 +1,7 @@ +package com.example.models; + +public class User { + public void save() { + System.out.println("saving user"); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-calls/app/Services/UserService.php b/gitnexus/test/fixtures/lang-resolution/php-calls/app/Services/UserService.php index fa0a1b8be..882e12d64 100644 --- a/gitnexus/test/fixtures/lang-resolution/php-calls/app/Services/UserService.php +++ b/gitnexus/test/fixtures/lang-resolution/php-calls/app/Services/UserService.php @@ -2,10 +2,13 @@ namespace App\Services; -use function App\Utils\OneArg\log; -use function App\Utils\ZeroArg\log as zero_log; +use function App\Utils\OneArg\write_audit; +use function App\Utils\ZeroArg\write_audit as zero_write_audit; function create_user(): string { + // Two visible write_audit candidates (different arities). Arity narrowing + // must pick the 1-arg OneArg version. This validates that visibility + + // arity together correctly disambiguate. return write_audit('hello'); } diff --git a/gitnexus/test/fixtures/lang-resolution/php-dynamic-calls/app/Services/Dynamic.php b/gitnexus/test/fixtures/lang-resolution/php-dynamic-calls/app/Services/Dynamic.php new file mode 100644 index 000000000..4904f3c24 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-dynamic-calls/app/Services/Dynamic.php @@ -0,0 +1,113 @@ +$method() — dynamic method name via variable_name node. + // Query pattern requires `name: (name)` so this is not captured. + $method = 'dynamicProcess'; + $obj->$method(); + } + + public function memberCallBraceDynamicName(Targets $obj): void + { + // $obj->{$method}() — brace-syntax variant of the above. + $method = 'dynamicBrace'; + $obj->{$method}(); + } + + public function scopedCallDynamicMethodName(): void + { + // ClassName::$method() — dynamic method name on static dispatch. + $method = 'dynamicHandle'; + Targets::$method(); + } + + public function scopedCallVariableClassNameStaticMethod($className): void + { + // $className::method() — class-name is an untyped parameter (no + // type hint, no string-literal assignment that could be picked up + // by a future type-binding heuristic). Receiver IS captured but + // resolution falls through because $className has no class type + // binding in scope. The unresolved-receiver fallback also doesn't + // fire because `dynamicStaticMethod` is unique workspace-wide AND + // exact-arity narrowing in U4 would still match — meaning the + // ONLY thing keeping the edge count at zero today is the absence + // of any type binding for the receiver. + $className::dynamicStaticMethod(); + } + + public function scopedCallDynamicClassAndMethodName(): void + { + // $className::$method() — both dynamic. + $className = 'App\\Services\\Targets'; + $method = 'dynamicScopedDynName'; + $className::$method(); + } + + public function callUserFuncVariableCallable($callable): void + { + // call_user_func($callable, ...) — resolver is structural-only + // and never inspects argument values to infer the callable. + // The literal `call_user_func` itself is an unresolved built-in. + call_user_func($callable); + } + + public function callUserFuncArrayVariable($callable, $args): void + { + // call_user_func_array($callable, $args) — unknown-arity variant. + call_user_func_array($callable, $args); + } + + public function callUserFuncStringCallable(): void + { + // 'Class::method' string-callable form — argument is a string + // literal, never reaches the function: child of function_call_expression. + call_user_func('App\\Services\\Targets::dynamicCallableMethod'); + } + + public function callUserFuncArrayObjectCallable(Targets $obj): void + { + // [$obj, 'method'] array-callable form — array is an argument + // value, not the function: child. + call_user_func([$obj, 'dynamicArrayCallableMethod']); + } + + public function callUserFuncArrayClassNameCallable(): void + { + // ['Class', 'method'] array-callable with class-name string. + call_user_func(['App\\Services\\Targets', 'dynamicArrayClassCallableMethod']); + } + + public function dynamicPropertyRead(Targets $obj): string + { + // $obj->$prop — dynamic property read. No read-access property + // capture pattern exists in query.ts at all (Finding 2). + $prop = 'dynamicProp'; + return $obj->$prop; + } + + public function sanityStaticCall(Targets $obj): void + { + // The fixture's deliberate sanity-check call. THIS one DOES emit + // a CALLS edge — if the assertion that this edge exists ever + // fails, the test infra is broken, not the dynamic-dispatch + // suppression. Without this, every zero-edge assertion above + // would pass even if the pipeline never emitted any edges at all. + $obj->sanityStaticallyNamedTarget(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-dynamic-calls/app/Services/OtherTargets.php b/gitnexus/test/fixtures/lang-resolution/php-dynamic-calls/app/Services/OtherTargets.php new file mode 100644 index 000000000..c8b70a20d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-dynamic-calls/app/Services/OtherTargets.php @@ -0,0 +1,16 @@ +record()` MUST resolve to app/Other/User.php::record, +// NOT app/Models/User.php::record. The `saveLocal` method exercises the +// simple-name path as a control — `User` here is the imported App\Models\User. +class Service { + public function save(\App\Other\User $u): void { + $u->record(); + } + + public function saveLocal(User $u): void { + $u->record(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-fqn-cross-namespace/composer.json b/gitnexus/test/fixtures/lang-resolution/php-fqn-cross-namespace/composer.json new file mode 100644 index 000000000..386b0bd2d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-fqn-cross-namespace/composer.json @@ -0,0 +1,7 @@ +{ + "autoload": { + "psr-4": { + "App\\": "app/" + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-mro-arity-mismatch/app/Models/ChildModel.php b/gitnexus/test/fixtures/lang-resolution/php-mro-arity-mismatch/app/Models/ChildModel.php new file mode 100644 index 000000000..0dff935c9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-mro-arity-mismatch/app/Models/ChildModel.php @@ -0,0 +1,10 @@ + hits Case 2 (findClassBindingInScope) in + // receiver-bound-calls.ts. + // Pre-fix bug: MRO walk emits a false CALLS edge to ParentModel::method + // because Case 2 used `continue` on arity mismatch and fell through. + // Post-fix: zero edges (PHP throws ArgumentCountError at runtime). + ChildModel::method(1); + } + + public function callCompatible(): void + { + // Happy path: ChildModel::compat takes 1 arg; matches call site. + ChildModel::compat(1); + } + + public function callNoParent(): void + { + // Orphan::method takes 2 args; called with 1; no parent class exists. + // Pre-fix: same Case 2 bug — the loop exhausts with memberDef cleared, + // BUT with `continue` the loop simply ends after one iteration since + // the chain has only one entry; no edge would have been emitted here + // even pre-fix. Post-fix: same — zero edges. Documents the boundary. + Orphan::method(1); + } + + public function callMostDerivedHappy(): void + { + // Happy path: ChildModel::method takes 2 args; matches call site. + ChildModel::method(1, 2); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-mro-arity-mismatch/composer.json b/gitnexus/test/fixtures/lang-resolution/php-mro-arity-mismatch/composer.json new file mode 100644 index 000000000..60ede80e5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-mro-arity-mismatch/composer.json @@ -0,0 +1,5 @@ +{ + "autoload": { + "psr-4": { "App\\": "app/" } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-namespace-fallback-isolation/composer.json b/gitnexus/test/fixtures/lang-resolution/php-namespace-fallback-isolation/composer.json new file mode 100644 index 000000000..3675b0d1c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-namespace-fallback-isolation/composer.json @@ -0,0 +1,8 @@ +{ + "autoload": { + "psr-4": { + "App\\": "src/App/", + "Vendor\\": "src/Vendor/" + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-namespace-fallback-isolation/src/App/Caller.php b/gitnexus/test/fixtures/lang-resolution/php-namespace-fallback-isolation/src/App/Caller.php new file mode 100644 index 000000000..6fecabb3c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-namespace-fallback-isolation/src/App/Caller.php @@ -0,0 +1,19 @@ +record(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-parent-vs-trait/composer.json b/gitnexus/test/fixtures/lang-resolution/php-parent-vs-trait/composer.json new file mode 100644 index 000000000..386b0bd2d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-parent-vs-trait/composer.json @@ -0,0 +1,7 @@ +{ + "autoload": { + "psr-4": { + "App\\": "app/" + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-transitive-traits/app/Models/Consumer.php b/gitnexus/test/fixtures/lang-resolution/php-transitive-traits/app/Models/Consumer.php new file mode 100644 index 000000000..86d330173 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-transitive-traits/app/Models/Consumer.php @@ -0,0 +1,20 @@ +aMethod(); + } + + public function callDepthTwo(): string { + return $this->bMethod(); + } + + public function callDepthThree(): string { + return $this->deepMethod(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-transitive-traits/app/Traits/TraitA.php b/gitnexus/test/fixtures/lang-resolution/php-transitive-traits/app/Traits/TraitA.php new file mode 100644 index 000000000..4408b8dfb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-transitive-traits/app/Traits/TraitA.php @@ -0,0 +1,10 @@ += required → edge emitted + * - variadic candidate, argCount < required → NO edge + */ +class Caller +{ + public function callHappyPath($h): void + { + // happyPath(): min=0. argCount=0 → exact match. Edge. + $h->happyPath(); + } + + public function callDefaultExactRequired($h): void + { + // withDefault($a, $b=0): min=1. argCount=1 === min → exact match. Edge. + $h->withDefault('a'); + } + + public function callDefaultBeyondRequired($h): void + { + // withDefault($a, $b=0): min=1, max=2. argCount=2 > min. + // Pre-fix: first-stage narrow accepts (2 <= 2), edge emitted. + // Post-fix: exact-required gate rejects (2 !== 1), no edge. + $h->withDefault('a', 99); + } + + public function callVariadicAtRequired($h): void + { + // variadicLog($level, ...$args): min=1, hasVarArgs. + // argCount=1 === min → edge emitted (variadic relaxed path). + $h->variadicLog('info'); + } + + public function callVariadicBeyondRequired($h): void + { + // variadicLog($level, ...$args): min=1, hasVarArgs. + // argCount=2 > min, variadic → edge emitted. + $h->variadicLog('info', 'arg1'); + } + + public function callVariadicBelowRequired($h): void + { + // variadicLogTwoRequired($a, $b, ...$rest): min=2, hasVarArgs. + // argCount=1 < min → no edge (first-stage rejects). Both pre/post-fix. + $h->variadicLogTwoRequired('only-one'); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-unresolved-receiver-arity/composer.json b/gitnexus/test/fixtures/lang-resolution/php-unresolved-receiver-arity/composer.json new file mode 100644 index 000000000..60ede80e5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-unresolved-receiver-arity/composer.json @@ -0,0 +1,5 @@ +{ + "autoload": { + "psr-4": { "App\\": "app/" } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-variadic-arity-minimum/app/Services/Caller.php b/gitnexus/test/fixtures/lang-resolution/php-variadic-arity-minimum/app/Services/Caller.php new file mode 100644 index 000000000..1da29a43a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-variadic-arity-minimum/app/Services/Caller.php @@ -0,0 +1,30 @@ +32 767-char inputs through extractors, + * chunkers, or any parse caller need to assert the call routed through + * `parseSourceSafe` rather than `parser.parse(string, ...)` directly. A + * direct call SIGSEGVs on Windows for inputs that size; on Linux/macOS it + * succeeds, so a "no throw" assertion alone silently passes with the + * bypass reintroduced. The spy assertion is what actually catches the + * regression. + * + * Why the test still has to call `vi.mock` with a literal path: vitest's + * hoister static-analyzes the first argument of `vi.mock`, and the path + * varies by directory depth across test files. Everything else — the + * `vi.importActual` round-trip, the spy installation, and the merged + * module shape — lives here. + * + * Why the test still has to dynamic-`import()` this helper inside the + * `vi.mock` factory: `vi.mock` is hoisted above static imports, so the + * factory closure cannot reference statically-imported helpers (they are + * uninitialized at hoist time). The factory body, however, is async and + * runs only when the mocked module is first consumed — by which point + * the helper resolves cleanly via dynamic `import()`. + * + * Usage: + * + * const { parseSourceSafeSpy } = vi.hoisted(() => ({ parseSourceSafeSpy: vi.fn() })); + * + * vi.mock('../../../src/core/tree-sitter/safe-parse.js', async () => { + * const { buildSafeParseMock } = await import('../../helpers/parse-source-safe-mock.js'); + * return buildSafeParseMock(parseSourceSafeSpy); + * }); + * + * it('routes large input through parseSourceSafe', async () => { + * parseSourceSafeSpy.mockClear(); + * // ... call extractor with >40 000-char input ... + * expect(parseSourceSafeSpy).toHaveBeenCalled(); + * }); + */ +export async function buildSafeParseMock( + spy: ReturnType, +): Promise { + const actual = await vi.importActual( + '../../src/core/tree-sitter/safe-parse.js', + ); + spy.mockImplementation(actual.parseSourceSafe); + return { ...actual, parseSourceSafe: spy }; +} diff --git a/gitnexus/test/integration/augmentation.test.ts b/gitnexus/test/integration/augmentation.test.ts index aed09d61f..60c4170fd 100644 --- a/gitnexus/test/integration/augmentation.test.ts +++ b/gitnexus/test/integration/augmentation.test.ts @@ -57,6 +57,7 @@ vi.mock('../../src/storage/repo-manager.js', () => ({ })); let augment: (pattern: string, cwd?: string) => Promise; +let augmentNoFts: (pattern: string, cwd?: string) => Promise; withTestLbugDB( 'augment', @@ -106,6 +107,28 @@ withTestLbugDB( const result = await augment('日本語テスト', handle.dbPath); expect(typeof result).toBe('string'); }); + + // ─── Negative-safety: fallback must stay gated on !ftsAvailable ─── + // + // When FTS is available but happens to return zero BM25 hits, the + // CONTAINS fallback must NOT fire — preserving the original early-return + // semantics. If anyone later loosens the gate to `symbolMatches.length + // === 0` alone, this test fails. + + it('does NOT fire CONTAINS fallback when FTS is available but BM25 returns empty', async () => { + const bm25 = await import('../../src/core/search/bm25-index.js'); + const spy = vi + .spyOn(bm25, 'searchFTSFromLbug') + .mockResolvedValue({ results: [], ftsAvailable: true }); + try { + // 'login' WOULD match a graph node via CONTAINS, but FTS is available + // and empty → fallback gate must hold → result must be ''. + const result = await augment('login', handle.dbPath); + expect(result).toBe(''); + } finally { + spy.mockRestore(); + } + }); }); }, { @@ -131,3 +154,65 @@ withTestLbugDB( }, }, ); + +// ─── FTS-unavailable suite: exercises the CONTAINS fallback branch ──────────── +// +// No ftsIndexes → searchFTSFromLbug returns ftsAvailable: false → fallback fires. +// Same seed data so 'login' still exists as a graph node. + +withTestLbugDB( + 'augment-no-fts', + (handle) => { + describe('augment() — FTS indexes unavailable (CONTAINS fallback)', () => { + it('falls back to CONTAINS query and returns enrichment when FTS is unavailable', async () => { + const result = await augmentNoFts('login', handle.dbPath); + + expect(result.length).toBeGreaterThan(0); + expect(result).toContain('[GitNexus]'); + }); + + it("returns empty string for whitespace-only pattern (CONTAINS '' guard)", async () => { + const result = await augmentNoFts(' ', handle.dbPath); + expect(result).toBe(''); + }); + + it('returns empty string when no nodes match the CONTAINS query', async () => { + const result = await augmentNoFts('nxyz_notfound', handle.dbPath); + expect(result).toBe(''); + }); + + it('returns empty string when fallback CONTAINS query throws', async () => { + const poolAdapter = await import('../../src/core/lbug/pool-adapter.js'); + const spy = vi + .spyOn(poolAdapter, 'executeQuery') + .mockRejectedValue(new Error('simulated DB error')); + try { + const result = await augmentNoFts('login', handle.dbPath); + expect(result).toBe(''); + } finally { + spy.mockRestore(); + } + }); + }); + }, + { + seed: AUGMENT_SEED_DATA, + // Intentionally no ftsIndexes — forces searchFTSFromLbug to return ftsAvailable: false + poolAdapter: true, + afterSetup: async (handle) => { + const { listRegisteredRepos } = await import('../../src/storage/repo-manager.js'); + (listRegisteredRepos as ReturnType).mockResolvedValue([ + { + name: handle.repoId, + path: handle.dbPath, + storagePath: handle.tmpHandle.dbPath, + indexedAt: new Date().toISOString(), + lastCommit: 'abc123', + }, + ]); + + const engine = await import('../../src/core/augmentation/engine.js'); + augmentNoFts = engine.augment; + }, + }, +); diff --git a/gitnexus/test/integration/cli-e2e.test.ts b/gitnexus/test/integration/cli-e2e.test.ts index 8037511bf..99e52c330 100644 --- a/gitnexus/test/integration/cli-e2e.test.ts +++ b/gitnexus/test/integration/cli-e2e.test.ts @@ -36,6 +36,7 @@ const FIXTURE_SRC = path.resolve(testDir, '..', 'fixtures', 'mini-repo'); // still works), `afterAll` rms the parent tmpdir. let MINI_REPO: string; let tmpParent: string; +let suiteGitnexusHome: string; // Absolute file:// URL to tsx loader — needed when spawning CLI with cwd // outside the project tree (bare 'tsx' specifier won't resolve there). @@ -49,6 +50,7 @@ beforeAll(() => { // Copy the fixture into an isolated tmpdir named `mini-repo` so that the // `--repo mini-repo` CLI arg (which matches by basename) still works. tmpParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cli-e2e-')); + suiteGitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cli-e2e-home-')); MINI_REPO = path.join(tmpParent, 'mini-repo'); fs.cpSync(FIXTURE_SRC, MINI_REPO, { recursive: true }); @@ -75,21 +77,30 @@ afterAll(() => { if (tmpParent) { fs.rmSync(tmpParent, { recursive: true, force: true }); } + if (suiteGitnexusHome) { + fs.rmSync(suiteGitnexusHome, { recursive: true, force: true }); + } }); +function cliEnv(extraEnv: Record = {}) { + return { + ...process.env, + GITNEXUS_HOME: suiteGitnexusHome, + // Pre-set --max-old-space-size so analyzeCommand's ensureHeap() sees it + // and skips the re-exec. The re-exec drops the tsx loader (--import tsx + // is not in process.argv), causing ERR_UNKNOWN_FILE_EXTENSION on .ts files. + NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), + ...extraEnv, + }; +} + function runCli(command: string, cwd: string, timeoutMs = 15000) { return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, command], { cwd, encoding: 'utf8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - // Pre-set --max-old-space-size so analyzeCommand's ensureHeap() sees it - // and skips the re-exec. The re-exec drops the tsx loader (--import tsx - // is not in process.argv), causing ERR_UNKNOWN_FILE_EXTENSION on .ts files. - NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), - }, + env: cliEnv(), }); } @@ -103,10 +114,7 @@ function runCliRaw(extraArgs: string[], cwd: string, timeoutMs = 15000) { encoding: 'utf8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), - }, + env: cliEnv(), }); } @@ -126,11 +134,7 @@ function runCliWithEnv( encoding: 'utf8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), - ...extraEnv, - }, + env: cliEnv(extraEnv), }); } @@ -919,10 +923,7 @@ describe('CLI end-to-end', () => { encoding: 'utf8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), - }, + env: cliEnv(), }); } @@ -1042,10 +1043,7 @@ describe('CLI end-to-end', () => { encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), - }, + env: cliEnv(), }, ); if (result.status === null) return; @@ -1159,10 +1157,7 @@ describe('CLI end-to-end', () => { { cwd: MINI_REPO, stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), - }, + env: cliEnv(), }, ); @@ -1212,10 +1207,7 @@ describe('CLI end-to-end', () => { { cwd: MINI_REPO, stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), - }, + env: cliEnv(), }, ); diff --git a/gitnexus/test/integration/lbug-close-handle-release.test.ts b/gitnexus/test/integration/lbug-close-handle-release.test.ts index c0a3b8758..65a53bc7d 100644 --- a/gitnexus/test/integration/lbug-close-handle-release.test.ts +++ b/gitnexus/test/integration/lbug-close-handle-release.test.ts @@ -8,9 +8,16 @@ * absorbed by the open-time retry in `lbug-config.ts`. */ import path from 'path'; -import { describe, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { createTempDir } from '../helpers/test-db.js'; +/** + * LadybugDB's native Windows file lock can outlive Database.close() for + * same-process close/reopen cycles. Keep true reopen coverage on POSIX and + * cover ordering deterministically in lbug-checkpoint-lifecycle.test.ts. + */ +const itLbugReopen = process.platform === 'win32' ? it.skip : it; + describe('safeClose — close + reopen does not surface lock errors', () => { it('survives 10 sequential open/close/reopen cycles on the same path', async () => { const tmp = await createTempDir('gitnexus-lbug-close-cycle-'); @@ -38,4 +45,38 @@ describe('safeClose — close + reopen does not surface lock errors', () => { await tmp.cleanup(); } }); + + itLbugReopen('flushes WAL when switching between two database paths in one process', async () => { + const repoA = await createTempDir('gitnexus-lbug-switch-a-'); + const repoB = await createTempDir('gitnexus-lbug-switch-b-'); + const dbPathA = path.join(repoA.dbPath, 'lbug'); + const dbPathB = path.join(repoB.dbPath, 'lbug'); + + try { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + await adapter.withLbugDb(dbPathA, async () => { + await adapter.executeQuery( + "CREATE (:File {id: 'file:a', name: 'a.ts', filePath: 'a.ts', content: 'repo a'})", + ); + }); + + await adapter.withLbugDb(dbPathB, async () => { + await adapter.executeQuery( + "CREATE (:File {id: 'file:b', name: 'b.ts', filePath: 'b.ts', content: 'repo b'})", + ); + }); + + const rows = await adapter.withLbugDb(dbPathA, async () => + adapter.executeQuery("MATCH (n:File {id: 'file:a'}) RETURN n.filePath AS filePath"), + ); + + expect(rows).toEqual([{ filePath: 'a.ts' }]); + } finally { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.closeLbug().catch(() => {}); + await repoA.cleanup(); + await repoB.cleanup(); + } + }); }); diff --git a/gitnexus/test/integration/resolvers/c.test.ts b/gitnexus/test/integration/resolvers/c.test.ts new file mode 100644 index 000000000..980764b8c --- /dev/null +++ b/gitnexus/test/integration/resolvers/c.test.ts @@ -0,0 +1,111 @@ +/** + * C: struct + include-based imports + function calls across files + */ +import { describe, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, + createResolverParityIt, + getRelationships, + getNodesByLabel, + edgeSet, + runPipelineFromRepo, + type PipelineResult, +} from './helpers.js'; + +const it = createResolverParityIt('c'); + +// --------------------------------------------------------------------------- +// C structs + include-based imports + cross-file function calls +// --------------------------------------------------------------------------- + +describe('C struct & include resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'c-structs'), () => {}); + }, 60000); + + it('detects User and Service structs', () => { + const structs = getNodesByLabel(result, 'Struct'); + expect(structs).toContain('User'); + expect(structs).toContain('Service'); + }); + + it('detects functions across all files', () => { + const fns = getNodesByLabel(result, 'Function'); + expect(fns).toContain('main'); + expect(fns).toContain('create_user'); + expect(fns).toContain('free_user'); + expect(fns).toContain('get_user_age'); + expect(fns).toContain('create_service'); + expect(fns).toContain('service_add_user'); + expect(fns).toContain('destroy_service'); + }); + + it('resolves #include imports between .c and .h files', () => { + const imports = getRelationships(result, 'IMPORTS'); + const edges = edgeSet(imports); + // user.c includes user.h + expect(edges).toContain('user.c → user.h'); + // service.h includes user.h + expect(edges).toContain('service.h → user.h'); + // service.c includes service.h + expect(edges).toContain('service.c → service.h'); + // main.c includes service.h + expect(edges).toContain('main.c → service.h'); + }); + + it('emits CALLS edges for cross-file function calls', () => { + const calls = getRelationships(result, 'CALLS'); + const edges = edgeSet(calls); + // main.c calls functions from service + expect(edges).toContain('main → create_service'); + expect(edges).toContain('main → service_add_user'); + expect(edges).toContain('main → destroy_service'); + // service.c calls functions from user + expect(edges).toContain('service_add_user → create_user'); + expect(edges).toContain('service_add_user → free_user'); + expect(edges).toContain('destroy_service → free_user'); + }); +}); + +// --------------------------------------------------------------------------- +// C static function isolation — static functions must NOT leak across files +// --------------------------------------------------------------------------- + +describe('C static function isolation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'c-static-isolation'), () => {}); + }, 60000); + + it('detects both static and non-static helper functions', () => { + const fns = getNodesByLabel(result, 'Function'); + expect(fns).toContain('helper'); + expect(fns).toContain('public_a'); + expect(fns).toContain('public_b'); + expect(fns).toContain('main'); + }); + + it('caller.c calls b:helper via include, NOT a:static helper', () => { + const calls = getRelationships(result, 'CALLS'); + const edges = edgeSet(calls); + + // caller.c should call public_b (included via b.h) + expect(edges).toContain('main → public_b'); + + // a.c's static helper calls itself locally + expect(edges).toContain('public_a → helper'); + + // caller.c should NOT have a CALLS edge to a.c's static helper. + // Filter edges to only those originating from main → helper to + // verify the correct target file. + const mainToHelper = calls.filter((r) => r.source === 'main' && r.target === 'helper'); + // If a main→helper edge exists, it should point to b.c, not a.c + for (const edge of mainToHelper) { + expect(edge.targetFilePath).not.toContain('a.c'); + } + }); +}); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index e92526b19..571f2121f 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -9,6 +9,18 @@ import type { PipelineResult } from '../../../src/types/pipeline.js'; import type { GraphRelationship } from 'gitnexus-shared'; const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly>> = { + c: new Set([ + // The legacy DAG path does not resolve the main → create_service call + // because the function prototype in the .h file and the definition in + // the .c file create a dedup ambiguity. The registry-primary path + // resolves it via scope-based wildcard import binding. + 'emits CALLS edges for cross-file function calls', + // The legacy DAG path does not resolve cross-file calls through + // #include → prototype chains. The scope-based path resolves + // caller.c → b.h → public_b via wildcard import binding + + // isFileLocalDef filtering of static functions. + 'caller.c calls b:helper via include, NOT a:static helper', + ]), csharp: new Set([ 'emits the using-import edge App/Program.cs -> Models/User.cs through the scope-resolution path', // Generic type-argument USES edges are emitted by the registry-primary @@ -22,6 +34,52 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonlymethod()` precedence inside a class that composes a trait AND + // extends a parent both defining the same method requires the augmented + // trait-aware MRO (trait shadows parent). The legacy DAG has no + // trait-aware MRO, so it fails to bind the call to the trait. Scope- + // resolver-only correctness win (commit af9af4a9 U3). + '$this->record() still resolves to Auditable::record (trait shadows parent)', + // Fully-qualified type-hint resolution (`\App\Other\User $u` parameter) + // routes through the scope-resolver's bindingAugmentations channel + // populated by `populatePhpNamespaceSiblings` Step 3b. The legacy DAG + // resolves receiver types via simple-name workspace lookup and has no + // namespace-prefixed binding channel, so it cannot distinguish the FQN + // target from a same-simple-name class reachable via `use`. Scope- + // resolver-only correctness win (Codex PR #1497 review, finding 1). + '\\App\\Other\\User parameter resolves $u->record() to app/Other/User.php (NOT app/Models/User.php)', + // MRO arity-mismatch on class-name receivers (`Child::method(1)` where + // Child::method takes 2 args and Parent::method takes 1): the legacy + // DAG has no arity narrowing on Case 2 (class-name) MRO walk, so it + // emits a false CALLS edge to Parent::method on fallthrough. Scope- + // resolver-only correctness win (PR #1497 review Image 1 / U1). + 'arity-incompatible most-derived override does NOT fall through to ParentModel::method', + // Class-name receiver with single-class arity mismatch (no parent in + // the MRO chain): legacy resolves the method by name without arity + // gating, so it emits a CALLS edge even when arity is definitively + // incompatible. The scope-resolver's `narrowOverloadCandidates` check + // in `receiver-bound-calls.ts` Case 2 rejects this post-fix. Scope- + // resolver-only correctness win (PR #1497 / U1). + 'arity-incompatible class with no parent emits zero CALLS edges (regression check)', + // `phpEmitUnresolvedReceiverEdges` exact-required-arity gate (PR + // #1497 / U4): the legacy DAG has no equivalent unresolved-receiver + // fallback hook, so it resolves these untyped-receiver sites via a + // different code path that over-emits for default-parameter and + // variadic-required-mismatch shapes. Scope-resolver-only correctness + // wins; backporting to legacy is out of scope. + 'argCount > required (2>1) on candidate with default param emits NO edge post-fix', + 'variadic candidate, argCount < required (1<2) emits NO edge', + ]), python: new Set([ // Suffix-fallback lex tiebreak depends on the registry-primary // resolver's deterministic sort. The legacy resolver returns the diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index d9bae90bf..8a813525b 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -1,11 +1,12 @@ /** * Java: class extends + implements multiple interfaces + ambiguous package disambiguation */ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, expect, beforeAll } from 'vitest'; import path from 'path'; import { FIXTURES, CROSS_FILE_FIXTURES, + createResolverParityIt, getRelationships, getNodesByLabel, getNodesByLabelFull, @@ -14,6 +15,8 @@ import { type PipelineResult, } from './helpers.js'; +const it = createResolverParityIt('java'); + // --------------------------------------------------------------------------- // Heritage: class extends + implements multiple interfaces // --------------------------------------------------------------------------- @@ -438,6 +441,54 @@ describe('Java variadic call resolution', () => { } expect(allDangling).toEqual([]); }); + + it('resolves 2-arg call to fixed-prefix varargs method format(int, String...) in Formatter.java', () => { + const calls = getRelationships(result, 'CALLS'); + const fmtCall = calls.find((c) => c.target === 'format' && c.source === 'run'); + expect(fmtCall).toBeDefined(); + expect(fmtCall!.targetFilePath).toBe('com/example/util/Formatter.java'); + }); + + it('0-arg call to format(int, String...) still resolves in legacy mode (arity rejection is registry-only)', () => { + // In REGISTRY_PRIMARY_JAVA=1 mode, `requiredParameterCount = 1` causes + // `javaArityCompatibility` to return 'incompatible' for 0-arg calls, + // preventing the CALLS edge. In default (legacy) mode, arity is not + // enforced so the edge is created. This test documents the legacy + // behavior; the negative assertion is a flip-blocker for registry-primary. + const calls = getRelationships(result, 'CALLS'); + const zeroArgFmtCall = calls.find((c) => c.target === 'format' && c.source === 'badCall'); + expect(zeroArgFmtCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Wildcard import: `import com.example.models.*` resolves to a package file +// --------------------------------------------------------------------------- + +describe('Java wildcard import resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'java-wildcard-import'), () => {}); + }, 60000); + + it('parses wildcard import without errors and creates graph nodes', () => { + // The wildcard import (`import com.example.models.*`) exercises the + // directoryChild branch in resolveJavaImportTarget. Even if no IMPORTS + // edge is created (nondeterministic file selection — documented flip + // blocker), the graph must contain valid nodes for all classes. + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Main'); + expect(classes).toContain('User'); + expect(classes).toContain('Order'); + }); + + it('resolves user.save() call via wildcard-imported User', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'run'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('com/example/models/User.java'); + }); }); // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts index e120d92f4..336e5dee2 100644 --- a/gitnexus/test/integration/resolvers/php.test.ts +++ b/gitnexus/test/integration/resolvers/php.test.ts @@ -1,11 +1,12 @@ /** * PHP: PSR-4 imports, extends, implements, trait use, enums, calls + ambiguous disambiguation */ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, expect, beforeAll } from 'vitest'; import path from 'path'; import { FIXTURES, CROSS_FILE_FIXTURES, + createResolverParityIt, getRelationships, getNodesByLabel, getNodesByLabelFull, @@ -14,6 +15,12 @@ import { type PipelineResult, } from './helpers.js'; +// Wrap vitest's `it` so legacy-DAG-only divergences (commit af9af4a9 U1/U3) +// are skipped under REGISTRY_PRIMARY_PHP=0. The skip list lives in +// helpers.ts:LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.php — sibling pattern +// to csharp/typescript/python. +const it = createResolverParityIt('php'); + // --------------------------------------------------------------------------- // Heritage: PSR-4 imports, extends, implements, trait use, enums, calls // --------------------------------------------------------------------------- @@ -91,6 +98,10 @@ describe('PHP heritage & import resolution', () => { expect(targets).toContain('label'); }); + // save($entity: mixed) calls $entity->getId() — the receiver is typed `mixed` + // so there is no TypeRef in scope. The scope-resolver `emitUnresolvedReceiverEdges` + // hook (PHP-wired) recovers this case via workspace-wide unique-name lookup, + // matching the legacy DAG behavior. it('emits CALLS edge: save → getId', () => { const calls = getRelationships(result, 'CALLS').filter( (e) => e.source === 'save' && e.target === 'getId', @@ -439,6 +450,161 @@ describe('PHP variadic call resolution', () => { }); }); +// --------------------------------------------------------------------------- +// Variadic arity minimum: required-arg count must be enforced for variadic +// functions. f(int $req, ...$rest) called as f() is an ArgumentCountError at +// PHP runtime and must NOT emit a CALLS edge from the resolver. +// --------------------------------------------------------------------------- + +describe('PHP variadic arity minimum (U1)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'php-variadic-arity-minimum'), () => {}); + }, 60000); + + const callsFrom = (source: string, target: string) => + getRelationships(result, 'CALLS').filter((c) => c.source === source && c.target === target); + + it('emits CALLS edge for record(level, ...msgs) with arity 4 (happy path)', () => { + expect(callsFrom('callValidRecord', 'record').length).toBe(1); + }); + + it('emits CALLS edge for record(level) with only the required arg (arity 1)', () => { + expect(callsFrom('callValidRecordMin', 'record').length).toBe(1); + }); + + it('does NOT emit CALLS edge for record() with zero args (below required=1)', () => { + expect(callsFrom('callTooFewRecord', 'record').length).toBe(0); + }); + + it('emits CALLS edge for format() — pure variadic, required=0', () => { + expect(callsFrom('callPureVariadic', 'format').length).toBe(1); + }); + + it('emits CALLS edge for pad("x") — required+optional+variadic, only required given', () => { + expect(callsFrom('callPadMin', 'pad').length).toBe(1); + }); + + it('does NOT emit CALLS edge for pad() with zero args (below required=1)', () => { + expect(callsFrom('callPadTooFew', 'pad').length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Transitive trait MRO: trait A uses B uses C — Consumer using A must see C's +// methods. Current depth-2 expansion in buildPhpMro silently drops methods +// from 3+ level chains. +// --------------------------------------------------------------------------- + +describe('PHP transitive trait MRO (U2)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'php-transitive-traits'), () => {}); + }, 60000); + + const callsFrom = (source: string, target: string) => + getRelationships(result, 'CALLS').filter((c) => c.source === source && c.target === target); + + it('detects 3 traits and 1 class', () => { + expect(getNodesByLabel(result, 'Trait')).toEqual(['TraitA', 'TraitB', 'TraitC']); + expect(getNodesByLabel(result, 'Class')).toContain('Consumer'); + }); + + it('depth-1: $this->aMethod() resolves to TraitA::aMethod', () => { + expect(callsFrom('callDepthOne', 'aMethod').length).toBe(1); + }); + + it('depth-2: $this->bMethod() resolves to TraitB::bMethod (TraitA uses TraitB)', () => { + expect(callsFrom('callDepthTwo', 'bMethod').length).toBe(1); + }); + + it('depth-3: $this->deepMethod() resolves to TraitC::deepMethod (TraitA → TraitB → TraitC)', () => { + expect(callsFrom('callDepthThree', 'deepMethod').length).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// parent:: bypasses traits. When a class composes a trait AND extends a parent +// that both define the same method name, parent::method() must resolve to the +// parent class (PHP semantics), NOT the trait. $this->method() still goes to +// the trait (PHP's own-class > trait > parent precedence). +// --------------------------------------------------------------------------- + +describe('PHP parent:: bypasses traits (U3)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'php-parent-vs-trait'), () => {}); + }, 60000); + + const callsFromTo = (source: string, target: string, file: string) => + getRelationships(result, 'CALLS').filter( + (c) => c.source === source && c.target === target && c.targetFilePath === file, + ); + + it('parent::record() resolves to Base::record, NOT Auditable::record', () => { + expect(callsFromTo('callViaParent', 'record', 'app/Base.php').length).toBe(1); + expect(callsFromTo('callViaParent', 'record', 'app/Auditable.php').length).toBe(0); + }); + + it('$this->record() still resolves to Auditable::record (trait shadows parent)', () => { + expect(callsFromTo('callViaThis', 'record', 'app/Auditable.php').length).toBe(1); + expect(callsFromTo('callViaThis', 'record', 'app/Base.php').length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Namespace-aware free-call fallback. PHP's `pickUniqueGlobalCallable` must +// reject cross-namespace candidates that the caller can't reach without an +// explicit `use function` import. Same-namespace and globally-imported calls +// still emit edges. +// --------------------------------------------------------------------------- + +describe('PHP namespace-aware free-call fallback (U4)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-namespace-fallback-isolation'), + () => {}, + ); + }, 60000); + + const callsFromTo = (source: string, target: string, file?: string) => + getRelationships(result, 'CALLS').filter( + (c) => + c.source === source && + c.target === target && + (file === undefined || c.targetFilePath === file), + ); + + it('rejects cross-namespace candidate when caller has no use-function import', () => { + // callNoImport (in \App) calls format('x'). Workspace has \App\Utils\format/1 + // and \Vendor\Utils\format/2. Caller is in \App — NOT same namespace as + // either candidate, and no `use function` for `format` is in scope. + // Expected: NO CALLS edge. + expect(callsFromTo('callNoImport', 'format').length).toBe(0); + }); + + it('resolves same-namespace free call (caller in App\\Utils → App\\Utils\\format)', () => { + expect(callsFromTo('callSameNamespace', 'format', 'src/App/Utils/Format.php').length).toBe(1); + }); + + it('resolves use-function-imported alias (vendorFormat → Vendor\\Utils\\format)', () => { + // `use function Vendor\Utils\format as vendorFormat;`. Caller in \App calls + // vendorFormat('x', 80) — the import target is reachable. The CALLS edge + // may surface against either the alias name (`vendorFormat`) or the + // canonical function name (`format` in the vendor file) depending on + // dedup ordering; either way, exactly one edge total. + expect( + callsFromTo('callImported', 'vendorFormat').length + + callsFromTo('callImported', 'format', 'src/Vendor/Utils/Format.php').length, + ).toBe(1); + }); +}); + // --------------------------------------------------------------------------- // Local shadow: same-file definition takes priority over imported name // --------------------------------------------------------------------------- @@ -1807,3 +1973,325 @@ describe('PHP Child extends ParentClass — inherited method resolution (SM-9)', expect(parentMethodCall!.source).toBe('run'); }); }); + +// --------------------------------------------------------------------------- +// Fully-qualified type-hint resolution (Codex PR #1497 review, finding 1). +// +// Two `User` classes coexist in the workspace: `App\Models\User` and +// `App\Other\User`. A service file imports the simple-name `User` from +// App\Models, but uses a fully-qualified `\App\Other\User` in a parameter +// annotation. PHP runtime semantics: the leading `\` is an absolute namespace +// path; the parameter is always `App\Other\User`, even when the simple +// `User` is bound to a different class by `use`. +// +// Pre-fix: `normalizePhpType` strips the qualifier so the TypeRef carries +// only `User`, then `findClassBindingInScope` walks the scope chain and +// resolves to the imported `App\Models\User` — emitting a CALLS edge to the +// wrong class. Post-fix: qualified form survives on `rawName`, the +// QualifiedNameIndex fallback (or a PHP-specific qualified lookup) routes +// the call to App\Other\User::record. +// --------------------------------------------------------------------------- + +describe('PHP fully-qualified type-hint resolution (Codex #1497)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'php-fqn-cross-namespace'), () => {}); + }, 60000); + + const callsFromTo = (source: string, target: string, file: string) => + getRelationships(result, 'CALLS').filter( + (c) => c.source === source && c.target === target && c.targetFilePath === file, + ); + + it('detects both User classes in distinct namespaces', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + // Exactly two User entries — one per namespace. + const userClasses = getNodesByLabelFull(result, 'Class').filter((n) => n.name === 'User'); + expect(userClasses.length).toBe(2); + const userFiles = userClasses.map((c) => c.properties.filePath as string).sort(); + expect( + userFiles.some((f) => f.includes('Models/User.php') || f.includes('Models\\User.php')), + ).toBe(true); + expect( + userFiles.some((f) => f.includes('Other/User.php') || f.includes('Other\\User.php')), + ).toBe(true); + }); + + it('\\App\\Other\\User parameter resolves $u->record() to app/Other/User.php (NOT app/Models/User.php)', () => { + // The bug Codex flagged: FQN parameter collapses to simple `User`, then + // resolves to the imported `App\Models\User` instead of the explicit + // `\App\Other\User` named in the annotation. Post-fix: exactly one edge, + // pointing to the FQN target. + expect(callsFromTo('save', 'record', 'app/Other/User.php').length).toBe(1); + expect(callsFromTo('save', 'record', 'app/Models/User.php').length).toBe(0); + }); + + it('simple-name `User $u` parameter resolves to the imported App\\Models\\User (control case)', () => { + // Sanity check that unqualified type-hint resolution still works via the + // `use App\Models\User;` import. Without this control, U2's normalizer + // change could regress the simple-name path and we'd miss it. + expect(callsFromTo('saveLocal', 'record', 'app/Models/User.php').length).toBe(1); + expect(callsFromTo('saveLocal', 'record', 'app/Other/User.php').length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// MRO arity-mismatch: most-derived override with incompatible arity must NOT +// fall through to an arity-compatible ancestor (PHP throws ArgumentCountError +// at runtime). See receiver-bound-calls.ts Case 2. +// --------------------------------------------------------------------------- + +describe('PHP MRO arity-mismatch fallthrough', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'php-mro-arity-mismatch'), () => {}); + }, 60000); + + const callsFromTo = (source: string, target: string, targetFilePath: string) => + getRelationships(result, 'CALLS').filter( + (c) => c.source === source && c.target === target && c.targetFilePath === targetFilePath, + ); + + it('detects ParentModel, ChildModel, Orphan, and Caller classes', () => { + expect(getNodesByLabel(result, 'Class')).toEqual([ + 'Caller', + 'ChildModel', + 'Orphan', + 'ParentModel', + ]); + }); + + it('arity-incompatible most-derived override does NOT fall through to ParentModel::method', () => { + // Pre-fix bug: `$child->method(1)` with ChildModel::method(int,int) and + // ParentModel::method(int) would emit a false CALLS edge to ParentModel::method. + // Post-fix: zero CALLS edges from callIncompatible for this site. + expect(callsFromTo('callIncompatible', 'method', 'app/Models/ParentModel.php').length).toBe(0); + expect(callsFromTo('callIncompatible', 'method', 'app/Models/ChildModel.php').length).toBe(0); + }); + + it('arity-compatible most-derived override emits exactly one CALLS edge to ChildModel::compat', () => { + // Happy path: ChildModel::compat(int) matches the call site $child->compat(1). + expect(callsFromTo('callCompatible', 'compat', 'app/Models/ChildModel.php').length).toBe(1); + expect(callsFromTo('callCompatible', 'compat', 'app/Models/ParentModel.php').length).toBe(0); + }); + + it('arity-incompatible class with no parent emits zero CALLS edges (regression check)', () => { + // Orphan::method(int,int) called with one arg, no parent class — must remain + // unresolved both before and after the fix. + expect(callsFromTo('callNoParent', 'method', 'app/Models/Orphan.php').length).toBe(0); + }); + + it('arity-compatible most-derived call still resolves to ChildModel::method (happy path)', () => { + // Ensure the fix did not break compatible-arity resolution. + expect(callsFromTo('callMostDerivedHappy', 'method', 'app/Models/ChildModel.php').length).toBe( + 1, + ); + expect(callsFromTo('callMostDerivedHappy', 'method', 'app/Models/ParentModel.php').length).toBe( + 0, + ); + }); +}); + +// --------------------------------------------------------------------------- +// @declaration.variable double-match dedup on typed properties. +// Pre-fix, the catch-all property pattern in query.ts (no `type:` constraint) +// also matched typed property declarations and emitted a stray Variable def +// alongside the legitimate Property def. captures.ts now pre-scans rawMatches +// for @declaration.property anchors and suppresses the duplicate. +// --------------------------------------------------------------------------- + +describe('PHP typed-property double-match dedup', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'php-typed-property-dedup'), () => {}); + }, 60000); + + it('detects the Mixed class', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Mixed'); + }); + + it('emits exactly one Property def for the typed property `$repo`', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties.filter((n) => n === 'repo').length).toBe(1); + }); + + it('emits exactly one Property def for the constructor-promoted typed `$promotedRepo`', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties.filter((n) => n === 'promotedRepo').length).toBe(1); + }); + + it('emits zero stray Variable defs for typed property and promoted typed parameter', () => { + // Pre-fix: a Variable def named `$repo` and `$promotedRepo` (no `$` strip) + // would slip through the catch-all pattern. Post-fix: zero. + const variables = getNodesByLabel(result, 'Variable'); + expect(variables.filter((n) => n === '$repo' || n === 'repo').length).toBe(0); + expect(variables.filter((n) => n === '$promotedRepo' || n === 'promotedRepo').length).toBe(0); + }); + + it('untyped property `$id` still emits its catch-all Property def (regression check)', () => { + // The untyped catch-all @declaration.variable pattern is the legitimate + // path for `public $id;`. Make sure the cross-match dedup does not + // over-suppress untyped declarations — they have no @declaration.property + // sibling, so their anchor is not in the typedPropertyAnchorIds set. + const properties = getNodesByLabel(result, 'Property'); + expect(properties.filter((n) => n === 'id').length).toBe(1); + }); + + it('no `$`-prefixed Property or Variable defs leak from typed declarations', () => { + // The catch-all branch does NOT run the `$`-strip normalization, so any + // def it produces for a typed property carries a `$`-prefixed name — + // a known receiver-binding lookup pollution vector. Post-fix the + // catch-all is suppressed for typed property_declaration anchors, so + // no `$repo` / `$promotedRepo` def should appear at any label. + for (const n of result.graph.iterNodes()) { + const name = String(n.properties.name); + if (name === '$repo' || name === '$promotedRepo') { + throw new Error(`leaked $-prefixed def: ${n.label}|${name}|${n.id}`); + } + } + }); +}); + +// --------------------------------------------------------------------------- +// Dynamic PHP constructs MUST NOT capture as resolvable references. +// Findings 1-7 of the PR #1497 adversarial review confirmed via grammar +// inspection that $obj->$method(), call_user_func(...), array/string +// callables, and dynamic property reads produce zero captures. This suite +// locks that invariant in regression so a future query.ts edit cannot +// silently relax `name: (name)` to `name: (_)` and reintroduce false- +// positive edges. +// --------------------------------------------------------------------------- + +describe('PHP dynamic dispatch — negative regression suite', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'php-dynamic-calls'), () => {}); + }, 60000); + + const callsFromDynamicTo = (target: string) => + getRelationships(result, 'CALLS').filter( + (c) => + c.target === target && + // Source is some method on `Dynamic` (the file under test). + c.sourceFilePath === 'app/Services/Dynamic.php', + ); + + it('detects the Dynamic and Targets classes', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Dynamic'); + expect(getNodesByLabel(result, 'Class')).toContain('Targets'); + }); + + it('sanity check: non-dynamic call DOES emit an edge', () => { + // Without this, every zero-edge assertion below would pass even if the + // pipeline emitted no CALLS edges at all. + expect(callsFromDynamicTo('sanityStaticallyNamedTarget').length).toBe(1); + }); + + it('$obj->$method() emits no CALLS edge to dynamicProcess', () => { + expect(callsFromDynamicTo('dynamicProcess').length).toBe(0); + }); + + it('$obj->{$method}() emits no CALLS edge to dynamicBrace', () => { + expect(callsFromDynamicTo('dynamicBrace').length).toBe(0); + }); + + it('Class::$method() emits no CALLS edge to dynamicHandle', () => { + expect(callsFromDynamicTo('dynamicHandle').length).toBe(0); + }); + + it('$className::method() with untyped variable receiver emits no CALLS edge', () => { + // Two attractor classes (Targets and OtherTargets) both expose + // dynamicStaticMethod so the unresolved-receiver fallback (Finding 8 / + // U4) cannot fire — that isolates this assertion to the dynamic- + // dispatch suppression at the query / receiver-bound-calls layer. + expect(callsFromDynamicTo('dynamicStaticMethod').length).toBe(0); + }); + + it('$className::$method() with dynamic class and method names emits no CALLS edge', () => { + expect(callsFromDynamicTo('dynamicScopedDynName').length).toBe(0); + }); + + it('call_user_func / call_user_func_array string and array callables emit no CALLS edges', () => { + // call_user_func itself is a built-in with no workspace def, so the + // free-call to it is unresolved — no edge to `call_user_func`. + expect(callsFromDynamicTo('call_user_func').length).toBe(0); + expect(callsFromDynamicTo('call_user_func_array').length).toBe(0); + // None of the named targets reachable only via the callable argument + // should pick up a false-positive edge. + expect(callsFromDynamicTo('dynamicCallableMethod').length).toBe(0); + expect(callsFromDynamicTo('dynamicArrayCallableMethod').length).toBe(0); + expect(callsFromDynamicTo('dynamicArrayClassCallableMethod').length).toBe(0); + }); + + it('dynamic property read ($obj->$prop) emits no read-edge to dynamicProp', () => { + // No read-access property capture pattern exists in query.ts at all + // (Finding 2). Verify no CALLS / READS / write edge targets `dynamicProp`. + expect(callsFromDynamicTo('dynamicProp').length).toBe(0); + const reads = getRelationships(result, 'READS').filter( + (r) => r.target === 'dynamicProp' && r.sourceFilePath === 'app/Services/Dynamic.php', + ); + expect(reads.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// phpEmitUnresolvedReceiverEdges exact-required-arity gate (Finding 8 / U4). +// The 0.6-confidence fallback for untyped receivers now requires argCount +// to exactly match the candidate's required parameter count for fixed- +// arity candidates. Variadic candidates keep the relaxed argCount >= +// required semantics. +// --------------------------------------------------------------------------- + +describe('PHP unresolved-receiver fallback exact-required-arity gate', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-unresolved-receiver-arity'), + () => {}, + ); + }, 60000); + + const fallbackEdgeFromTo = (source: string, target: string) => + getRelationships(result, 'CALLS').filter( + (c) => + c.source === source && c.target === target && c.targetFilePath === 'app/Models/Handler.php', + ); + + it('detects Handler and Caller classes', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Handler'); + expect(getNodesByLabel(result, 'Class')).toContain('Caller'); + }); + + it('happy path: argCount === required (0===0) emits 0.6 fallback edge', () => { + expect(fallbackEdgeFromTo('callHappyPath', 'happyPath').length).toBe(1); + }); + + it('argCount === required (1===1) on candidate with default param still emits edge', () => { + expect(fallbackEdgeFromTo('callDefaultExactRequired', 'withDefault').length).toBe(1); + }); + + it('argCount > required (2>1) on candidate with default param emits NO edge post-fix', () => { + // Pre-fix: first-stage narrowOverloadCandidates accepted (1 <= 2 <= 2). + // Post-fix: exact-required gate rejects (2 !== 1). + expect(fallbackEdgeFromTo('callDefaultBeyondRequired', 'withDefault').length).toBe(0); + }); + + it('variadic candidate, argCount === required (1===1) emits edge', () => { + expect(fallbackEdgeFromTo('callVariadicAtRequired', 'variadicLog').length).toBe(1); + }); + + it('variadic candidate, argCount > required (2>1) emits edge (relaxed)', () => { + expect(fallbackEdgeFromTo('callVariadicBeyondRequired', 'variadicLog').length).toBe(1); + }); + + it('variadic candidate, argCount < required (1<2) emits NO edge', () => { + expect(fallbackEdgeFromTo('callVariadicBelowRequired', 'variadicLogTwoRequired').length).toBe( + 0, + ); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript-esm-js-extension.test.ts b/gitnexus/test/integration/resolvers/typescript-esm-js-extension.test.ts new file mode 100644 index 000000000..476082563 --- /dev/null +++ b/gitnexus/test/integration/resolvers/typescript-esm-js-extension.test.ts @@ -0,0 +1,62 @@ +/** + * Integration test: TypeScript ESM .js extension imports produce CALLS edges. + * + * Verifies the full pipeline: .js import → resolveImportPath strips .js → + * resolves to .ts → scope-resolver emits CALLS edge. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import path from 'path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js'; + +function writeFixtureRepo(root: string, files: Record): void { + for (const [relPath, content] of Object.entries(files)) { + const fullPath = path.join(root, relPath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content, 'utf8'); + } +} + +describe('TypeScript ESM .js extension → CALLS edges', () => { + let result: PipelineResult; + let repoDir: string | undefined; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ts-esm-js-ext-')); + writeFixtureRepo(repoDir, { + 'src/utils.ts': ` +export function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} +`, + 'src/index.ts': ` +import { estimateTokens } from './utils.js'; + +export function processText(text: string): number { + return estimateTokens(text); +} +`, + }); + result = await runPipelineFromRepo(repoDir, () => {}); + }, 60000); + + afterAll(() => { + if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + it('emits CALLS edge from processText → estimateTokens via .js import', () => { + const calls = getRelationships(result, 'CALLS'); + const edge = calls.find((c) => c.source === 'processText' && c.target === 'estimateTokens'); + expect(edge).toBeDefined(); + expect(edge!.targetFilePath).toBe('src/utils.ts'); + }); + + it('emits IMPORTS edge from index.ts → utils.ts', () => { + const imports = getRelationships(result, 'IMPORTS'); + const edge = imports.find( + (e) => e.sourceFilePath === 'src/index.ts' && e.targetFilePath === 'src/utils.ts', + ); + expect(edge).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/worker-pool.test.ts b/gitnexus/test/integration/worker-pool.test.ts index 845c1cfba..7fba8ebf5 100644 --- a/gitnexus/test/integration/worker-pool.test.ts +++ b/gitnexus/test/integration/worker-pool.test.ts @@ -354,7 +354,7 @@ describe('worker pool integration', () => { try { await expect(pool.dispatch([{ path: 'crash.ts', content: '' }])).rejects.toThrow( - /simulated startup crash|exited with code/, + /simulated startup crash|exited with code|idle timeout/, ); const warnRecords = cap.records().filter((r) => Number(r.level) >= 40 /* warn or above */); expect(warnRecords.length).toBeGreaterThan(0); diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index bbaecb932..71d7ddbdc 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -137,6 +137,101 @@ describe('generateAIContextFiles', () => { } }); + it('does not create .claude/skills/gitnexus/ when skipSkills is true (#742)', async () => { + // Regression guard for #742. The --skip-skills flag must prevent + // installSkills() from writing the 6 standard skill dirs into the + // analyzed repo. Per-test tmpdir so we start from a known-clean + // slate — the shared tmpDir from beforeAll may already contain + // .claude/skills/gitnexus/ from an earlier test. + const skipDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-skip-skills-')); + const skipStorage = path.join(skipDir, '.gitnexus'); + await fs.mkdir(skipStorage, { recursive: true }); + try { + const stats = { nodes: 50, edges: 100, processes: 5 }; + const result = await generateAIContextFiles( + skipDir, + skipStorage, + 'TestProject', + stats, + undefined, + { skipSkills: true }, + ); + + expect(result.files).toContain('.claude/skills/gitnexus/ (skipped via --skip-skills)'); + await expect( + fs.access(path.join(skipDir, '.claude', 'skills', 'gitnexus')), + ).rejects.toThrow(); + } finally { + await fs.rm(skipDir, { recursive: true, force: true }); + } + }); + + it('writes nothing when both skipAgentsMd and skipSkills are true (--index-only, #742)', async () => { + // Regression guard for #742. analyzeCommand() resolves --index-only + // into BOTH skipAgentsMd=true and skipSkills=true. This test pins + // the resolved-flag combination so a future regression that drops + // either guard fails here. Per-test tmpdir for the same reason as + // the skipSkills test above. + const idxDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-index-only-')); + const idxStorage = path.join(idxDir, '.gitnexus'); + await fs.mkdir(idxStorage, { recursive: true }); + try { + const stats = { nodes: 50, edges: 100, processes: 5 }; + const result = await generateAIContextFiles( + idxDir, + idxStorage, + 'TestProject', + stats, + undefined, + { skipAgentsMd: true, skipSkills: true }, + ); + + expect(result.files).toContain('AGENTS.md (skipped via --skip-agents-md)'); + expect(result.files).toContain('CLAUDE.md (skipped via --skip-agents-md)'); + expect(result.files).toContain('.claude/skills/gitnexus/ (skipped via --skip-skills)'); + + await expect(fs.access(path.join(idxDir, 'AGENTS.md'))).rejects.toThrow(); + await expect(fs.access(path.join(idxDir, 'CLAUDE.md'))).rejects.toThrow(); + await expect(fs.access(path.join(idxDir, '.claude', 'skills', 'gitnexus'))).rejects.toThrow(); + } finally { + await fs.rm(idxDir, { recursive: true, force: true }); + } + }); + + it('omits standard skill references from AGENTS.md/CLAUDE.md when skipSkills is true (#742)', async () => { + // The skills routing table in AGENTS.md/CLAUDE.md points agents at + // .claude/skills/gitnexus/*/SKILL.md files installed by installSkills(). + // When --skip-skills suppresses that install but AGENTS.md/CLAUDE.md + // are still written, the routing table must NOT name files that don't + // exist — otherwise every agent load incurs 6 failed reads and the + // routing instructions are worthless. Per-test tmpdir so the assertions + // are not contaminated by a CLAUDE.md from an earlier test. + const noStdDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-no-std-skills-')); + const noStdStorage = path.join(noStdDir, '.gitnexus'); + await fs.mkdir(noStdStorage, { recursive: true }); + try { + const stats = { nodes: 50, edges: 100, processes: 5 }; + await generateAIContextFiles(noStdDir, noStdStorage, 'TestProject', stats, undefined, { + skipSkills: true, + }); + + const content = await fs.readFile(path.join(noStdDir, 'CLAUDE.md'), 'utf-8'); + expect(content).not.toContain('gitnexus-exploring/SKILL.md'); + expect(content).not.toContain('gitnexus-impact-analysis/SKILL.md'); + expect(content).not.toContain('gitnexus-debugging/SKILL.md'); + expect(content).not.toContain('gitnexus-refactoring/SKILL.md'); + expect(content).not.toContain('gitnexus-guide/SKILL.md'); + expect(content).not.toContain('gitnexus-cli/SKILL.md'); + // The load-bearing imperative sections must still ship — only the + // routing rows are conditional. + expect(content).toContain('## Always Do'); + expect(content).toContain('## Never Do'); + expect(content).toContain('gitnexus://repo/TestProject/context'); + } finally { + await fs.rm(noStdDir, { recursive: true, force: true }); + } + }); + it('preserves manual AGENTS.md and CLAUDE.md edits when skipAgentsMd is enabled', async () => { const stats = { nodes: 42, edges: 84, processes: 3 }; const agentsPath = path.join(tmpDir, 'AGENTS.md'); diff --git a/gitnexus/test/unit/analyze-community-skills-gate.test.ts b/gitnexus/test/unit/analyze-community-skills-gate.test.ts new file mode 100644 index 000000000..35eaa5c77 --- /dev/null +++ b/gitnexus/test/unit/analyze-community-skills-gate.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { shouldGenerateCommunitySkillFiles } from '../../src/cli/analyze.js'; + +describe('shouldGenerateCommunitySkillFiles (#742 / PR 1485)', () => { + it('is false when --index-only is set even if --skills and pipelineResult are present', () => { + expect(shouldGenerateCommunitySkillFiles({ skills: true, indexOnly: true }, { ok: true })).toBe( + false, + ); + }); + + it('is false when pipelineResult is missing', () => { + expect(shouldGenerateCommunitySkillFiles({ skills: true, indexOnly: false }, null)).toBe(false); + expect(shouldGenerateCommunitySkillFiles({ skills: true }, undefined)).toBe(false); + }); + + it('is true when --skills is set, pipeline exists, and not index-only', () => { + expect( + shouldGenerateCommunitySkillFiles({ skills: true, indexOnly: false }, { communities: [] }), + ).toBe(true); + expect(shouldGenerateCommunitySkillFiles({ skills: true }, { x: 1 })).toBe(true); + }); + + it('is false when --skills is omitted', () => { + expect(shouldGenerateCommunitySkillFiles({ indexOnly: false }, { x: 1 })).toBe(false); + expect(shouldGenerateCommunitySkillFiles(undefined, { x: 1 })).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/ast-utils.test.ts b/gitnexus/test/unit/ast-utils.test.ts index f736d7e77..481ebc740 100644 --- a/gitnexus/test/unit/ast-utils.test.ts +++ b/gitnexus/test/unit/ast-utils.test.ts @@ -1,10 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { createParserForLanguage, getLanguageFromFilename } = vi.hoisted(() => ({ +const { createParserForLanguage, getLanguageFromFilename, parseSourceSafeSpy } = vi.hoisted(() => ({ createParserForLanguage: vi.fn(), getLanguageFromFilename: vi.fn((filePath: string) => filePath.endsWith('.py') ? 'python' : 'typescript', ), + parseSourceSafeSpy: vi.fn(), })); vi.mock('../../src/core/tree-sitter/parser-loader.js', () => ({ @@ -15,6 +16,11 @@ vi.mock('../../src/core/tree-sitter/parser-loader.js', () => ({ ), })); +vi.mock('../../src/core/tree-sitter/safe-parse.js', async () => { + const { buildSafeParseMock } = await import('../helpers/parse-source-safe-mock.js'); + return buildSafeParseMock(parseSourceSafeSpy); +}); + vi.mock('gitnexus-shared', () => ({ getLanguageFromFilename, })); @@ -72,4 +78,25 @@ describe('ensureAndParse', () => { expect(tsParse).toHaveBeenCalledTimes(2); expect(tsxParse).toHaveBeenCalledTimes(1); }); + + // Windows SIGSEGV regression: ensureAndParse must route through parseSourceSafe + // so >32 767-char inputs do not crash the process. Direct parser.parse(content) + // on strings that size SIGSEGVs on Windows; the spy assertion is what catches + // a bypass since parser.parse(40 000 chars) succeeds on Linux/macOS. + it('routes >32 767-char input through parseSourceSafe', async () => { + parseSourceSafeSpy.mockClear(); + + const fakeParse = vi.fn().mockReturnValue({ rootNode: { type: 'module' } }); + createParserForLanguage.mockResolvedValue({ parse: fakeParse }); + + const { ensureAndParse } = await import('../../src/core/embeddings/ast-utils.js'); + + const largeInput = 'const x = 1;\n'.repeat(4000); // ~52 000 chars + expect(largeInput.length).toBeGreaterThan(40_000); + + const result = await ensureAndParse(largeInput, 'big.ts'); + + expect(parseSourceSafeSpy).toHaveBeenCalled(); + expect(result).not.toBeNull(); + }); }); diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 45e1b71d2..8a9a1a629 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -216,6 +216,19 @@ describe('LocalBackend.callTool', () => { expect(result).not.toHaveProperty('warning'); }); + it('does not crash when searchFTSFromLbug throws (#1489)', async () => { + const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js'); + vi.mocked(searchFTSFromLbug).mockRejectedValueOnce(new Error('bm25Results is not iterable')); + (executeParameterized as any).mockResolvedValue([]); + + const result = await backend.callTool('query', { query: 'auth' }); + + // Should still return a valid result shape (semantic-only fallback) + expect(result).toHaveProperty('processes'); + expect(result).toHaveProperty('definitions'); + expect(result).not.toHaveProperty('error'); + }); + it('skips vector index query when VECTOR is unsupported by the platform', async () => { const cap = _captureLogger(); platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(false); diff --git a/gitnexus/test/unit/cursor-hook.test.ts b/gitnexus/test/unit/cursor-hook.test.ts new file mode 100644 index 000000000..e64979895 --- /dev/null +++ b/gitnexus/test/unit/cursor-hook.test.ts @@ -0,0 +1,636 @@ +/** + * Regression Tests: Cursor postToolUse Hook + * + * Tests the hook script at gitnexus-cursor-integration/hooks/gitnexus-hook.cjs + * which runs as a Cursor 2.4 postToolUse hook. + * + * Covers: + * - extractPattern: pattern extraction from Grep/Read/Shell tool inputs + * - findGitNexusDir: .gitnexus directory discovery (shared with Claude hook) + * - cwd validation: rejects relative paths + * - shell injection: verifies no `shell: true` in spawnSync calls + * - cross-platform: Windows .cmd extension handling + * - output shape: top-level `additional_context` (NOT Claude's `hookSpecificOutput.additionalContext`) + * - hooks.json wiring matches the script's actual handlers + * + * Cursor hooks reach the augment CLI only when cwd is inside an indexed + * repo, so behavior tests stick to early-exit paths to avoid spawning + * `npx gitnexus`. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { runHook } from '../utils/hook-test-helpers.js'; + +// ─── Path to the Cursor hook + manifest ───────────────────────────── + +const CURSOR_HOOK = path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-cursor-integration', + 'hooks', + 'gitnexus-hook.cjs', +); +const CURSOR_HOOK_LOCK = path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-cursor-integration', + 'hooks', + 'hook-lock.cjs', +); +const CURSOR_HOOKS_JSON = path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-cursor-integration', + 'hooks', + 'hooks.json', +); + +// ─── Cursor-specific output parser ────────────────────────────────── +// Cursor postToolUse output shape: { "additional_context": "..." } + +function parseCursorOutput(stdout: string): { additional_context?: string } | null { + if (!stdout.trim()) return null; + try { + return JSON.parse(stdout.trim()); + } catch { + return null; + } +} + +// ─── Test fixtures ────────────────────────────────────────────────── + +let tmpDir: string; +// Separate fixture for the concurrency guard tests: this one has a real +// `.gitnexus/` so the hook reaches acquireHookSlot. The base tmpDir above +// deliberately has no .gitnexus so unrelated early-exit tests stay cheap. +let guardTmpDir: string; +let guardGitNexusDir: string; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-cursor-hook-test-')); + spawnSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' }); + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir, stdio: 'pipe' }); + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir, stdio: 'pipe' }); + + guardTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-cursor-hook-guard-')); + guardGitNexusDir = path.join(guardTmpDir, '.gitnexus'); + fs.mkdirSync(guardGitNexusDir, { recursive: true }); + spawnSync('git', ['init'], { cwd: guardTmpDir, stdio: 'pipe' }); + spawnSync('git', ['config', 'user.email', 'test@test.com'], { + cwd: guardTmpDir, + stdio: 'pipe', + }); + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: guardTmpDir, stdio: 'pipe' }); + fs.writeFileSync(path.join(guardTmpDir, 'dummy.txt'), 'hello'); + spawnSync('git', ['add', '.'], { cwd: guardTmpDir, stdio: 'pipe' }); + spawnSync('git', ['commit', '-m', 'init'], { cwd: guardTmpDir, stdio: 'pipe' }); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + fs.rmSync(guardTmpDir, { recursive: true, force: true }); +}); + +// ─── Manifest + hook file presence ─────────────────────────────────── + +describe('Cursor integration files', () => { + it('hook script exists', () => { + expect(fs.existsSync(CURSOR_HOOK)).toBe(true); + }); + + it('hooks.json exists', () => { + expect(fs.existsSync(CURSOR_HOOKS_JSON)).toBe(true); + }); + + it('legacy augment-shell.sh has been removed', () => { + const legacy = path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-cursor-integration', + 'hooks', + 'augment-shell.sh', + ); + expect(fs.existsSync(legacy)).toBe(false); + }); +}); + +// ─── hooks.json wiring ────────────────────────────────────────────── + +describe('hooks.json wiring', () => { + const manifest = JSON.parse(fs.readFileSync(CURSOR_HOOKS_JSON, 'utf-8')); + + it('declares version 1', () => { + expect(manifest.version).toBe(1); + }); + + it('registers a postToolUse hook (not legacy beforeShellExecution)', () => { + expect(manifest.hooks.postToolUse).toBeDefined(); + expect(Array.isArray(manifest.hooks.postToolUse)).toBe(true); + expect(manifest.hooks.beforeShellExecution).toBeUndefined(); + }); + + it('matches Shell, Read, and Grep tools', () => { + const matcher: string = manifest.hooks.postToolUse[0].matcher; + expect(matcher).toMatch(/Shell/); + expect(matcher).toMatch(/Read/); + expect(matcher).toMatch(/Grep/); + }); + + it('points command at the new Node hook', () => { + const command: string = manifest.hooks.postToolUse[0].command; + expect(command).toContain('gitnexus-hook.cjs'); + expect(command).not.toContain('augment-shell.sh'); + }); + + it('declares timeout in seconds (not milliseconds)', () => { + // Cursor's `timeout` field is in seconds per + // https://cursor.com/docs/agent/hooks. Regression guard: a value of + // 1000+ here would be a >16-minute timeout, almost certainly a ms/s mixup. + const timeout: number = manifest.hooks.postToolUse[0].timeout; + expect(typeof timeout).toBe('number'); + expect(timeout).toBeGreaterThan(0); + expect(timeout).toBeLessThan(120); + }); +}); + +// ─── Source code regressions ──────────────────────────────────────── + +describe('Cursor hook source regressions', () => { + const source = fs.readFileSync(CURSOR_HOOK, 'utf-8'); + + it('does not pass shell: true to spawnSync', () => { + const lines = source.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.trim().startsWith('//') || line.trim().startsWith('*')) continue; + if (/shell:\s*(true|isWin)/.test(line)) { + throw new Error(`Cursor hook line ${i + 1} has shell injection risk: ${line.trim()}`); + } + } + }); + + it('uses npx.cmd for Windows', () => { + expect(source).toContain('npx.cmd'); + }); + + it('validates cwd is an absolute path', () => { + expect(source).toMatch(/path\.isAbsolute\(cwd\)/); + }); + + it('truncates debug error messages to 200 chars', () => { + expect(source).toContain('.slice(0, 200)'); + }); + + it('emits Cursor-shape additional_context (not Claude hookSpecificOutput)', () => { + expect(source).toContain('additional_context'); + expect(source).not.toContain('hookSpecificOutput'); + expect(source).not.toContain('hookEventName'); + }); + + it('rejects patterns shorter than 3 chars', () => { + expect(source).toMatch(/length\s*>=\s*3/); + }); + + it('passes pattern after end-of-options marker (--)', () => { + // Regression for #200 — augment patterns starting with `-` would + // otherwise be parsed as CLI flags by the gitnexus CLI. + expect(source).toMatch(/'augment',\s*'--',\s*pattern/); + }); + + it('gates on a non-global .gitnexus directory before invoking the CLI', () => { + expect(source).toContain('findGitNexusDir'); + expect(source).toContain('isGlobalRegistryDir'); + }); + + it('handles linked git worktrees via git rev-parse --git-common-dir', () => { + expect(source).toContain('--git-common-dir'); + }); +}); + +// ─── extractPattern coverage (source-level) ───────────────────────── + +describe('Cursor hook extractPattern coverage', () => { + const source = fs.readFileSync(CURSOR_HOOK, 'utf-8'); + + it("handles 'grep' tool (Cursor matcher: Grep)", () => { + expect(source).toMatch(/t === 'grep'/); + }); + + it('probes a wide alias set for Grep query field (Cursor contract not formally specified)', () => { + // Cursor 2.4 docs at https://cursor.com/docs/agent/hooks list the + // matchers but not the per-tool tool_input field names. If Cursor + // changes the contract, we want the hook to still extract *something* + // — these aliases plus the longest-string fallback give us coverage. + for (const alias of ['query', 'pattern', 'regex', 'q', 'search', 'searchQuery']) { + expect(source).toContain(`toolInput.${alias}`); + } + expect(source).toContain('pickLongestStringValue'); + }); + + it("handles 'read' tool (Cursor matcher: Read)", () => { + expect(source).toMatch(/t === 'read'/); + for (const alias of ['target_file', 'file_path', 'filePath', 'path', 'file']) { + expect(source).toContain(`toolInput.${alias}`); + } + }); + + it("handles 'shell' tool (Cursor matcher: Shell)", () => { + expect(source).toMatch(/t === 'shell'/); + expect(source).toMatch(/\\brg\\b\|\\bgrep\\b/); + }); + + it('logs raw payload to stderr when GITNEXUS_DEBUG is set (for contract diagnostics)', () => { + expect(source).toContain('GITNEXUS_DEBUG'); + expect(source).toContain('GitNexus Cursor hook stdin:'); + }); +}); + +// ─── Behavior: graceful no-op paths (no augment CLI invocation) ───── + +describe('Cursor hook behavior — early-exit paths', () => { + it('exits cleanly on empty stdin', () => { + const result = spawnSync(process.execPath, [CURSOR_HOOK], { + input: '', + encoding: 'utf-8', + timeout: 10000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe(''); + }); + + it('exits cleanly on invalid JSON stdin', () => { + const result = spawnSync(process.execPath, [CURSOR_HOOK], { + input: 'not json at all', + encoding: 'utf-8', + timeout: 10000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe(''); + }); + + it('produces no output when cwd is relative', () => { + const result = runHook(CURSOR_HOOK, { + tool_name: 'Grep', + tool_input: { query: 'validateUser' }, + cwd: 'relative/path', + }); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + }); + + it('produces no output when cwd has no .gitnexus dir', () => { + const result = runHook(CURSOR_HOOK, { + tool_name: 'Grep', + tool_input: { query: 'validateUser' }, + cwd: tmpDir, + }); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + }); + + it('produces no output for unknown tool names', () => { + const result = runHook(CURSOR_HOOK, { + tool_name: 'TotallyMadeUpTool', + tool_input: { foo: 'bar' }, + cwd: tmpDir, + }); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + }); + + it('produces no output for Shell commands without rg/grep', () => { + const result = runHook(CURSOR_HOOK, { + tool_name: 'Shell', + tool_input: { command: 'ls -la' }, + cwd: tmpDir, + }); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + }); + + it('produces no output for Grep with a 2-char query', () => { + const result = runHook(CURSOR_HOOK, { + tool_name: 'Grep', + tool_input: { query: 'is' }, + cwd: tmpDir, + }); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + }); + + it('produces no output for Read whose basename has no identifier chars', () => { + const result = runHook(CURSOR_HOOK, { + tool_name: 'Read', + tool_input: { target_file: '/tmp/--.md' }, + cwd: tmpDir, + }); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + }); + + it('produces no output for Read with no file path', () => { + const result = runHook(CURSOR_HOOK, { + tool_name: 'Read', + tool_input: {}, + cwd: tmpDir, + }); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + }); + + it('treats tool_name case-insensitively (Grep vs grep)', () => { + // Both should reach the same handler — and both should early-exit silently + // because tmpDir has no .gitnexus. + for (const toolName of ['Grep', 'grep', 'GREP']) { + const result = runHook(CURSOR_HOOK, { + tool_name: toolName, + tool_input: { query: 'validateUser' }, + cwd: tmpDir, + }); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + } + }); +}); + +// ─── Behavior: GITNEXUS_DEBUG payload logging ──────────────────────── + +describe('Cursor hook debug logging', () => { + it('echoes the payload to stderr only when GITNEXUS_DEBUG is set', () => { + const payload = { + tool_name: 'Grep', + tool_input: { query: 'validateUser' }, + cwd: tmpDir, + }; + + // GITNEXUS_DEBUG unset → stderr quiet. + const quiet = spawnSync(process.execPath, [CURSOR_HOOK], { + input: JSON.stringify(payload), + encoding: 'utf-8', + timeout: 10000, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, GITNEXUS_DEBUG: '' }, + }); + expect(quiet.status).toBe(0); + expect(quiet.stderr).not.toContain('GitNexus Cursor hook stdin'); + + // GITNEXUS_DEBUG=1 → payload echoed to stderr (stdout still empty for + // unindexed cwd, so the hook output contract is preserved). + const verbose = spawnSync(process.execPath, [CURSOR_HOOK], { + input: JSON.stringify(payload), + encoding: 'utf-8', + timeout: 10000, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, GITNEXUS_DEBUG: '1' }, + }); + expect(verbose.status).toBe(0); + expect(verbose.stderr).toContain('GitNexus Cursor hook stdin'); + expect(verbose.stderr).toContain('"tool_name":"Grep"'); + expect(verbose.stdout.trim()).toBe(''); + }); +}); + +// ─── Source code regression: concurrency guard (#1486) ───────────── + +describe('Cursor hook concurrency guard', () => { + const source = fs.readFileSync(CURSOR_HOOK, 'utf-8'); + const lockSource = fs.readFileSync(CURSOR_HOOK_LOCK, 'utf-8'); + + it('loads acquireHookSlot helper module', () => { + expect(source).toContain('acquireHookSlot'); + expect(source).toContain('hook-lock.cjs'); + }); + + it('helper defines acquireHookSlot with MAX_INFLIGHT constant', () => { + expect(lockSource).toContain('function acquireHookSlot'); + expect(lockSource).toContain('HOOK_LOCK_MAX_INFLIGHT'); + }); + + it('calls acquireHookSlot in main() and releases via finally', () => { + // The Cursor hook uses a flat main() dispatcher rather than a separate + // handlePreToolUse — assert the guard call + finally release wiring is + // present so a future refactor cannot accidentally skip it. + expect(source).toContain('acquireHookSlot('); + expect(source).toMatch(/finally\s*\{[^}]*release\(\)/s); + }); + + it('uses atomic fixed-name slot files (hard cap, not soft TOCTOU cap)', () => { + expect(lockSource).toMatch(/slot-\$\{slot\}\.lock|`slot-/); + const slotFn = lockSource.slice( + lockSource.indexOf('function acquireHookSlot'), + lockSource.indexOf('function', lockSource.indexOf('function acquireHookSlot') + 1), + ); + expect(slotFn).not.toContain('readdirSync'); + }); + + it('fails closed when lock dir cannot be created', () => { + // Regression: see hooks.test.ts. The mkdirSync catch must return null + // (skip augment) rather than `() => {}` (proceed unguarded), so that + // a read-only or cross-user `.gitnexus/` cannot reintroduce #1486. + const slotFn = lockSource.slice( + lockSource.indexOf('function acquireHookSlot'), + lockSource.indexOf('function', lockSource.indexOf('function acquireHookSlot') + 1), + ); + const mkdirCatch = slotFn.slice( + slotFn.indexOf('fs.mkdirSync(lockDir'), + slotFn.indexOf('const myPidStr'), + ); + expect(mkdirCatch).toContain('return null'); + expect(mkdirCatch).not.toMatch(/return\s*\(\s*\)\s*=>\s*\{\s*\}/); + }); + + // Note: the 10-concurrent-spawner burst test that validates `wx` + // (O_CREAT|O_EXCL) under simultaneous contention lives in + // hooks.test.ts. The Cursor hook uses byte-for-byte the same + // acquireHookSlot, so duplicating the burst test here would only test + // the OS primitive, not Cursor-specific wiring. The source-level checks + // above guarantee the Cursor hook keeps calling that same algorithm. +}); + +// ─── Integration: concurrency guard skips when slots are full ────── + +describe('Cursor hook concurrency guard (integration)', () => { + it('exits silently when all MAX_INFLIGHT slots hold live pids', async () => { + const { spawn } = await import('child_process'); + const lockDir = path.join(guardGitNexusDir, '.hook-locks'); + fs.mkdirSync(lockDir, { recursive: true }); + + const sleepers = [0, 1, 2].map(() => + spawn(process.execPath, ['-e', 'setTimeout(()=>{},60000)'], { + stdio: 'ignore', + detached: false, + }), + ); + const writtenLocks: string[] = []; + try { + for (let i = 0; i < sleepers.length; i++) { + const p = path.join(lockDir, `slot-${i}.lock`); + fs.writeFileSync(p, String(sleepers[i].pid)); + writtenLocks.push(p); + } + + const result = runHook(CURSOR_HOOK, { + tool_name: 'Grep', + tool_input: { query: 'validateUser' }, + cwd: guardTmpDir, + }); + + expect(result.stdout.trim()).toBe(''); + for (let i = 0; i < sleepers.length; i++) { + const p = path.join(lockDir, `slot-${i}.lock`); + expect(fs.existsSync(p)).toBe(true); + expect(fs.readFileSync(p, 'utf-8').trim()).toBe(String(sleepers[i].pid)); + } + } finally { + for (const child of sleepers) { + try { + child.kill(); + } catch { + /* ignore */ + } + } + for (const p of writtenLocks) { + try { + fs.unlinkSync(p); + } catch { + /* ignore */ + } + } + try { + fs.rmdirSync(lockDir); + } catch { + /* ignore */ + } + } + }); + + it('reclaims a slot held by a dead pid', () => { + const lockDir = path.join(guardGitNexusDir, '.hook-locks'); + fs.mkdirSync(lockDir, { recursive: true }); + const deadPid = 2_147_483_640; + const stalePath = path.join(lockDir, 'slot-0.lock'); + try { + fs.writeFileSync(stalePath, String(deadPid)); + expect(fs.readFileSync(stalePath, 'utf-8').trim()).toBe(String(deadPid)); + + runHook(CURSOR_HOOK, { + tool_name: 'Grep', + tool_input: { query: 'validateUser' }, + cwd: guardTmpDir, + }); + + // The hook reclaimed and then released slot-0 — either gone (released) + // or no longer owned by the dead pid. + if (fs.existsSync(stalePath)) { + expect(fs.readFileSync(stalePath, 'utf-8').trim()).not.toBe(String(deadPid)); + } + } finally { + try { + fs.unlinkSync(stalePath); + } catch { + /* already pruned */ + } + try { + fs.rmdirSync(lockDir); + } catch { + /* ignore */ + } + } + }); +}); + +// ─── Documented contract behavior (extractPattern via the live hook) ─ + +describe('Shell quoted-pattern parser limitations (documented)', () => { + // The Shell parser cannot reconstruct shell quoting. These tests pin the + // current behavior so a future "fix" doesn't silently change extraction + // — and so users diagnosing a noisy/missed pattern can find the behavior + // documented in tests. + // + // We can't observe the extracted pattern directly without an indexed + // repo, but we *can* confirm the hook reaches the augment-call path + // (vs. early-exiting) by checking exit status + clean stdout for cases + // where parseRgGrepPattern would yield a >=3-char token. + + it('quoted multi-word `rg "User Service"` extracts the first word only', () => { + const result = runHook(CURSOR_HOOK, { + tool_name: 'Shell', + tool_input: { command: 'rg "User Service" src/' }, + cwd: tmpDir, // no .gitnexus → exits early after extract + }); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe(''); + }); + + it('single-token quoted `rg "validateUser"` works as expected', () => { + const result = runHook(CURSOR_HOOK, { + tool_name: 'Shell', + tool_input: { command: 'rg "validateUser"' }, + cwd: tmpDir, + }); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe(''); + }); +}); + +// ─── Install docs ───────────────────────────────────────────────────── + +describe('Cursor integration install docs', () => { + const integrationReadme = path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-cursor-integration', + 'README.md', + ); + + it('install README exists', () => { + expect(fs.existsSync(integrationReadme)).toBe(true); + }); + + it('install README documents the hook install path', () => { + const body = fs.readFileSync(integrationReadme, 'utf-8'); + expect(body).toContain('.cursor/hooks.json'); + expect(body).toContain('hooks/gitnexus-hook.cjs'); + expect(body).toContain('hooks/hook-lock.cjs'); + expect(body).toContain('Hook install'); + }); + + it('install README documents GITNEXUS_DEBUG for payload diagnostics', () => { + const body = fs.readFileSync(integrationReadme, 'utf-8'); + expect(body).toContain('GITNEXUS_DEBUG'); + }); +}); + +// ─── Output parser sanity (synthetic JSON) ────────────────────────── + +describe('parseCursorOutput', () => { + it('parses a well-formed { additional_context } payload', () => { + const parsed = parseCursorOutput('{"additional_context":"hello"}'); + expect(parsed).not.toBeNull(); + expect(parsed?.additional_context).toBe('hello'); + }); + + it('returns null on empty stdout', () => { + expect(parseCursorOutput('')).toBeNull(); + expect(parseCursorOutput(' \n')).toBeNull(); + }); + + it('returns null on malformed JSON', () => { + expect(parseCursorOutput('not json')).toBeNull(); + }); +}); diff --git a/gitnexus/test/unit/esm-extension-resolution.test.ts b/gitnexus/test/unit/esm-extension-resolution.test.ts new file mode 100644 index 000000000..69dc652cf --- /dev/null +++ b/gitnexus/test/unit/esm-extension-resolution.test.ts @@ -0,0 +1,153 @@ +/** + * Unit tests for TypeScript ESM .js extension resolution. + * + * TypeScript ESM requires imports to use .js extensions even when source + * files are .ts. The resolver must map .js → .ts (and .jsx → .tsx, + * .mjs → .mts, .cjs → .cts) when the literal .js file does not exist. + */ + +import { describe, it, expect } from 'vitest'; +import { resolveImportPath } from '../../src/core/ingestion/import-resolvers/standard.js'; +import { stripJsExtension } from '../../src/core/ingestion/import-resolvers/standard.js'; +import { buildSuffixIndex } from '../../src/core/ingestion/import-resolvers/utils.js'; +import { SupportedLanguages } from 'gitnexus-shared'; + +function makeCtx(files: string[]) { + // Match production normalization: only replace backslashes with forward slashes + const normalized = files.map((f) => f.replace(/\\/g, '/')); + const allFilesSet = new Set(files); + const index = buildSuffixIndex(normalized, files); + const cache = new Map(); + return { files, normalized, allFilesSet, index, cache }; +} + +function resolve( + currentFile: string, + importPath: string, + language: SupportedLanguages, + ctx: ReturnType, +): string | null { + return resolveImportPath( + currentFile, + importPath, + ctx.allFilesSet, + ctx.files, + ctx.normalized, + ctx.cache, + language, + null, + ctx.index, + ); +} + +describe('TypeScript ESM .js extension resolution', () => { + it('resolves ./utils.js to ./utils.ts when .js does not exist', () => { + const ctx = makeCtx(['src/index.ts', 'src/utils.ts']); + const result = resolve('src/index.ts', './utils.js', SupportedLanguages.TypeScript, ctx); + expect(result).toBe('src/utils.ts'); + }); + + it('resolves ./component.jsx to ./component.tsx', () => { + const ctx = makeCtx(['src/app.ts', 'src/component.tsx']); + const result = resolve('src/app.ts', './component.jsx', SupportedLanguages.TypeScript, ctx); + expect(result).toBe('src/component.tsx'); + }); + + it('resolves ./config.mjs to ./config.mts', () => { + const ctx = makeCtx(['src/index.ts', 'src/config.mts']); + const result = resolve('src/index.ts', './config.mjs', SupportedLanguages.TypeScript, ctx); + expect(result).toBe('src/config.mts'); + }); + + it('resolves ./legacy.cjs to ./legacy.cts', () => { + const ctx = makeCtx(['src/index.ts', 'src/legacy.cts']); + const result = resolve('src/index.ts', './legacy.cjs', SupportedLanguages.TypeScript, ctx); + expect(result).toBe('src/legacy.cts'); + }); + + it('prefers actual .js file when it exists', () => { + const ctx = makeCtx(['src/index.ts', 'src/utils.js', 'src/utils.ts']); + const result = resolve('src/index.ts', './utils.js', SupportedLanguages.TypeScript, ctx); + expect(result).toBe('src/utils.js'); + }); + + it('resolves relative path with ../ and .js extension', () => { + const ctx = makeCtx(['src/helpers/token.ts', 'src/core/engine.ts']); + const result = resolve( + 'src/core/engine.ts', + '../helpers/token.js', + SupportedLanguages.TypeScript, + ctx, + ); + expect(result).toBe('src/helpers/token.ts'); + }); + + it('works for JavaScript language too', () => { + const ctx = makeCtx(['src/index.js', 'src/utils.ts']); + const result = resolve('src/index.js', './utils.js', SupportedLanguages.JavaScript, ctx); + expect(result).toBe('src/utils.ts'); + }); + + it('does NOT apply ESM fallback for non-TS/JS languages', () => { + const ctx = makeCtx(['src/main.py', 'src/utils.ts']); + const result = resolve('src/main.py', './utils.js', SupportedLanguages.Python, ctx); + expect(result).toBeNull(); + }); + + it('returns null when neither .js nor .ts exists', () => { + const ctx = makeCtx(['src/index.ts']); + const result = resolve('src/index.ts', './missing.js', SupportedLanguages.TypeScript, ctx); + expect(result).toBeNull(); + }); +}); + +describe('ESM extension resolution — .mjs/.cjs with competing siblings', () => { + it('resolves ./config.mjs to .ts when only .ts exists (no .mts)', () => { + const ctx = makeCtx(['src/index.ts', 'src/config.ts']); + const result = resolve('src/index.ts', './config.mjs', SupportedLanguages.TypeScript, ctx); + // .ts wins because EXTENSIONS order tries .ts before .mts + expect(result).toBe('src/config.ts'); + }); + + it('resolves ./config.mjs to .mts when both .ts and .mts exist', () => { + // Note: EXTENSIONS order is .tsx, .ts, .mts, .cts — so .ts wins over .mts. + // This is intentional for a source-analysis tool: we resolve to the first + // matching source file. In practice, having both config.ts and config.mts + // in the same directory is extremely rare. + const ctx = makeCtx(['src/index.ts', 'src/config.ts', 'src/config.mts']); + const result = resolve('src/index.ts', './config.mjs', SupportedLanguages.TypeScript, ctx); + expect(result).toBe('src/config.ts'); + }); + + it('resolves ./config.cjs to .cts when only .cts exists', () => { + const ctx = makeCtx(['src/index.ts', 'src/config.cts']); + const result = resolve('src/index.ts', './config.cjs', SupportedLanguages.TypeScript, ctx); + expect(result).toBe('src/config.cts'); + }); +}); + +describe('ESM extension resolution — directory index boundary', () => { + it('resolves ./dir.js to dir/index.ts when dir/ exists (bundler-mode)', () => { + // After stripping .js from "dir.js" → "dir", tryResolveWithExtensions probes + // "/index.ts" suffix. This matches bundler-mode behavior where bare directory + // imports resolve to index files. Intentional for source-analysis compatibility. + const ctx = makeCtx(['src/index.ts', 'src/dir/index.ts']); + const result = resolve('src/index.ts', './dir.js', SupportedLanguages.TypeScript, ctx); + expect(result).toBe('src/dir/index.ts'); + }); + + it('resolves ./dir/index.js to dir/index.ts', () => { + const ctx = makeCtx(['src/index.ts', 'src/dir/index.ts']); + const result = resolve('src/index.ts', './dir/index.js', SupportedLanguages.TypeScript, ctx); + expect(result).toBe('src/dir/index.ts'); + }); +}); + +describe('stripJsExtension', () => { + it('strips .js', () => expect(stripJsExtension('foo/bar.js')).toBe('foo/bar')); + it('strips .jsx', () => expect(stripJsExtension('foo/bar.jsx')).toBe('foo/bar')); + it('strips .mjs', () => expect(stripJsExtension('foo/bar.mjs')).toBe('foo/bar')); + it('strips .cjs', () => expect(stripJsExtension('foo/bar.cjs')).toBe('foo/bar')); + it('returns null for .ts', () => expect(stripJsExtension('foo/bar.ts')).toBeNull()); + it('returns null for no extension', () => expect(stripJsExtension('foo/bar')).toBeNull()); +}); diff --git a/gitnexus/test/unit/git-clone.test.ts b/gitnexus/test/unit/git-clone.test.ts index 18ac8e9d6..88832aa83 100644 --- a/gitnexus/test/unit/git-clone.test.ts +++ b/gitnexus/test/unit/git-clone.test.ts @@ -7,12 +7,12 @@ import { buildCloneArgs, normalizeGitUrlForCompare, assertRemoteMatchesRequestedUrl, - getRemoteOriginUrl, } from '../../src/server/git-clone.js'; import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs/promises'; import { spawn } from 'node:child_process'; +import { getRemoteOriginUrl } from '../../src/storage/git.js'; describe('git-clone', () => { describe('extractRepoName', () => { @@ -50,17 +50,6 @@ describe('git-clone', () => { expect(() => extractRepoName('https://example.com/foo:.')).toThrow('valid repository name'); }); - it('rejects URLs with shell metacharacters in the last segment', () => { - // The split on /[/:]/ does not split on backslashes or other shell chars, - // so a name like `repo;rm -rf /` would slip through without the pattern. - expect(() => extractRepoName('https://example.com/foo:repo;rm')).toThrow( - 'valid repository name', - ); - expect(() => extractRepoName('https://example.com/foo:repo$x')).toThrow( - 'valid repository name', - ); - }); - it('rejects empty input', () => { expect(() => extractRepoName('')).toThrow('valid repository name'); }); @@ -77,6 +66,31 @@ describe('git-clone', () => { // multiple seconds on 10k slashes). expect(elapsedMs).toBeLessThan(500); }); + + it('strips leading dashes to prevent argument injection', () => { + expect(extractRepoName('https://github.com/user/--upload-pack=payload.git')).toBe( + 'upload-pack_payload', + ); + expect(extractRepoName('https://github.com/user/-repo')).toBe('repo'); + }); + + it('sanitizes unsafe directory characters', () => { + // sanitizeRepoName turns into _tag_ + expect(extractRepoName('https://github.com/user/repo.git')).toBe('repo_tag_'); + }); + + it('sanitizes shell metacharacters in URL segments', () => { + // The split on /[/:]/ does not split on backslashes or other shell chars, + // so a name like `repo;rm -rf /` would slip through without the pattern. + // After fix/sanitize-repo-name, these are sanitized to underscores. + expect(extractRepoName('https://example.com/foo:repo;rm')).toBe('repo_rm'); + expect(extractRepoName('https://example.com/foo:repo$x')).toBe('repo_x'); + }); + + it('sanitizes whitespace and backslashes', () => { + expect(extractRepoName('https://example.com/foo:repo name')).toBe('repo_name'); + expect(extractRepoName('https://example.com/foo:repo\\name')).toBe('repo_name'); + }); }); describe('getCloneDir', () => { @@ -209,7 +223,7 @@ describe('git-clone', () => { it('blocks NAT64 with embedded RFC1918 addresses', () => { // The startsWith('64:ff9b:') check covers any embedded IPv4. These - // explicit RFC1918 cases document SSRF coverage for the full private + // explicit RFC1918 architectures document SSRF coverage for the full private // IPv4 surface — not just loopback and cloud metadata. expect(() => validateGitUrl('http://[64:ff9b::a00:1]/repo.git')).toThrow('private/internal'); // 10.0.0.1 expect(() => validateGitUrl('http://[64:ff9b::ac10:1]/repo.git')).toThrow('private/internal'); // 172.16.0.1 diff --git a/gitnexus/test/unit/git.test.ts b/gitnexus/test/unit/git.test.ts index af339ce69..1bebf4143 100644 --- a/gitnexus/test/unit/git.test.ts +++ b/gitnexus/test/unit/git.test.ts @@ -8,6 +8,8 @@ import { getCurrentCommit, getGitRoot, findGitRootByDotGit, + parseRepoNameFromUrl, + sanitizeRepoName, } from '../../src/storage/git.js'; // Mock child_process.execSync @@ -164,4 +166,61 @@ describe('git utilities', () => { } }); }); + + describe('sanitizeRepoName', () => { + it('strips leading dashes', () => { + expect(sanitizeRepoName('--repo')).toBe('repo'); + }); + + it('replaces unsafe characters with underscores', () => { + expect(sanitizeRepoName('repo')).toBe('repo_tag_'); + expect(sanitizeRepoName('repo:name')).toBe('repo_name'); + expect(sanitizeRepoName('repo"quoted"')).toBe('repo_quoted_'); + }); + + it('blocks path traversal segments', () => { + expect(sanitizeRepoName('.')).toBe('unknown'); + expect(sanitizeRepoName('..')).toBe('unknown'); + }); + + it('blocks Windows reserved names', () => { + expect(sanitizeRepoName('CON')).toBe('unknown'); + expect(sanitizeRepoName('prn')).toBe('unknown'); + expect(sanitizeRepoName('AUX')).toBe('unknown'); + expect(sanitizeRepoName('NUL')).toBe('unknown'); + expect(sanitizeRepoName('COM1')).toBe('unknown'); + expect(sanitizeRepoName('LPT9')).toBe('unknown'); + + // Reserved names with extensions + expect(sanitizeRepoName('CON.txt')).toBe('unknown'); + expect(sanitizeRepoName('NUL.tar.gz')).toBe('unknown'); + expect(sanitizeRepoName('AUX.local')).toBe('unknown'); + }); + + it('returns unknown for empty or invalid input', () => { + expect(sanitizeRepoName('')).toBe('unknown'); + expect(sanitizeRepoName('---')).toBe('unknown'); + }); + }); + + describe('parseRepoNameFromUrl', () => { + it('extracts and sanitizes name from HTTPS URL', () => { + expect(parseRepoNameFromUrl('https://github.com/user/my-repo.git')).toBe('my-repo'); + expect(parseRepoNameFromUrl('https://github.com/user/--payload.git')).toBe('payload'); + }); + + it('extracts and sanitizes name from SSH URL', () => { + expect(parseRepoNameFromUrl('git@github.com:user/my-repo.git')).toBe('my-repo'); + expect(parseRepoNameFromUrl('git@github.com:--payload.git')).toBe('payload'); + }); + + it('returns null for all-dash inputs (prevents registry collision)', () => { + expect(parseRepoNameFromUrl('https://github.com/user/---.git')).toBeNull(); + }); + + it('returns null for empty URL', () => { + expect(parseRepoNameFromUrl('')).toBeNull(); + expect(parseRepoNameFromUrl(null)).toBeNull(); + }); + }); }); diff --git a/gitnexus/test/unit/group/grpc-extractor.test.ts b/gitnexus/test/unit/group/grpc-extractor.test.ts index b128c6ddf..127dd6ab9 100644 --- a/gitnexus/test/unit/group/grpc-extractor.test.ts +++ b/gitnexus/test/unit/group/grpc-extractor.test.ts @@ -1,8 +1,15 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import fsp from 'node:fs/promises'; import * as path from 'node:path'; import * as os from 'node:os'; + +const { parseSourceSafeSpy } = vi.hoisted(() => ({ parseSourceSafeSpy: vi.fn() })); + +vi.mock('../../../src/core/tree-sitter/safe-parse.js', async () => { + const { buildSafeParseMock } = await import('../../helpers/parse-source-safe-mock.js'); + return buildSafeParseMock(parseSourceSafeSpy); +}); import { GrpcExtractor, buildProtoMap, @@ -677,6 +684,33 @@ stub = leaked_pb2_grpc.LeakedServiceStub(channel)`, ).toBe(false); }); }); + + describe('Windows SIGSEGV regression — large input must route through parseSourceSafe', () => { + it('routes >32 767-char source file through parseSourceSafe (not direct parser.parse)', async () => { + parseSourceSafeSpy.mockClear(); + + // Synthesize a >40 000-char source file in a language whose grpc plugin + // is always available (Go has no optional grammar — the Go plugin is + // unconditionally wired in grpc-patterns/index.ts). Direct + // parser.parse(content) on an input this size SIGSEGVs the process on + // Windows; parseSourceSafe routes through the chunked-callback path and + // works on every platform. The spy assertion is what catches the + // regression — a "no throw" assertion alone is satisfied by the bypass + // on Linux/macOS where parser.parse(40 000 chars) succeeds. + const padding = Array.from( + { length: 600 }, + (_, i) => `func helper${i}() string { return "padding-${i}-aaaaaaaaaaaaaaaaaaaaaa" }\n`, + ).join(''); + const largeGo = `package big\n\n${padding}\n`; + expect(largeGo.length).toBeGreaterThan(40_000); + + writeFile('server/big.go', largeGo); + + await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + expect(parseSourceSafeSpy).toHaveBeenCalled(); + }); + }); }); describe('buildProtoMap', () => { diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 69d3da2fa..aa648a71d 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -1,17 +1,25 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; + +const { parseSourceSafeSpy } = vi.hoisted(() => ({ parseSourceSafeSpy: vi.fn() })); + +vi.mock('../../../src/core/tree-sitter/safe-parse.js', async () => { + const { buildSafeParseMock } = await import('../../helpers/parse-source-safe-mock.js'); + return buildSafeParseMock(parseSourceSafeSpy); +}); + import { HttpRouteExtractor } from '../../../src/core/group/extractors/http-route-extractor.js'; import type { RepoHandle } from '../../../src/core/group/types.js'; describe('HttpRouteExtractor', () => { - const tmpDir = path.join(os.tmpdir(), `gitnexus-http-extract-${Date.now()}`); + let tmpDir: string; let extractor: HttpRouteExtractor; beforeEach(() => { extractor = new HttpRouteExtractor(); - fs.mkdirSync(tmpDir, { recursive: true }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-http-extract-')); }); afterEach(() => { @@ -428,6 +436,74 @@ def create_order(): consumers.find((c) => c.contractId === 'http::POST::/api/orders/{param}'), ).toBeDefined(); }); + it('extracts Python httpx.AsyncClient calls assigned to attributes or aliases', async () => { + const dir = path.join(tmpDir, 'python-httpx-consumer'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'client.py'), + ` +import httpx + +module_client = httpx.AsyncClient(base_url="https://svc.local") + +class TopicClient: + def __init__(self): + self._client = httpx.AsyncClient(base_url="https://svc.local") + + async def list_topics(self): + return await self._client.get("/topic") + + async def publish(self): + return await self._client.request("POST", "/questions/import") + + async def delete_topic(self): + return await self._client.delete("/topic") + +async def check_duplicate(): + async with httpx.AsyncClient() as client: + data = {} + data.get("/nope") + service.request("POST", "/nope") + return await client.post("https://svc.local/questions/duplicate-check") + +def unrelated_scope_collision(): + client = acquire_cache_client() + return client.get("/ignored-same-name") + +def module_scope_shadow_collision(): + client = acquire_cache_client() + return client.get("/ignored-module-same-name") + +module_client.get("/module-topic") +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + const expected = [ + 'http::GET::/topic', + 'http::POST::/questions/import', + 'http::DELETE::/topic', + 'http::POST::/questions/duplicate-check', + 'http::GET::/module-topic', + ]; + + for (const contractId of expected) { + const consumer = consumers.find((c) => c.contractId === contractId); + expect(consumer).toBeDefined(); + expect(consumer?.meta.framework).toBe('python-httpx'); + } + + expect(consumers.find((c) => c.contractId === 'http::GET::/nope')).toBeUndefined(); + expect(consumers.find((c) => c.contractId === 'http::POST::/nope')).toBeUndefined(); + expect( + consumers.find((c) => c.contractId === 'http::GET::/ignored-same-name'), + ).toBeUndefined(); + expect( + consumers.find((c) => c.contractId === 'http::GET::/ignored-module-same-name'), + ).toBeUndefined(); + }); it('extracts Java RestTemplate, WebClient and OkHttp calls', async () => { const dir = path.join(tmpDir, 'java-consumer'); @@ -815,4 +891,34 @@ export default r; expect(contracts.some((c) => c.symbolRef?.filePath?.startsWith('mentor_env/'))).toBe(false); }); }); + + describe('Windows SIGSEGV regression — large input must route through parseSourceSafe', () => { + it('routes >32 767-char source file through parseSourceSafe (not direct parser.parse)', async () => { + parseSourceSafeSpy.mockClear(); + + // >40 000-char Java controller file. Direct parser.parse(content) on + // an input this size SIGSEGVs the process on Windows. The spy assertion + // is what catches the regression — a "no throw" assertion alone is + // satisfied by the bypass on Linux/macOS where parser.parse(40 000 chars) + // succeeds. + const padding = Array.from( + { length: 600 }, + (_, i) => ` public String helper${i}() { return "padding-${i}-aaaaaaaaaaaaaaaaaaa"; }\n`, + ).join(''); + const largeJava = `package com.example;\n\n@RestController\npublic class BigController {\n${padding}}\n`; + expect(largeJava.length).toBeGreaterThan(40_000); + + // Use mkdtempSync rather than a fixed subdir name: satisfies CodeQL's + // js/insecure-temporary-file rule by generating a unique random suffix + // instead of relying on the parent tmpDir's predictable Date.now() name. + const dir = fs.mkdtempSync(path.join(tmpDir, 'large-input-')); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'src/controller/BigController.java'), largeJava); + + const mockDbExecutor = async (_query: string) => []; + await extractor.extract(mockDbExecutor, dir, makeRepo(dir)); + + expect(parseSourceSafeSpy).toHaveBeenCalled(); + }); + }); }); diff --git a/gitnexus/test/unit/group/include-extractor.test.ts b/gitnexus/test/unit/group/include-extractor.test.ts index 3956cd5f2..321773518 100644 --- a/gitnexus/test/unit/group/include-extractor.test.ts +++ b/gitnexus/test/unit/group/include-extractor.test.ts @@ -1,7 +1,15 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; + +const { parseSourceSafeSpy } = vi.hoisted(() => ({ parseSourceSafeSpy: vi.fn() })); + +vi.mock('../../../src/core/tree-sitter/safe-parse.js', async () => { + const { buildSafeParseMock } = await import('../../helpers/parse-source-safe-mock.js'); + return buildSafeParseMock(parseSourceSafeSpy); +}); + import { IncludeExtractor } from '../../../src/core/group/extractors/include-extractor.js'; import type { RepoHandle } from '../../../src/core/group/types.js'; import { normalizeContractId } from '../../../src/core/group/matching.js'; @@ -560,4 +568,35 @@ int auto_main() { return 0; }`, } }); }); + + describe('Windows SIGSEGV regression — large input must route through parseSourceSafe', () => { + it('routes >32 767-char header file through parseSourceSafe (not direct parser.parse)', async () => { + parseSourceSafeSpy.mockClear(); + + // Bump the file-size cap so the >40 000-char file isn't filtered before + // it ever reaches the parser. Direct parser.parse(content) on a string + // this size SIGSEGVs the process on Windows. The spy assertion catches + // the regression — a "no throw" assertion alone is satisfied by the + // bypass on Linux/macOS where parser.parse(40 000 chars) succeeds. + const previousLimit = process.env.GITNEXUS_MAX_FILE_SIZE; + process.env.GITNEXUS_MAX_FILE_SIZE = '512'; + try { + const includes = Array.from( + { length: 1500 }, + (_, i) => `#include "lib/header_${i}.h"\n`, + ).join(''); + const largeHeader = `#pragma once\n${includes}\nstruct Big {};\n`; + expect(largeHeader.length).toBeGreaterThan(40_000); + + writeFile('big/big.cpp', largeHeader); + + await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + expect(parseSourceSafeSpy).toHaveBeenCalled(); + } finally { + if (previousLimit === undefined) delete process.env.GITNEXUS_MAX_FILE_SIZE; + else process.env.GITNEXUS_MAX_FILE_SIZE = previousLimit; + } + }); + }); }); diff --git a/gitnexus/test/unit/group/thrift-extractor.test.ts b/gitnexus/test/unit/group/thrift-extractor.test.ts index ca7045543..fa33b0e49 100644 --- a/gitnexus/test/unit/group/thrift-extractor.test.ts +++ b/gitnexus/test/unit/group/thrift-extractor.test.ts @@ -1,8 +1,16 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import fsp from 'node:fs/promises'; import * as path from 'node:path'; import * as os from 'node:os'; + +const { parseSourceSafeSpy } = vi.hoisted(() => ({ parseSourceSafeSpy: vi.fn() })); + +vi.mock('../../../src/core/tree-sitter/safe-parse.js', async () => { + const { buildSafeParseMock } = await import('../../helpers/parse-source-safe-mock.js'); + return buildSafeParseMock(parseSourceSafeSpy); +}); + import { ThriftExtractor, buildThriftContext, @@ -580,6 +588,39 @@ class PaymentWorkflow { expect(contracts).toEqual([]); }); + + describe('Windows SIGSEGV regression — large input must route through parseSourceSafe', () => { + it('routes >32 767-char source file through parseSourceSafe (not direct parser.parse)', async () => { + parseSourceSafeSpy.mockClear(); + + // Need a base .thrift file so buildThriftContext finds at least one + // service to scan; without it the source-scan loop short-circuits. + writeFile( + 'idl/order.thrift', + `namespace java billing.v1 +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +}`, + ); + + // >40 000-char Java client file. Direct parser.parse(content) on a + // string this size SIGSEGVs the process on Windows. The spy assertion + // catches the regression — a "no throw" assertion alone is satisfied + // by the bypass on Linux/macOS where parser.parse(40 000 chars) succeeds. + const padding = Array.from( + { length: 600 }, + (_, i) => ` public String helper${i}() { return "padding-${i}-aaaaaaaaaaaaaaaaaaa"; }\n`, + ).join(''); + const largeJava = `package com.example;\n\nimport billing.v1.OrderService;\n\npublic class BigClient {\n private OrderService.Iface client;\n${padding}}\n`; + expect(largeJava.length).toBeGreaterThan(40_000); + + writeFile('src/main/java/com/example/BigClient.java', largeJava); + + await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + expect(parseSourceSafeSpy).toHaveBeenCalled(); + }); + }); }); describe('buildThriftContext', () => { diff --git a/gitnexus/test/unit/hooks.test.ts b/gitnexus/test/unit/hooks.test.ts index da19da002..346ee1ed6 100644 --- a/gitnexus/test/unit/hooks.test.ts +++ b/gitnexus/test/unit/hooks.test.ts @@ -26,6 +26,7 @@ import { runHook, parseHookOutput } from '../utils/hook-test-helpers.js'; // ─── Paths to both hook variants ──────────────────────────────────── const CJS_HOOK = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'gitnexus-hook.cjs'); +const CJS_HOOK_LOCK = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'hook-lock.cjs'); const PLUGIN_HOOK = path.resolve( __dirname, '..', @@ -35,6 +36,15 @@ const PLUGIN_HOOK = path.resolve( 'hooks', 'gitnexus-hook.js', ); +const PLUGIN_HOOK_LOCK = path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-claude-plugin', + 'hooks', + 'hook-lock.js', +); // ─── Test fixtures: temporary .gitnexus directory ─────────────────── @@ -294,6 +304,296 @@ describe('Git mutation regex', () => { } }); +// ─── Source code regression: PreToolUse concurrency guard (#1486) ── + +describe('PreToolUse concurrency guard', () => { + for (const [label, hookPath, lockPath] of [ + ['CJS', CJS_HOOK, CJS_HOOK_LOCK], + ['Plugin', PLUGIN_HOOK, PLUGIN_HOOK_LOCK], + ] as const) { + it(`${label} hook loads acquireHookSlot helper`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + expect(source).toContain('acquireHookSlot'); + expect(source).toContain('hook-lock'); + }); + + it(`${label} helper defines acquireHookSlot`, () => { + const source = fs.readFileSync(lockPath, 'utf-8'); + expect(source).toContain('function acquireHookSlot'); + expect(source).toContain('HOOK_LOCK_MAX_INFLIGHT'); + }); + + it(`${label} hook calls acquireHookSlot in handlePreToolUse`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + const preBody = source.slice( + source.indexOf('function handlePreToolUse'), + source.indexOf('function handlePostToolUse'), + ); + expect(preBody).toContain('acquireHookSlot('); + expect(preBody).toMatch(/release\(\)/); + }); + + it(`${label} hook uses atomic fixed-name slot files (hard cap)`, () => { + // Regression for the TOCTOU soft-cap: an earlier revision counted + // entries then wrote a per-pid lock, which let simultaneous bursts + // exceed MAX_INFLIGHT. The hard-cap version writes to fixed-name + // slot-N.lock paths so O_CREAT|O_EXCL is atomic across processes. + const source = fs.readFileSync(lockPath, 'utf-8'); + expect(source).toMatch(/slot-\$\{slot\}\.lock|`slot-/); + // And no longer reads the lock dir to count active hooks. + const slotFn = source.slice( + source.indexOf('function acquireHookSlot'), + source.indexOf('function', source.indexOf('function acquireHookSlot') + 1), + ); + expect(slotFn).not.toContain('readdirSync'); + }); + + it(`${label} hook fails closed when lock dir cannot be created`, () => { + // Regression: an earlier revision returned `() => {}` (truthy no-op) on + // mkdirSync failure, which left callers — `if (!release) return;` — to + // proceed unguarded and reintroduce the #1486 fan-out on read-only or + // cross-user `.gitnexus/` setups. The guard must fail closed (null). + const source = fs.readFileSync(lockPath, 'utf-8'); + const slotFn = source.slice( + source.indexOf('function acquireHookSlot'), + source.indexOf('function', source.indexOf('function acquireHookSlot') + 1), + ); + const mkdirCatch = slotFn.slice( + slotFn.indexOf('fs.mkdirSync(lockDir'), + slotFn.indexOf('const myPidStr'), + ); + expect(mkdirCatch).toContain('return null'); + expect(mkdirCatch).not.toMatch(/return\s*\(\s*\)\s*=>\s*\{\s*\}/); + }); + } +}); + +// ─── Integration: concurrency guard skips when slots are full ────── + +describe('PreToolUse concurrency guard (integration)', () => { + for (const [label, hookPath] of [ + ['CJS', CJS_HOOK], + ['Plugin', PLUGIN_HOOK], + ] as const) { + it(`${label}: hook exits silently when all MAX_INFLIGHT slots hold live pids`, async () => { + const { spawn } = await import('child_process'); + const lockDir = path.join(gitNexusDir, '.hook-locks'); + fs.mkdirSync(lockDir, { recursive: true }); + + // Spawn 3 long-sleeping node child processes to use as live PIDs. + const sleepers = [0, 1, 2].map(() => + spawn(process.execPath, ['-e', 'setTimeout(()=>{},60000)'], { + stdio: 'ignore', + detached: false, + }), + ); + const writtenLocks: string[] = []; + try { + for (let i = 0; i < sleepers.length; i++) { + // Slot files are named slot-N.lock; content is the owning PID. + const p = path.join(lockDir, `slot-${i}.lock`); + fs.writeFileSync(p, String(sleepers[i].pid)); + writtenLocks.push(p); + } + + const result = runHook(hookPath, { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }); + + expect(result.stdout.trim()).toBe(''); + // Sentinel slot files survive; the hook bailed before claiming any of them. + for (let i = 0; i < sleepers.length; i++) { + const p = path.join(lockDir, `slot-${i}.lock`); + expect(fs.existsSync(p)).toBe(true); + // Owner unchanged. + expect(fs.readFileSync(p, 'utf-8').trim()).toBe(String(sleepers[i].pid)); + } + } finally { + for (const child of sleepers) { + try { + child.kill(); + } catch { + /* ignore */ + } + } + for (const p of writtenLocks) { + try { + fs.unlinkSync(p); + } catch { + /* ignore */ + } + } + try { + fs.rmdirSync(lockDir); + } catch { + /* ignore */ + } + } + }); + + it(`${label}: hook reclaims a slot held by a dead pid`, () => { + const lockDir = path.join(gitNexusDir, '.hook-locks'); + fs.mkdirSync(lockDir, { recursive: true }); + // PID 1 exists on every POSIX system (init); on Windows process.kill(1,0) + // throws. Use a definitely-dead PID instead: a very large number unlikely + // to be assigned. + const deadPid = 2_147_483_640; + const stalePath = path.join(lockDir, 'slot-0.lock'); + try { + fs.writeFileSync(stalePath, String(deadPid)); + expect(fs.readFileSync(stalePath, 'utf-8').trim()).toBe(String(deadPid)); + + runHook(hookPath, { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }); + + // The hook reclaimed and then released slot-0 — either the file is + // gone (released) or its content is something other than the dead PID. + if (fs.existsSync(stalePath)) { + expect(fs.readFileSync(stalePath, 'utf-8').trim()).not.toBe(String(deadPid)); + } + } finally { + try { + fs.unlinkSync(stalePath); + } catch { + /* already pruned */ + } + try { + fs.rmdirSync(lockDir); + } catch { + /* ignore */ + } + } + }); + + it(`${label}: hook does not exceed MAX_INFLIGHT under simultaneous bursts (hard cap)`, async () => { + // Spawn many hook processes concurrently and assert that at most + // MAX_INFLIGHT (3) slot files end up populated by live pids. The + // O_CREAT|O_EXCL slot scheme makes this a hard cap, not the soft cap + // that the count-then-claim approach gives. + const { spawn } = await import('child_process'); + const lockDir = path.join(gitNexusDir, '.hook-locks'); + // Clean any leftover slot files. + try { + for (const f of fs.readdirSync(lockDir)) fs.unlinkSync(path.join(lockDir, f)); + } catch { + /* dir may not exist yet */ + } + fs.mkdirSync(lockDir, { recursive: true }); + + // We use child workers that just claim a slot via the same algorithm + // and then sleep, so we can observe the on-disk state under contention + // without spawning the real gitnexus augment CLI. + const claimerScript = ` + const fs = require('fs'); const path = require('path'); + const lockDir = ${JSON.stringify(lockDir)}; + const MAX = 3; + const STALE = 30000; + const myPid = String(process.pid); + function tryAcquire() { + for (let slot = 0; slot < MAX; slot++) { + const p = path.join(lockDir, 'slot-' + slot + '.lock'); + for (let a = 0; a < 2; a++) { + try { fs.writeFileSync(p, myPid, { flag: 'wx' }); return p; } + catch { + let stat; try { stat = fs.statSync(p); } catch { continue; } + let live = false; + try { + const s = fs.readFileSync(p, 'utf-8').trim(); + if (s === '') live = true; + else { const o = Number.parseInt(s, 10); + if (Number.isFinite(o) && o > 0) { try { process.kill(o, 0); live = true; } catch {} } + } + } catch {} + if (live && Date.now() - stat.mtimeMs > STALE) live = false; + if (live) break; + try { fs.unlinkSync(p); } catch {} + } + } + } + return null; + } + const claimed = tryAcquire(); + if (claimed) { + process.stdout.write('CLAIMED:' + claimed + '\\n'); + setTimeout(() => {}, 5000); + } else { + process.stdout.write('SKIPPED\\n'); + } + `; + + const N = 10; + const claimers = Array.from({ length: N }, () => + spawn(process.execPath, ['-e', claimerScript], { + stdio: ['ignore', 'pipe', 'ignore'], + detached: false, + }), + ); + try { + // Wait until every claimer has printed its decision. + const decisions = await Promise.all( + claimers.map( + (c) => + new Promise((resolve) => { + let buf = ''; + c.stdout!.on('data', (d) => { + buf += d.toString(); + if (buf.includes('\n')) resolve(buf.split('\n')[0]); + }); + c.on('exit', () => resolve(buf.split('\n')[0] || 'EXIT')); + }), + ), + ); + const claimedCount = decisions.filter((d) => d.startsWith('CLAIMED:')).length; + const skippedCount = decisions.filter((d) => d === 'SKIPPED').length; + + // HARD CAP: never more than 3 winners, regardless of how many bursts. + expect(claimedCount).toBeLessThanOrEqual(3); + // And the remainder must have all explicitly skipped. + expect(claimedCount + skippedCount).toBe(N); + + // On-disk state matches. + const liveSlots = fs + .readdirSync(lockDir) + .filter((f) => /^slot-\d+\.lock$/.test(f)) + .filter((f) => { + try { + const o = Number.parseInt(fs.readFileSync(path.join(lockDir, f), 'utf-8').trim(), 10); + return Number.isFinite(o) && o > 0; + } catch { + return false; + } + }); + expect(liveSlots.length).toBeLessThanOrEqual(3); + } finally { + for (const c of claimers) { + try { + c.kill(); + } catch { + /* ignore */ + } + } + try { + for (const f of fs.readdirSync(lockDir)) fs.unlinkSync(path.join(lockDir, f)); + } catch { + /* ignore */ + } + try { + fs.rmdirSync(lockDir); + } catch { + /* ignore */ + } + } + }); + } +}); + // ─── Integration: PostToolUse staleness detection ─────────────────── describe('PostToolUse staleness detection (integration)', () => { diff --git a/gitnexus/test/unit/http-embedder.test.ts b/gitnexus/test/unit/http-embedder.test.ts index 0dea77758..4ac683095 100644 --- a/gitnexus/test/unit/http-embedder.test.ts +++ b/gitnexus/test/unit/http-embedder.test.ts @@ -96,6 +96,73 @@ describe('HTTP embedding backend', () => { expect(result.length).toBe(384); }); + it('omits dimensions from request body when GITNEXUS_EMBEDDING_DIMS is unset', async () => { + process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; + process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model'; + // GITNEXUS_EMBEDDING_DIMS intentionally unset + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ embedding: mockVec }] }), + }), + ); + + const { embedText } = await import('../../src/core/embeddings/embedder.js'); + await embedText('test text'); + + const body = JSON.parse((fetch as any).mock.calls[0][1].body); + // Backends that reject unknown fields must see the pre-existing + // request shape. The field must be absent, not `undefined`. + expect('dimensions' in body).toBe(false); + }); + + it('forwards GITNEXUS_EMBEDDING_DIMS as dimensions in request body', async () => { + process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; + process.env.GITNEXUS_EMBEDDING_MODEL = 'text-embedding-3-large'; + process.env.GITNEXUS_EMBEDDING_DIMS = '1024'; + + const vec1024 = Array.from({ length: 1024 }, (_, i) => i / 1024); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ embedding: vec1024 }] }), + }), + ); + + const { embedText } = await import('../../src/core/embeddings/embedder.js'); + const result = await embedText('test text'); + + const body = JSON.parse((fetch as any).mock.calls[0][1].body); + expect(body.dimensions).toBe(1024); + expect(body.model).toBe('text-embedding-3-large'); + expect(result.length).toBe(1024); + }); + + it('forwards dimensions on the single-query path', async () => { + process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; + process.env.GITNEXUS_EMBEDDING_MODEL = 'text-embedding-3-large'; + process.env.GITNEXUS_EMBEDDING_DIMS = '512'; + + const vec512 = Array.from({ length: 512 }, (_, i) => i / 512); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ embedding: vec512 }] }), + }), + ); + + const mod = await import('../../src/mcp/core/embedder.js'); + const result = await mod.embedQuery('query text'); + + const body = JSON.parse((fetch as any).mock.calls[0][1].body); + expect(body.dimensions).toBe(512); + expect(result.length).toBe(512); + }); + it('retries on server error', async () => { process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model'; @@ -191,6 +258,51 @@ describe('HTTP embedding backend', () => { expect(results).toHaveLength(70); }); + it('forwards dimensions in every batch when splitting large inputs', async () => { + process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; + process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model'; + process.env.GITNEXUS_EMBEDDING_DIMS = '512'; + + const vec512 = Array.from({ length: 512 }, (_, i) => i / 512); + const makeResp = (n: number) => ({ + ok: true, + json: async () => ({ data: Array.from({ length: n }, () => ({ embedding: vec512 })) }), + }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce(makeResp(64)).mockResolvedValueOnce(makeResp(6)), + ); + + const { embedBatch } = await import('../../src/core/embeddings/embedder.js'); + const results = await embedBatch(Array.from({ length: 70 }, (_, i) => `text ${i}`)); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(results).toHaveLength(70); + + // Verify dimensions is sent in BOTH batch requests + const body0 = JSON.parse((fetch as any).mock.calls[0][1].body); + const body1 = JSON.parse((fetch as any).mock.calls[1][1].body); + expect(body0.dimensions).toBe(512); + expect(body1.dimensions).toBe(512); + }); + + it('rejects non-numeric GITNEXUS_EMBEDDING_DIMS values', async () => { + process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; + process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model'; + process.env.GITNEXUS_EMBEDDING_DIMS = '1024abc'; + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ embedding: mockVec }] }), + }), + ); + + const { embedText } = await import('../../src/core/embeddings/embedder.js'); + await expect(embedText('test')).rejects.toThrow('must be a positive integer'); + }); + it('rejects initEmbedder when using HTTP backend', async () => { process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model'; diff --git a/gitnexus/test/unit/hybrid-search.test.ts b/gitnexus/test/unit/hybrid-search.test.ts index ee2a4499b..813f78aa7 100644 --- a/gitnexus/test/unit/hybrid-search.test.ts +++ b/gitnexus/test/unit/hybrid-search.test.ts @@ -1,5 +1,5 @@ /** - * P1 Unit Tests: Hybrid Search (mergeWithRRF) + * P1 Unit Tests: Hybrid Search (mergeWithRRF + hybridSearch) * * Tests: mergeWithRRF from hybrid-search.ts * - BM25-only merge @@ -7,12 +7,20 @@ * - Combined ranking * - Limit parameter * - Empty inputs + * - Undefined/null inputs (#1489) + * + * Tests: hybridSearch fallback when FTS unavailable (#1489) */ -import { describe, it, expect } from 'vitest'; -import { mergeWithRRF } from '../../src/core/search/hybrid-search.js'; +import { describe, it, expect, vi } from 'vitest'; +import { mergeWithRRF, hybridSearch } from '../../src/core/search/hybrid-search.js'; import type { BM25SearchResult } from '../../src/core/search/bm25-index.js'; import type { SemanticSearchResult } from '../../src/core/embeddings/types.js'; +vi.mock('../../src/core/search/bm25-index.js', async (importOriginal) => { + const actual = (await importOriginal()) as any; + return { ...actual, searchFTSFromLbug: vi.fn() }; +}); + let bm25Rank = 0; function makeBM25(filePath: string, score: number): BM25SearchResult { return { filePath, score, rank: ++bm25Rank }; @@ -123,4 +131,72 @@ describe('mergeWithRRF', () => { expect(result[0].bm25Score).toBe(15); expect(result[0].semanticScore).toBeCloseTo(0.7); // 1 - distance }); + + // Regression: #1489 — bm25Results is not iterable when FTS unavailable + it('does not crash when bm25Results is undefined (#1489)', () => { + const semantic: SemanticSearchResult[] = [makeSemantic('src/a.ts', 0.1)]; + // Force undefined to simulate the crash path where FTS returns unexpected shape + const result = mergeWithRRF(undefined as any, semantic); + expect(result).toHaveLength(1); + expect(result[0].filePath).toBe('src/a.ts'); + expect(result[0].sources).toEqual(['semantic']); + }); + + it('does not crash when semanticResults is undefined (#1489)', () => { + const bm25: BM25SearchResult[] = [makeBM25('src/a.ts', 10)]; + const result = mergeWithRRF(bm25, undefined as any); + expect(result).toHaveLength(1); + expect(result[0].filePath).toBe('src/a.ts'); + expect(result[0].sources).toEqual(['bm25']); + }); + + it('does not crash when both inputs are undefined (#1489)', () => { + const result = mergeWithRRF(undefined as any, undefined as any); + expect(result).toHaveLength(0); + }); +}); + +// Regression: #1489 — hybridSearch must not crash when FTS is unavailable +describe('hybridSearch — FTS failure fallback (#1489)', () => { + it('falls back to semantic-only when searchFTSFromLbug throws', async () => { + const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js'); + vi.mocked(searchFTSFromLbug).mockRejectedValueOnce(new Error('bm25Results is not iterable')); + + const mockExecuteQuery = vi.fn().mockResolvedValue([]); + const mockSemanticSearch = vi + .fn() + .mockResolvedValue([makeSemantic('src/semantic-hit.ts', 0.15)]); + + const results = await hybridSearch('test query', 10, mockExecuteQuery, mockSemanticSearch); + expect(results).toHaveLength(1); + expect(results[0].filePath).toBe('src/semantic-hit.ts'); + expect(results[0].sources).toEqual(['semantic']); + }); + + it('returns empty when both FTS and semantic return nothing', async () => { + const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js'); + vi.mocked(searchFTSFromLbug).mockRejectedValueOnce(new Error('FTS unavailable')); + + const mockExecuteQuery = vi.fn().mockResolvedValue([]); + const mockSemanticSearch = vi.fn().mockResolvedValue([]); + + const results = await hybridSearch('test query', 10, mockExecuteQuery, mockSemanticSearch); + expect(results).toHaveLength(0); + }); + + it('works normally when FTS succeeds', async () => { + const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js'); + vi.mocked(searchFTSFromLbug).mockResolvedValueOnce({ + results: [{ filePath: 'src/fts-hit.ts', score: 5, rank: 1 }], + ftsAvailable: true, + }); + + const mockExecuteQuery = vi.fn().mockResolvedValue([]); + const mockSemanticSearch = vi.fn().mockResolvedValue([]); + + const results = await hybridSearch('test query', 10, mockExecuteQuery, mockSemanticSearch); + expect(results).toHaveLength(1); + expect(results[0].filePath).toBe('src/fts-hit.ts'); + expect(results[0].sources).toEqual(['bm25']); + }); }); diff --git a/gitnexus/test/unit/incremental-file-hash.test.ts b/gitnexus/test/unit/incremental-file-hash.test.ts new file mode 100644 index 000000000..0f59dfb09 --- /dev/null +++ b/gitnexus/test/unit/incremental-file-hash.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest'; +import { mkdtemp, writeFile, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import path from 'path'; +import { computeFileHash, computeFileHashes, diffFileHashes } from '../../src/storage/file-hash.js'; + +describe('diffFileHashes', () => { + it('classifies files into changed / added / deleted / toWrite', () => { + const stored = { a: 'h-a', b: 'h-b', c: 'h-c' }; + const current = new Map([ + ['a', 'h-a'], // unchanged + ['b', 'h-b-NEW'], // changed + ['d', 'h-d'], // added + // 'c' is gone → deleted + ]); + const diff = diffFileHashes(current, stored); + expect(diff.changed).toEqual(['b']); + expect(diff.added).toEqual(['d']); + expect(diff.deleted).toEqual(['c']); + // toWrite is the union of changed ∪ added (rows to be (re)written) + expect(diff.toWrite.sort()).toEqual(['b', 'd']); + }); + + it('treats no stored map as "everything is added"', () => { + const current = new Map([ + ['x', 'h1'], + ['y', 'h2'], + ]); + const diff = diffFileHashes(current, undefined); + expect(diff.added.sort()).toEqual(['x', 'y']); + expect(diff.changed).toEqual([]); + expect(diff.deleted).toEqual([]); + expect(diff.toWrite.sort()).toEqual(['x', 'y']); + }); + + it('returns sorted arrays for stable cross-platform comparison', () => { + const stored = { z: 'h', a: 'h', m: 'h' }; + const current = new Map([ + ['z', 'h2'], + ['a', 'h2'], + ['m', 'h2'], + ]); + const diff = diffFileHashes(current, stored); + expect(diff.changed).toEqual(['a', 'm', 'z']); + expect(diff.toWrite).toEqual(['a', 'm', 'z']); + }); + + it('handles empty current map (all stored files become deleted)', () => { + const stored = { a: 'h1', b: 'h2' }; + const diff = diffFileHashes(new Map(), stored); + expect(diff.deleted).toEqual(['a', 'b']); + expect(diff.changed).toEqual([]); + expect(diff.added).toEqual([]); + }); +}); + +describe('computeFileHash', () => { + it('produces a stable SHA-256 hex digest', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'gnx-fh-')); + try { + const f = path.join(dir, 'a.txt'); + await writeFile(f, 'hello world\n', 'utf-8'); + const h1 = await computeFileHash(f); + const h2 = await computeFileHash(f); + expect(h1).toBe(h2); + expect(h1).toMatch(/^[a-f0-9]{64}$/); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('returns null on missing file (caller treats as "no signature")', async () => { + const h = await computeFileHash('/definitely/does/not/exist/here.xyz'); + expect(h).toBeNull(); + }); + + it('different content → different hash', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'gnx-fh-')); + try { + const a = path.join(dir, 'a.txt'); + const b = path.join(dir, 'b.txt'); + await writeFile(a, 'hello', 'utf-8'); + await writeFile(b, 'goodbye', 'utf-8'); + const ha = await computeFileHash(a); + const hb = await computeFileHash(b); + expect(ha).not.toBeNull(); + expect(hb).not.toBeNull(); + expect(ha).not.toBe(hb); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +describe('computeFileHashes', () => { + it('hashes a small batch of files in parallel', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'gnx-fh-')); + try { + await writeFile(path.join(dir, 'one.txt'), 'A', 'utf-8'); + await writeFile(path.join(dir, 'two.txt'), 'B', 'utf-8'); + await writeFile(path.join(dir, 'three.txt'), 'C', 'utf-8'); + const map = await computeFileHashes(dir, ['one.txt', 'two.txt', 'three.txt']); + expect(map.size).toBe(3); + expect(map.get('one.txt')).toMatch(/^[a-f0-9]{64}$/); + // All distinct since contents differ + const hashes = [...map.values()]; + expect(new Set(hashes).size).toBe(3); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('omits files that fail to read (no entry in result)', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'gnx-fh-')); + try { + await writeFile(path.join(dir, 'real.txt'), 'X', 'utf-8'); + const map = await computeFileHashes(dir, ['real.txt', 'phantom.txt']); + expect(map.has('real.txt')).toBe(true); + expect(map.has('phantom.txt')).toBe(false); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/gitnexus/test/unit/incremental-orchestration.test.ts b/gitnexus/test/unit/incremental-orchestration.test.ts new file mode 100644 index 000000000..3d0d244af --- /dev/null +++ b/gitnexus/test/unit/incremental-orchestration.test.ts @@ -0,0 +1,263 @@ +/** + * Integration coverage for the `runFullAnalysis` incremental-orchestration + * wiring (Claude PR-review Finding 2). + * + * These tests exercise the *real runtime path* — they call + * `runFullAnalysis` against a real on-disk git repo backed by a real + * LadybugDB at `/.gitnexus/`, and assert behaviours that pure + * unit tests on `diffFileHashes` / `extractChangedSubgraph` cannot + * catch: + * + * - the `isIncremental` decision (post-pipeline eligibility check) + * - `incrementalInProgress` dirty-flag set-before-mutation and + * clear-on-success + * - the importer-closure expansion (1-hop reached via the writable + * set, transitive reachable via bounded BFS) + * - the "forced full rebuild on dirty-flag-from-prior-crash" path + * + * Each test creates a temporary git repo, runs the analyzer, and asserts + * on the resulting `meta.json` and graph state. Cleanup is best-effort + * (Windows LadybugDB handle release can lag; `cleanupTempDir` retries). + */ + +import { execSync } from 'child_process'; +import { writeFile, readFile, copyFile, mkdir } from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { describe, it, expect } from 'vitest'; +import { + getStoragePaths, + saveMeta, + loadMeta, + INCREMENTAL_SCHEMA_VERSION, + type RepoMeta, +} from '../../src/storage/repo-manager.js'; +import { createTempDir } from '../helpers/test-db.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_SRC = path.resolve(HERE, '..', 'fixtures', 'mini-repo', 'src'); + +/** + * Copy the mini-repo fixture into a fresh git-initialized temp directory. + * Returns the temp handle so the caller owns cleanup. + */ +async function setupMiniRepo(): Promise<{ dbPath: string; cleanup: () => Promise }> { + const tmp = await createTempDir('gitnexus-incr-orch-'); + const dest = path.join(tmp.dbPath, 'src'); + await mkdir(dest, { recursive: true }); + // Copy mini-repo fixture files + const names = [ + 'index.ts', + 'handler.ts', + 'validator.ts', + 'formatter.ts', + 'middleware.ts', + 'logger.ts', + 'db.ts', + ]; + for (const n of names) { + await copyFile(path.join(FIXTURE_SRC, n), path.join(dest, n)); + } + execSync('git init', { cwd: tmp.dbPath, stdio: 'pipe' }); + execSync('git -c user.name=test -c user.email=t@t -c commit.gpgsign=false add -A', { + cwd: tmp.dbPath, + stdio: 'pipe', + }); + execSync('git -c user.name=test -c user.email=t@t -c commit.gpgsign=false commit -q -m initial', { + cwd: tmp.dbPath, + stdio: 'pipe', + }); + return tmp; +} + +describe('runFullAnalysis — incremental orchestration', () => { + it('first run populates fileHashes + schemaVersion and clears incrementalInProgress on success', async () => { + const repo = await setupMiniRepo(); + try { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + + const { storagePath } = getStoragePaths(repo.dbPath); + const meta = await loadMeta(storagePath); + expect(meta).not.toBeNull(); + expect(meta!.schemaVersion).toBe(INCREMENTAL_SCHEMA_VERSION); + expect(meta!.fileHashes).toBeDefined(); + expect(Object.keys(meta!.fileHashes ?? {}).length).toBeGreaterThan(0); + // Dirty flag MUST be cleared after a successful run. + expect(meta!.incrementalInProgress).toBeUndefined(); + } finally { + await repo.cleanup(); + } + }, 180_000); + + it('second run on unchanged state takes the alreadyUpToDate fast path', async () => { + const repo = await setupMiniRepo(); + try { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const first = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true }, + { onProgress: () => {} }, + ); + expect(first.alreadyUpToDate).toBeUndefined(); + + const second = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true }, + { onProgress: () => {} }, + ); + // lastCommit==HEAD && working tree clean (mod GitNexus output) → + // early-return fast path. + expect(second.alreadyUpToDate).toBe(true); + } finally { + await repo.cleanup(); + } + }, 300_000); + + it('second run after a comment-only edit takes the incremental path, clears the dirty flag, and preserves graph stats exactly', async () => { + const repo = await setupMiniRepo(); + try { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + const { storagePath } = getStoragePaths(repo.dbPath); + const firstMeta = await loadMeta(storagePath); + + // Modify a source file with a COMMENT-ONLY edit — by construction + // this changes the content hash (driving the incremental code path) + // without changing any symbol, scope binding, call edge, import, + // or community membership. Therefore every graph-stat invariant + // (files / nodes / edges / communities / processes) MUST be + // bit-identical to the first run. Anything else is a regression. + const target = path.join(repo.dbPath, 'src', 'logger.ts'); + const before = await readFile(target, 'utf-8'); + await writeFile(target, before + '\n// touched by test\n', 'utf-8'); + + const second = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true }, + { onProgress: () => {} }, + ); + // The early-return alreadyUpToDate path must NOT fire (the dirty + // tree should kick the run through to incremental writeback). + expect(second.alreadyUpToDate).toBeUndefined(); + + const secondMeta = await loadMeta(storagePath); + expect(secondMeta).not.toBeNull(); + // Dirty flag must be cleared on success. + expect(secondMeta!.incrementalInProgress).toBeUndefined(); + // fileHashes[logger.ts] must have rotated to the new content. + expect(secondMeta!.fileHashes?.['src/logger.ts']).toBeDefined(); + expect(secondMeta!.fileHashes?.['src/logger.ts']).not.toBe( + firstMeta!.fileHashes?.['src/logger.ts'], + ); + // Exact-equality stats invariant. DoD §2.7: avoid bounds-only + // assertions that would mask a regression dropping half the graph. + expect(secondMeta!.stats?.files).toBe(firstMeta!.stats?.files); + expect(secondMeta!.stats?.nodes).toBe(firstMeta!.stats?.nodes); + expect(secondMeta!.stats?.edges).toBe(firstMeta!.stats?.edges); + expect(secondMeta!.stats?.communities).toBe(firstMeta!.stats?.communities); + expect(secondMeta!.stats?.processes).toBe(firstMeta!.stats?.processes); + } finally { + await repo.cleanup(); + } + }, 300_000); + + it('incremental output is byte-equivalent to a full rebuild (incremental ≡ --force on the same repo state)', async () => { + // The central correctness contract of this PR: an incremental run + // and a full rebuild from the same repo state must produce identical + // graph stats. We exercise it end-to-end: + // + // 1. setup mini-repo + run analyze (populates the index) + // 2. edit one source file (comment-only — same graph) + // 3. run incremental analyze → record secondMeta + // 4. run analyze --force from the same state → record forceMeta + // 5. assert every stats invariant is exactly equal. + // + // Steps 3 and 4 share the same on-disk file contents, so any + // divergence is purely an artifact of the writeback strategy. If + // any invariant differs, the PR's load-bearing claim is violated. + const repo = await setupMiniRepo(); + try { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + + // Step 1: initial index. + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + + // Step 2: comment-only edit, same as the test above. + const target = path.join(repo.dbPath, 'src', 'logger.ts'); + const original = await readFile(target, 'utf-8'); + await writeFile(target, original + '\n// equivalence test touch\n', 'utf-8'); + + // Step 3: incremental writeback for the edited file. + const incremental = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true }, + { onProgress: () => {} }, + ); + expect(incremental.alreadyUpToDate).toBeUndefined(); + const { storagePath } = getStoragePaths(repo.dbPath); + const secondMeta = await loadMeta(storagePath); + expect(secondMeta).not.toBeNull(); + + // Step 4: force a full rebuild from the SAME on-disk file state. + const forced = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true, force: true }, + { onProgress: () => {} }, + ); + expect(forced.alreadyUpToDate).toBeUndefined(); + const forceMeta = await loadMeta(storagePath); + expect(forceMeta).not.toBeNull(); + + // Step 5: exact-equality across every stat. `toEqual` would also + // work but `toBe` per-field makes a failure pinpoint the field. + expect(secondMeta!.stats?.files).toBe(forceMeta!.stats?.files); + expect(secondMeta!.stats?.nodes).toBe(forceMeta!.stats?.nodes); + expect(secondMeta!.stats?.edges).toBe(forceMeta!.stats?.edges); + expect(secondMeta!.stats?.communities).toBe(forceMeta!.stats?.communities); + expect(secondMeta!.stats?.processes).toBe(forceMeta!.stats?.processes); + } finally { + await repo.cleanup(); + } + }, 600_000); + + it('a stale incrementalInProgress flag at startup forces a full rebuild that clears it', async () => { + const repo = await setupMiniRepo(); + try { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + // First run lays down a normal index. + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + + // Manually corrupt meta.json with a stale dirty flag — simulates + // a crashed previous incremental run. + const { storagePath } = getStoragePaths(repo.dbPath); + const meta = await loadMeta(storagePath); + expect(meta).not.toBeNull(); + const tampered: RepoMeta = { + ...meta!, + incrementalInProgress: { + startedAt: Date.now() - 60_000, + toWriteCount: 3, + }, + }; + await saveMeta(storagePath, tampered); + + // Next run must detect the flag, force a full rebuild (which + // overwrites meta), and clear the flag. + const recovered = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true }, + { onProgress: () => {} }, + ); + // A full rebuild was taken — the alreadyUpToDate fast path + // explicitly cannot fire because the dirty-flag check rewrote + // `options.force` to true. + expect(recovered.alreadyUpToDate).toBeUndefined(); + + const after = await loadMeta(storagePath); + expect(after!.incrementalInProgress).toBeUndefined(); + } finally { + await repo.cleanup(); + } + }, 300_000); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts new file mode 100644 index 000000000..757b9cf3a --- /dev/null +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect } from 'vitest'; +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import path from 'path'; +import { + PARSE_CACHE_VERSION, + computeChunkHash, + fileContentHash, + loadParseCache, + saveParseCache, + pruneCache, + type ParseCache, +} from '../../src/storage/parse-cache.js'; +import type { ParseWorkerResult } from '../../src/core/ingestion/workers/parse-worker.js'; + +const minimalResult = (overrides: Partial = {}): ParseWorkerResult => ({ + nodes: [], + relationships: [], + symbols: [], + imports: [], + calls: [], + assignments: [], + heritage: [], + routes: [], + fetchCalls: [], + decoratorRoutes: [], + toolDefs: [], + ormQueries: [], + constructorBindings: [], + fileScopeBindings: [], + parsedFiles: [], + skippedLanguages: {}, + fileCount: 0, + ...overrides, +}); + +describe('computeChunkHash', () => { + it('produces a stable hex hash for a fixed set of (filePath, contentHash) entries', () => { + const entries = [ + { filePath: 'a.ts', contentHash: 'h-a' }, + { filePath: 'b.ts', contentHash: 'h-b' }, + { filePath: 'c.ts', contentHash: 'h-c' }, + ]; + const h1 = computeChunkHash(entries); + const h2 = computeChunkHash(entries); + expect(h1).toBe(h2); + expect(h1).toMatch(/^[a-f0-9]{64}$/); + }); + + it('is order-independent (same files in different order → same hash)', () => { + const order1 = [ + { filePath: 'a.ts', contentHash: 'h-a' }, + { filePath: 'b.ts', contentHash: 'h-b' }, + ]; + const order2 = [ + { filePath: 'b.ts', contentHash: 'h-b' }, + { filePath: 'a.ts', contentHash: 'h-a' }, + ]; + expect(computeChunkHash(order1)).toBe(computeChunkHash(order2)); + }); + + it('changes when any file content changes', () => { + const before = [ + { filePath: 'a.ts', contentHash: 'h-a' }, + { filePath: 'b.ts', contentHash: 'h-b' }, + ]; + const after = [ + { filePath: 'a.ts', contentHash: 'h-a' }, + { filePath: 'b.ts', contentHash: 'h-b-NEW' }, // b.ts content changed + ]; + expect(computeChunkHash(before)).not.toBe(computeChunkHash(after)); + }); + + it('changes when chunk membership changes (file added or removed)', () => { + const small = [ + { filePath: 'a.ts', contentHash: 'h-a' }, + { filePath: 'b.ts', contentHash: 'h-b' }, + ]; + const bigger = [...small, { filePath: 'c.ts', contentHash: 'h-c' }]; + expect(computeChunkHash(small)).not.toBe(computeChunkHash(bigger)); + }); +}); + +describe('fileContentHash', () => { + it('hashes a string deterministically', () => { + expect(fileContentHash('hello')).toBe(fileContentHash('hello')); + expect(fileContentHash('hello')).not.toBe(fileContentHash('hello!')); + expect(fileContentHash('hello')).toMatch(/^[a-f0-9]{64}$/); + }); + + it('handles Buffer input identical to its string form', () => { + const s = 'sentinel'; + expect(fileContentHash(Buffer.from(s))).toBe(fileContentHash(s)); + }); +}); + +describe('PARSE_CACHE_VERSION', () => { + it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => { + // Looks like "1+1.6.4" — schema bump prefix + actual gitnexus version + expect(PARSE_CACHE_VERSION).toMatch(/^\d+\+\d+\.\d+\.\d+/); + }); +}); + +describe('pruneCache', () => { + it('drops entries whose hashes are not in the used-set', () => { + const cache: ParseCache = { + version: PARSE_CACHE_VERSION, + entries: new Map([ + ['hash-A', [minimalResult()]], + ['hash-B', [minimalResult()]], + ['hash-C', [minimalResult()]], + ]), + usedKeys: new Set(['hash-A']), + }; + const removed = pruneCache(cache, cache.usedKeys); + expect(removed).toBe(2); + expect([...cache.entries.keys()].sort()).toEqual(['hash-A']); + }); + + it('returns 0 when every entry is in use', () => { + const cache: ParseCache = { + version: PARSE_CACHE_VERSION, + entries: new Map([ + ['hash-A', [minimalResult()]], + ['hash-B', [minimalResult()]], + ]), + usedKeys: new Set(['hash-A', 'hash-B']), + }; + expect(pruneCache(cache, cache.usedKeys)).toBe(0); + expect(cache.entries.size).toBe(2); + }); +}); + +describe('loadParseCache / saveParseCache (round-trip)', () => { + it('round-trips an empty cache', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-')); + try { + const cache: ParseCache = { + version: PARSE_CACHE_VERSION, + entries: new Map(), + usedKeys: new Set(), + }; + await saveParseCache(dir, cache); + const loaded = await loadParseCache(dir); + expect(loaded.version).toBe(PARSE_CACHE_VERSION); + expect(loaded.entries.size).toBe(0); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('returns an empty cache when the file is missing', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-')); + try { + const loaded = await loadParseCache(dir); + expect(loaded.entries.size).toBe(0); + expect(loaded.usedKeys.size).toBe(0); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('returns an empty cache on version mismatch (next-run regen)', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-')); + try { + // Write a cache file with a different version directly + const fs = await import('fs/promises'); + await fs.writeFile( + path.join(dir, 'parse-cache.json'), + JSON.stringify({ version: 'foreign-99', entries: { h: [] } }), + 'utf-8', + ); + const loaded = await loadParseCache(dir); + expect(loaded.entries.size).toBe(0); // mismatch → empty + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('returns an empty cache on corrupt JSON', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-')); + try { + const fs = await import('fs/promises'); + await fs.writeFile(path.join(dir, 'parse-cache.json'), '{not-json', 'utf-8'); + const loaded = await loadParseCache(dir); + expect(loaded.entries.size).toBe(0); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('round-trips Map and Set values through the JSON replacer/reviver', async () => { + // ParsedFile.scopes[*].typeBindings is a ReadonlyMap. + // Without the replacer/reviver pair, JSON.stringify collapses Maps to + // {} and downstream code that does .get() / iterates entries crashes + // with "is not iterable". This test pins the round-trip behaviour. + const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-')); + try { + const innerMap = new Map([ + ['k1', 'v1'], + ['k2', 'v2'], + ]); + const innerSet = new Set(['s1', 's2']); + // Stash the live Map/Set inside a synthetic ParseWorkerResult — we + // only need the serializer to traverse them. Casting to bypass the + // strict shape isn't a problem here: this test is about JSON + // round-tripping of arbitrary nested Map/Set values, not full + // ParseWorkerResult contents. + const fake = minimalResult({ + parsedFiles: [ + { + filePath: 't.ts', + // Cast through unknown to satisfy the readonly Scope shape + // while still smuggling a live Map into the serializer's + // traversal path — see comment block above. + scopes: [{ id: 's1', typeBindings: innerMap, extras: innerSet }], + } as unknown as ParseWorkerResult['parsedFiles'][number], + ], + }); + + const cache: ParseCache = { + version: PARSE_CACHE_VERSION, + entries: new Map([['chunk-h', [fake]]]), + usedKeys: new Set(['chunk-h']), + }; + await saveParseCache(dir, cache); + const loaded = await loadParseCache(dir); + const reloaded = loaded.entries.get('chunk-h')?.[0]; + expect(reloaded).toBeDefined(); + const scope = (reloaded as ParseWorkerResult).parsedFiles[0]?.scopes[0] as unknown as { + typeBindings?: unknown; + extras?: unknown; + }; + expect(scope.typeBindings).toBeInstanceOf(Map); + expect((scope.typeBindings as Map).get('k1')).toBe('v1'); + expect((scope.typeBindings as Map).size).toBe(2); + expect(scope.extras).toBeInstanceOf(Set); + expect((scope.extras as Set).has('s2')).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/gitnexus/test/unit/incremental-shadow-candidates.test.ts b/gitnexus/test/unit/incremental-shadow-candidates.test.ts new file mode 100644 index 000000000..207cc0b3c --- /dev/null +++ b/gitnexus/test/unit/incremental-shadow-candidates.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { shadowCandidatesFor } from '../../src/core/incremental/shadow-candidates.js'; + +describe('shadowCandidatesFor', () => { + it('returns an empty list when the input has no recognised module extension', () => { + expect(shadowCandidatesFor('README.md')).toEqual([]); + expect(shadowCandidatesFor('src/foo')).toEqual([]); + expect(shadowCandidatesFor('binary.so')).toEqual([]); + }); + + it('enumerates same-basename / different-extension candidates (pattern a)', () => { + const out = shadowCandidatesFor('src/foo/bar.ts'); + // All non-.ts module extensions on the same path should appear. + expect(out).toContain('src/foo/bar.tsx'); + expect(out).toContain('src/foo/bar.js'); + expect(out).toContain('src/foo/bar.jsx'); + expect(out).toContain('src/foo/bar.mjs'); + expect(out).toContain('src/foo/bar.cjs'); + expect(out).toContain('src/foo/bar.d.ts'); + // ...but NOT the same .ts (you can't shadow yourself). + expect(out).not.toContain('src/foo/bar.ts'); + }); + + it('enumerates directory-style index candidates (pattern b) for both path separators', () => { + const out = shadowCandidatesFor('src/foo/bar.ts'); + // POSIX form + expect(out).toContain('src/foo/bar/index.ts'); + expect(out).toContain('src/foo/bar/index.tsx'); + expect(out).toContain('src/foo/bar/index.js'); + // Windows form + expect(out).toContain('src/foo/bar\\index.ts'); + expect(out).toContain('src/foo/bar\\index.js'); + }); + + it('enumerates bare-file shadows when the added file is a directory index (pattern c)', () => { + const out = shadowCandidatesFor('src/foo/index.ts'); + // Adding foo/index.ts can shadow foo.{ext} (rare but real — converting + // a single-file module into a directory module). + expect(out).toContain('src/foo.ts'); + expect(out).toContain('src/foo.tsx'); + expect(out).toContain('src/foo.js'); + expect(out).toContain('src/foo.jsx'); + expect(out).toContain('src/foo.mjs'); + expect(out).toContain('src/foo.cjs'); + }); + + it('also handles the Windows-separator form of `foo\\index.ts`', () => { + const out = shadowCandidatesFor('src\\foo\\index.ts'); + expect(out).toContain('src\\foo.ts'); + expect(out).toContain('src\\foo.tsx'); + expect(out).toContain('src\\foo.js'); + }); + + it('handles `.d.ts` as a single extension token (not `.ts`)', () => { + // The longest-match scan in shadowCandidatesFor puts `.d.ts` first. + // For `foo.d.ts`, the noExt portion is "foo" (not "foo.d"), so the + // pattern (a) candidates should be the non-.d.ts module variants. + const out = shadowCandidatesFor('types/foo.d.ts'); + expect(out).toContain('types/foo.ts'); + expect(out).toContain('types/foo.tsx'); + expect(out).toContain('types/foo.js'); + // Not the .d.ts itself. + expect(out).not.toContain('types/foo.d.ts'); + }); + + it('deduplicates output (no candidate appears twice)', () => { + const out = shadowCandidatesFor('src/foo/bar.ts'); + expect(out.length).toBe(new Set(out).size); + }); + + it('never includes the input path itself', () => { + const input = 'src/foo/bar.ts'; + expect(shadowCandidatesFor(input)).not.toContain(input); + }); +}); diff --git a/gitnexus/test/unit/incremental-subgraph-extract.test.ts b/gitnexus/test/unit/incremental-subgraph-extract.test.ts new file mode 100644 index 000000000..dc720fd9e --- /dev/null +++ b/gitnexus/test/unit/incremental-subgraph-extract.test.ts @@ -0,0 +1,169 @@ +/** + * Tests for incremental DB writeback subgraph extraction. + * + * Locks the Finding 1 fix (PR #1479 review): cross-file edges between + * two unchanged files MUST land in the writeback subgraph when a third + * (changed) file alters their cross-file resolution. The pre-fix + * behaviour silently dropped those edges, leaving stale rows in the DB. + * + * These tests use synthetic graphs constructed via createKnowledgeGraph + * directly — they don't run the parser, so they're cheap and stable. + */ + +import { describe, it, expect } from 'vitest'; +import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; +import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; +import { + extractChangedSubgraph, + computeEffectiveWriteSet, +} from '../../src/core/incremental/subgraph-extract.js'; + +const makeFileNode = (id: string, filePath: string, label = 'Function'): GraphNode => + ({ + id, + label, + properties: { filePath, name: id }, + }) as unknown as GraphNode; + +const makeWideNode = (id: string, label: 'Community' | 'Process'): GraphNode => + ({ + id, + label, + properties: {}, + }) as unknown as GraphNode; + +const makeRel = ( + id: string, + sourceId: string, + targetId: string, + type = 'CALLS', +): GraphRelationship => + ({ + id, + sourceId, + targetId, + type, + properties: {}, + }) as unknown as GraphRelationship; + +describe('extractChangedSubgraph', () => { + it('includes nodes whose filePath is in the explicit toWriteSet', () => { + const g = createKnowledgeGraph(); + g.addNode(makeFileNode('a', '/repo/a.ts')); + g.addNode(makeFileNode('c', '/repo/c.ts')); + + const sub = extractChangedSubgraph(g, new Set(['/repo/c.ts'])); + + expect(sub.nodes.map((n) => n.id).sort()).toEqual(['c']); + }); + + it('always includes graph-wide nodes (Community, Process)', () => { + const g = createKnowledgeGraph(); + g.addNode(makeFileNode('a', '/repo/a.ts')); + g.addNode(makeWideNode('comm-1', 'Community')); + g.addNode(makeWideNode('proc-1', 'Process')); + + const sub = extractChangedSubgraph(g, new Set([])); // no files changed + + expect(sub.nodes.map((n) => n.id).sort()).toEqual(['comm-1', 'proc-1']); + }); + + it('includes a relationship when at least one endpoint is writable', () => { + const g = createKnowledgeGraph(); + g.addNode(makeFileNode('a:fn', '/repo/a.ts')); + g.addNode(makeFileNode('c:fn', '/repo/c.ts')); + g.addRelationship(makeRel('e1', 'a:fn', 'c:fn', 'CALLS')); + + // toWriteSet already includes A (the orchestrator expanded it via + // computeEffectiveWriteSet) — both endpoints writable, edge fires. + const sub = extractChangedSubgraph(g, new Set(['/repo/a.ts', '/repo/c.ts'])); + + expect(sub.nodes.map((n) => n.id).sort()).toEqual(['a:fn', 'c:fn']); + expect(sub.relationships.map((r) => r.id)).toEqual(['e1']); + }); + + it('skips a relationship entirely between unchanged files', () => { + const g = createKnowledgeGraph(); + g.addNode(makeFileNode('x:fn', '/repo/x.ts')); + g.addNode(makeFileNode('y:fn', '/repo/y.ts')); + g.addRelationship(makeRel('e1', 'x:fn', 'y:fn', 'CALLS')); + + const sub = extractChangedSubgraph(g, new Set(['/repo/c.ts'])); + + expect(sub.nodes).toEqual([]); + expect(sub.relationships).toEqual([]); + }); +}); + +describe('computeEffectiveWriteSet (Finding 1)', () => { + it('barrel re-export — expands the writable set to the consumer file', () => { + // Scenario: file C (a barrel) used to re-export from B; now re-exports + // from D. File A is unchanged byte-wise but its CALLS to foo() now + // resolve to D instead of B. Both A and D are unchanged at the file + // level — but A's edges have shifted. + // + // Pre-fix: toWriteSet={C} → A's nodes not deleted, A→D edge not + // inserted (neither endpoint writable). DB ends up with + // stale A→B and missing A→D. + // Post-fix: the new graph has A→C (A still imports the barrel), so + // A crosses the writable boundary and joins the effective + // write set. deleteNodesForFile(A) then clears the stale + // rows and the subgraph carries the new A→D edge. + const g = createKnowledgeGraph(); + g.addNode(makeFileNode('a:fn', '/repo/a.ts')); + g.addNode(makeFileNode('b:fn', '/repo/b.ts')); + g.addNode(makeFileNode('c:re-export', '/repo/c.ts')); + g.addNode(makeFileNode('d:fn', '/repo/d.ts')); + g.addRelationship(makeRel('e1', 'a:fn', 'c:re-export', 'IMPORTS')); + g.addRelationship(makeRel('e2', 'a:fn', 'd:fn', 'CALLS')); + + const effective = computeEffectiveWriteSet(g, new Set(['/repo/c.ts'])); + + expect([...effective].sort()).toEqual(['/repo/a.ts', '/repo/c.ts']); + }); + + it('picks up edges pointing INTO the changed file (symmetric case)', () => { + const g = createKnowledgeGraph(); + g.addNode(makeFileNode('b:fn', '/repo/b.ts')); + g.addNode(makeFileNode('c:fn', '/repo/c.ts')); + g.addRelationship(makeRel('e1', 'b:fn', 'c:fn', 'CALLS')); + + const effective = computeEffectiveWriteSet(g, new Set(['/repo/c.ts'])); + + expect([...effective].sort()).toEqual(['/repo/b.ts', '/repo/c.ts']); + }); + + it('does not expand when no edge crosses the writable boundary', () => { + const g = createKnowledgeGraph(); + g.addNode(makeFileNode('x:fn', '/repo/x.ts')); + g.addNode(makeFileNode('y:fn', '/repo/y.ts')); + g.addRelationship(makeRel('e1', 'x:fn', 'y:fn', 'CALLS')); + + const effective = computeEffectiveWriteSet(g, new Set(['/repo/c.ts'])); + + expect([...effective].sort()).toEqual(['/repo/c.ts']); + }); + + it('ignores edges to graph-wide nodes (no filePath)', () => { + const g = createKnowledgeGraph(); + g.addNode(makeFileNode('a:fn', '/repo/a.ts')); + g.addNode(makeWideNode('comm-1', 'Community')); + g.addRelationship(makeRel('e1', 'a:fn', 'comm-1', 'BELONGS_TO')); + + const effective = computeEffectiveWriteSet(g, new Set(['/repo/a.ts'])); + + expect([...effective].sort()).toEqual(['/repo/a.ts']); + }); + + it('does not mutate the input set', () => { + const g = createKnowledgeGraph(); + g.addNode(makeFileNode('a:fn', '/repo/a.ts')); + g.addNode(makeFileNode('c:fn', '/repo/c.ts')); + g.addRelationship(makeRel('e1', 'a:fn', 'c:fn', 'CALLS')); + + const input = new Set(['/repo/c.ts']); + computeEffectiveWriteSet(g, input); + + expect([...input]).toEqual(['/repo/c.ts']); + }); +}); diff --git a/gitnexus/test/unit/lbug-checkpoint-lifecycle.test.ts b/gitnexus/test/unit/lbug-checkpoint-lifecycle.test.ts new file mode 100644 index 000000000..3e9ffe8f6 --- /dev/null +++ b/gitnexus/test/unit/lbug-checkpoint-lifecycle.test.ts @@ -0,0 +1,420 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +describe('lbug adapter CHECKPOINT lifecycle', () => { + afterEach(() => { + vi.doUnmock('../../src/core/lbug/lbug-config.js'); + vi.doUnmock('../../src/core/lbug/extension-loader.js'); + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('drains and closes CHECKPOINT result before closing connection and database handles', async () => { + vi.resetModules(); + + const events: string[] = []; + const checkpointResult = { + getAll: vi.fn(async () => { + events.push('checkpoint:getAll'); + return []; + }), + close: vi.fn(() => { + events.push('checkpoint:close'); + }), + }; + const genericResult = { + getAll: vi.fn(async () => []), + close: vi.fn(), + }; + const conn = { + query: vi.fn(async (sql: string) => { + if (sql === 'CHECKPOINT') { + events.push('checkpoint:query'); + return checkpointResult; + } + return genericResult; + }), + close: vi.fn(async () => { + events.push('conn:close'); + }), + }; + const db = { + close: vi.fn(async () => { + events.push('db:close'); + }), + }; + + vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ + openLbugConnection: vi.fn(async () => ({ db, conn })), + closeLbugConnection: vi.fn(async () => {}), + isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')), + isOpenRetryExhausted: vi.fn(() => false), + waitForWindowsHandleRelease: vi.fn(async () => true), + })); + vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ + extensionManager: { + ensure: vi.fn(async () => true), + getCapabilities: vi.fn(() => []), + reset: vi.fn(), + }, + })); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.initLbug('/tmp/gitnexus-lbug-checkpoint-lifecycle/lbug'); + + events.length = 0; + await adapter.closeLbug(); + + expect(events).toEqual([ + 'checkpoint:query', + 'checkpoint:getAll', + 'checkpoint:close', + 'conn:close', + 'db:close', + ]); + }); + + it('closes normal query results after reading rows', async () => { + vi.resetModules(); + + const events: string[] = []; + const queryResult = { + getAll: vi.fn(async () => { + events.push('query:getAll'); + return [{ id: 'file:a' }]; + }), + close: vi.fn(() => { + events.push('query:close'); + }), + }; + const genericResult = { + getAll: vi.fn(async () => []), + close: vi.fn(), + }; + const conn = { + query: vi.fn(async (sql: string) => { + if (sql === 'MATCH (n:File) RETURN n.id AS id') { + events.push('query:run'); + return queryResult; + } + return genericResult; + }), + close: vi.fn(async () => {}), + }; + const db = { + close: vi.fn(async () => {}), + }; + + vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ + openLbugConnection: vi.fn(async () => ({ db, conn })), + closeLbugConnection: vi.fn(async () => {}), + isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')), + isOpenRetryExhausted: vi.fn(() => false), + waitForWindowsHandleRelease: vi.fn(async () => true), + })); + vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ + extensionManager: { + ensure: vi.fn(async () => true), + getCapabilities: vi.fn(() => []), + reset: vi.fn(), + }, + })); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.initLbug('/tmp/gitnexus-lbug-query-lifecycle/lbug'); + + events.length = 0; + await expect(adapter.executeQuery('MATCH (n:File) RETURN n.id AS id')).resolves.toEqual([ + { id: 'file:a' }, + ]); + + expect(events).toEqual(['query:run', 'query:getAll', 'query:close']); + + await adapter.closeLbug(); + }); + + it('treats synchronous query result close errors as best-effort cleanup', async () => { + vi.resetModules(); + + const queryResult = { + getAll: vi.fn(async () => [{ id: 'file:a' }]), + close: vi.fn(() => { + throw new Error('close failed'); + }), + }; + const genericResult = { + getAll: vi.fn(async () => []), + close: vi.fn(), + }; + const conn = { + query: vi.fn(async (sql: string) => { + if (sql === 'MATCH (n:File) RETURN n.id AS id') { + return queryResult; + } + return genericResult; + }), + close: vi.fn(async () => {}), + }; + const db = { + close: vi.fn(async () => {}), + }; + + vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ + openLbugConnection: vi.fn(async () => ({ db, conn })), + closeLbugConnection: vi.fn(async () => {}), + isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')), + isOpenRetryExhausted: vi.fn(() => false), + waitForWindowsHandleRelease: vi.fn(async () => true), + })); + vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ + extensionManager: { + ensure: vi.fn(async () => true), + getCapabilities: vi.fn(() => []), + reset: vi.fn(), + }, + })); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.initLbug('/tmp/gitnexus-lbug-sync-close-lifecycle/lbug'); + + await expect(adapter.executeQuery('MATCH (n:File) RETURN n.id AS id')).resolves.toEqual([ + { id: 'file:a' }, + ]); + expect(queryResult.close).toHaveBeenCalledOnce(); + + await adapter.closeLbug(); + }); + + it('closes later query results when an earlier array result fails to read', async () => { + vi.resetModules(); + + const events: string[] = []; + const firstResult = { + getAll: vi.fn(async () => { + events.push('first:getAll'); + throw new Error('read failed'); + }), + close: vi.fn(() => { + events.push('first:close'); + }), + }; + const secondResult = { + getAll: vi.fn(async () => { + events.push('second:getAll'); + return []; + }), + close: vi.fn(() => { + events.push('second:close'); + }), + }; + const genericResult = { + getAll: vi.fn(async () => []), + close: vi.fn(), + }; + const conn = { + query: vi.fn(async (sql: string) => { + if (sql === 'MATCH (n:File) RETURN n.id AS id') { + return [firstResult, secondResult]; + } + return genericResult; + }), + close: vi.fn(async () => {}), + }; + const db = { + close: vi.fn(async () => {}), + }; + + vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ + openLbugConnection: vi.fn(async () => ({ db, conn })), + closeLbugConnection: vi.fn(async () => {}), + isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')), + isOpenRetryExhausted: vi.fn(() => false), + waitForWindowsHandleRelease: vi.fn(async () => true), + })); + vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ + extensionManager: { + ensure: vi.fn(async () => true), + getCapabilities: vi.fn(() => []), + reset: vi.fn(), + }, + })); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.initLbug('/tmp/gitnexus-lbug-array-error-lifecycle/lbug'); + + await expect(adapter.executeQuery('MATCH (n:File) RETURN n.id AS id')).rejects.toThrow( + 'read failed', + ); + expect(events).toEqual(['first:getAll', 'first:close', 'second:getAll', 'second:close']); + + await adapter.closeLbug(); + }); + + it('closes non-first stream query results when LadybugDB returns an array', async () => { + vi.resetModules(); + + const events: string[] = []; + const firstResult = { + hasNext: vi + .fn() + .mockImplementationOnce(() => { + events.push('first:hasNext:true'); + return true; + }) + .mockImplementationOnce(() => { + events.push('first:hasNext:false'); + return false; + }), + getNext: vi.fn(async () => { + events.push('first:getNext'); + return { id: 'file:a' }; + }), + getAll: vi.fn(async () => { + events.push('first:getAll'); + return []; + }), + close: vi.fn(() => { + events.push('first:close'); + }), + }; + const secondResult = { + getAll: vi.fn(async () => { + events.push('second:getAll'); + return []; + }), + close: vi.fn(() => { + events.push('second:close'); + }), + }; + const genericResult = { + getAll: vi.fn(async () => []), + close: vi.fn(), + }; + const conn = { + query: vi.fn(async (sql: string) => { + if (sql === 'MATCH (n:File) RETURN n.id AS id') { + events.push('stream:query'); + return [firstResult, secondResult]; + } + return genericResult; + }), + close: vi.fn(async () => {}), + }; + const db = { + close: vi.fn(async () => {}), + }; + + vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ + openLbugConnection: vi.fn(async () => ({ db, conn })), + closeLbugConnection: vi.fn(async () => {}), + isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')), + isOpenRetryExhausted: vi.fn(() => false), + waitForWindowsHandleRelease: vi.fn(async () => true), + })); + vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ + extensionManager: { + ensure: vi.fn(async () => true), + getCapabilities: vi.fn(() => []), + reset: vi.fn(), + }, + })); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.initLbug('/tmp/gitnexus-lbug-stream-lifecycle/lbug'); + + const rows: unknown[] = []; + events.length = 0; + await expect( + adapter.streamQuery('MATCH (n:File) RETURN n.id AS id', (row) => { + rows.push(row); + }), + ).resolves.toBe(1); + + expect(rows).toEqual([{ id: 'file:a' }]); + expect(events).toEqual([ + 'stream:query', + 'first:hasNext:true', + 'first:getNext', + 'first:hasNext:false', + 'first:getAll', + 'first:close', + 'second:getAll', + 'second:close', + ]); + + await adapter.closeLbug(); + }); + + it('drains stream query results when row handling fails before the result is exhausted', async () => { + vi.resetModules(); + + const events: string[] = []; + const queryResult = { + hasNext: vi.fn(() => { + events.push('stream:hasNext'); + return true; + }), + getNext: vi.fn(async () => { + events.push('stream:getNext'); + return { id: 'file:a' }; + }), + getAll: vi.fn(async () => { + events.push('stream:getAll'); + return [{ id: 'file:b' }]; + }), + close: vi.fn(() => { + events.push('stream:close'); + }), + }; + const genericResult = { + getAll: vi.fn(async () => []), + close: vi.fn(), + }; + const conn = { + query: vi.fn(async (sql: string) => { + if (sql === 'MATCH (n:File) RETURN n.id AS id') { + events.push('stream:query'); + return queryResult; + } + return genericResult; + }), + close: vi.fn(async () => {}), + }; + const db = { + close: vi.fn(async () => {}), + }; + + vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ + openLbugConnection: vi.fn(async () => ({ db, conn })), + closeLbugConnection: vi.fn(async () => {}), + isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')), + isOpenRetryExhausted: vi.fn(() => false), + waitForWindowsHandleRelease: vi.fn(async () => true), + })); + vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ + extensionManager: { + ensure: vi.fn(async () => true), + getCapabilities: vi.fn(() => []), + reset: vi.fn(), + }, + })); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.initLbug('/tmp/gitnexus-lbug-stream-error-lifecycle/lbug'); + + await expect( + adapter.streamQuery('MATCH (n:File) RETURN n.id AS id', () => { + throw new Error('client disconnected'); + }), + ).rejects.toThrow('client disconnected'); + + expect(events).toEqual([ + 'stream:query', + 'stream:hasNext', + 'stream:getNext', + 'stream:getAll', + 'stream:close', + ]); + + await adapter.closeLbug(); + }); +}); diff --git a/gitnexus/test/unit/lbug-checkpoint.test.ts b/gitnexus/test/unit/lbug-checkpoint.test.ts index 5b68ee9bd..5b9603997 100644 --- a/gitnexus/test/unit/lbug-checkpoint.test.ts +++ b/gitnexus/test/unit/lbug-checkpoint.test.ts @@ -58,6 +58,14 @@ describe('flushWAL / safeClose — consolidation guard (#1376)', () => { expect(matches.length).toBe(1); }); + it('flushWAL drains and closes the CHECKPOINT result before returning', () => { + const flushBody = adapterSource.slice( + adapterSource.indexOf('export const flushWAL'), + adapterSource.indexOf('export const safeClose'), + ); + expect(flushBody).toMatch(/await drainQueryResult\(checkpointResult\)/); + }); + it('conn.close() only appears inside safeClose (with eslint-disable)', () => { // Every conn.close() in the adapter must live inside safeClose, guarded // by the eslint-disable comment. Count occurrences to catch leaks. diff --git a/gitnexus/test/unit/registry-primary-flag.test.ts b/gitnexus/test/unit/registry-primary-flag.test.ts index 5f688c732..864754b7f 100644 --- a/gitnexus/test/unit/registry-primary-flag.test.ts +++ b/gitnexus/test/unit/registry-primary-flag.test.ts @@ -148,16 +148,20 @@ 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; Go stays off. - process.env['REGISTRY_PRIMARY_PYTHON'] = 'false'; - process.env['REGISTRY_PRIMARY_CSHARP'] = 'false'; - process.env['REGISTRY_PRIMARY_TYPESCRIPT'] = 'false'; - process.env['REGISTRY_PRIMARY_GO'] = 'false'; + // testing explicit env overrides. Java (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 (PHP joined the set in commit 69786b16; future + // Ring 3 additions land here without test churn). + for (const lang of MIGRATED_LANGUAGES) { + process.env[envVarNameFor(lang)] = 'false'; + } process.env['REGISTRY_PRIMARY_JAVA'] = '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.PHP)).toBe(false); expect(enabled.has(SupportedLanguages.Java)).toBe(true); // Only Java is on: migrated defaults overridden off, Java explicitly on. expect(enabled.size).toBe(1); diff --git a/gitnexus/test/unit/safe-parse.test.ts b/gitnexus/test/unit/safe-parse.test.ts new file mode 100644 index 000000000..e536bbd40 --- /dev/null +++ b/gitnexus/test/unit/safe-parse.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import Parser from 'tree-sitter'; +import Python from 'tree-sitter-python'; +import { parseSourceSafe } from '../../src/core/tree-sitter/safe-parse.js'; + +const makeParser = (): Parser => { + const p = new Parser(); + p.setLanguage(Python); + return p; +}; + +const buildSource = (chars: number, lineLen = 80): string => { + const line = 'x = 1' + ' '.repeat(Math.max(0, lineLen - 6)) + '\n'; + const lines = Math.ceil(chars / line.length); + return line.repeat(lines).slice(0, chars); +}; + +describe('parseSourceSafe', () => { + it('parses small ASCII sources via the direct path', () => { + const tree = parseSourceSafe(makeParser(), 'x = 1\n'); + expect(tree.rootNode.type).toBe('module'); + expect(tree.rootNode.hasError).toBe(false); + }); + + it('parses sources at the direct/callback boundary (16 KiB)', () => { + const src = buildSource(16 * 1024); + const tree = parseSourceSafe(makeParser(), src); + expect(tree.rootNode.hasError).toBe(false); + expect(tree.rootNode.endIndex).toBe(src.length); + }); + + it('parses sources just above the boundary via the callback path', () => { + const src = buildSource(16 * 1024 + 1); + const tree = parseSourceSafe(makeParser(), src); + expect(tree.rootNode.hasError).toBe(false); + expect(tree.rootNode.endIndex).toBe(src.length); + }); + + it('parses sources at and around the 32 767-char Windows crash boundary', () => { + for (const len of [32_766, 32_767, 32_768]) { + const src = buildSource(len); + const tree = parseSourceSafe(makeParser(), src); + expect(tree.rootNode.hasError, `len=${len}`).toBe(false); + expect(tree.rootNode.endIndex, `len=${len}`).toBe(src.length); + } + }); + + it('parses a single line longer than the chunk size (no newlines)', () => { + const src = '"' + 'a'.repeat(20_000) + '"\n'; + const tree = parseSourceSafe(makeParser(), src); + expect(tree.rootNode.hasError).toBe(false); + expect(tree.rootNode.endIndex).toBe(src.length); + }); + + it('parses sources with CRLF line endings near a chunk boundary', () => { + const line = 'x = 1' + ' '.repeat(75) + '\r\n'; + const src = line.repeat(Math.ceil(20_000 / line.length)); + const tree = parseSourceSafe(makeParser(), src); + expect(tree.rootNode.hasError).toBe(false); + expect(tree.rootNode.endIndex).toBe(src.length); + }); + + it('parses a large all-non-ASCII source identically to the direct path', () => { + const small = '# ' + '漢'.repeat(50) + '\n'; + const direct = makeParser().parse(small); + const safe = parseSourceSafe(makeParser(), small); + expect(safe.rootNode.toString()).toBe(direct.rootNode.toString()); + + const large = ('# ' + '漢'.repeat(8_000) + '\n').repeat(3); + const tree = parseSourceSafe(makeParser(), large); + expect(tree.rootNode.hasError).toBe(false); + expect(tree.rootNode.endIndex).toBe(large.length); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/c/c-arity.test.ts b/gitnexus/test/unit/scope-resolution/c/c-arity.test.ts new file mode 100644 index 000000000..fddf1b932 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/c/c-arity.test.ts @@ -0,0 +1,212 @@ +/** + * Unit tests for C arity computation and compatibility. + */ + +import { describe, it, expect } from 'vitest'; +import { getCParser } from '../../../../src/core/ingestion/languages/c/query.js'; +import { + computeCDeclarationArity, + computeCCallArity, +} from '../../../../src/core/ingestion/languages/c/arity-metadata.js'; +import { cArityCompatibility } from '../../../../src/core/ingestion/languages/c/arity.js'; +import type { SyntaxNode } from '../../../../src/core/ingestion/utils/ast-helpers.js'; +import type { Callsite, SymbolDefinition } from 'gitnexus-shared'; + +function parseFunctionNode(src: string): SyntaxNode | null { + const tree = getCParser().parse(src); + for (let i = 0; i < tree.rootNode.namedChildCount; i++) { + const child = tree.rootNode.namedChild(i); + if (child?.type === 'function_definition' || child?.type === 'declaration') { + return child as SyntaxNode; + } + } + return null; +} + +function parseCallNode(src: string): SyntaxNode | null { + const tree = getCParser().parse(src); + // Walk deeper to find call_expression + function findCall(node: SyntaxNode): SyntaxNode | null { + if (node.type === 'call_expression') return node; + for (let i = 0; i < node.namedChildCount; i++) { + const found = findCall(node.namedChild(i) as SyntaxNode); + if (found !== null) return found; + } + return null; + } + return findCall(tree.rootNode as SyntaxNode); +} + +describe('computeCDeclarationArity', () => { + it('returns count for simple parameters', () => { + const node = parseFunctionNode('int add(int a, int b) { return a + b; }'); + expect(node).not.toBeNull(); + const arity = computeCDeclarationArity(node!); + expect(arity.parameterCount).toBe(2); + expect(arity.requiredParameterCount).toBe(2); + }); + + it('returns zero for (void) parameter list', () => { + const node = parseFunctionNode('void f(void) { }'); + expect(node).not.toBeNull(); + const arity = computeCDeclarationArity(node!); + expect(arity.parameterCount).toBe(0); + expect(arity.requiredParameterCount).toBe(0); + expect(arity.parameterTypes).toEqual([]); + }); + + it('handles variadic functions — parameterCount is undefined', () => { + const node = parseFunctionNode('int printf(const char *fmt, ...) { return 0; }'); + expect(node).not.toBeNull(); + const arity = computeCDeclarationArity(node!); + expect(arity.parameterCount).toBeUndefined(); + expect(arity.requiredParameterCount).toBe(1); + expect(arity.parameterTypes).toContain('...'); + }); + + it('extracts parameter types', () => { + const node = parseFunctionNode('void f(int a, float b, char *c) { }'); + expect(node).not.toBeNull(); + const arity = computeCDeclarationArity(node!); + expect(arity.parameterTypes).toEqual(['int', 'float', 'char']); + }); + + it('handles pointer-return function', () => { + const node = parseFunctionNode('int *create(int size) { return 0; }'); + expect(node).not.toBeNull(); + const arity = computeCDeclarationArity(node!); + expect(arity.parameterCount).toBe(1); + }); + + it('handles function prototype (no body)', () => { + const node = parseFunctionNode('int add(int a, int b);'); + expect(node).not.toBeNull(); + const arity = computeCDeclarationArity(node!); + expect(arity.parameterCount).toBe(2); + }); + + it('returns empty for non-function node', () => { + const node = parseFunctionNode('int x = 5;'); + // This might be a declaration node, but without function_declarator + if (node !== null) { + const arity = computeCDeclarationArity(node); + expect(arity.parameterCount).toBeUndefined(); + } + }); + + it('handles single parameter', () => { + const node = parseFunctionNode('void f(int x) { }'); + expect(node).not.toBeNull(); + const arity = computeCDeclarationArity(node!); + expect(arity.parameterCount).toBe(1); + expect(arity.requiredParameterCount).toBe(1); + }); + + it('returns unknown arity for K&R empty parameter list int foo()', () => { + const node = parseFunctionNode('int foo() { return 0; }'); + expect(node).not.toBeNull(); + const arity = computeCDeclarationArity(node!); + // K&R old-style: unspecified parameters, NOT zero parameters + expect(arity.parameterCount).toBeUndefined(); + expect(arity.requiredParameterCount).toBeUndefined(); + expect(arity.parameterTypes).toBeUndefined(); + }); + + it('distinguishes K&R int foo() from explicit int foo(void)', () => { + const knrNode = parseFunctionNode('int foo() { return 0; }'); + const voidNode = parseFunctionNode('int foo(void) { return 0; }'); + expect(knrNode).not.toBeNull(); + expect(voidNode).not.toBeNull(); + + const knrArity = computeCDeclarationArity(knrNode!); + const voidArity = computeCDeclarationArity(voidNode!); + + // K&R: unknown arity + expect(knrArity.parameterCount).toBeUndefined(); + // Explicit void: zero params + expect(voidArity.parameterCount).toBe(0); + expect(voidArity.requiredParameterCount).toBe(0); + }); + + it('returns unknown arity for K&R prototype int foo();', () => { + const node = parseFunctionNode('int foo();'); + expect(node).not.toBeNull(); + const arity = computeCDeclarationArity(node!); + expect(arity.parameterCount).toBeUndefined(); + expect(arity.requiredParameterCount).toBeUndefined(); + }); +}); + +describe('computeCCallArity', () => { + it('counts zero arguments', () => { + const node = parseCallNode('void f(void) { init(); }'); + expect(node).not.toBeNull(); + expect(computeCCallArity(node!)).toBe(0); + }); + + it('counts two arguments', () => { + const node = parseCallNode('void f(void) { add(1, 2); }'); + expect(node).not.toBeNull(); + expect(computeCCallArity(node!)).toBe(2); + }); + + it('counts three arguments', () => { + const node = parseCallNode('void f(void) { func(a, b, c); }'); + expect(node).not.toBeNull(); + expect(computeCCallArity(node!)).toBe(3); + }); + + it('counts string literal arguments', () => { + const node = parseCallNode('void f(void) { printf("hello %s", name); }'); + expect(node).not.toBeNull(); + expect(computeCCallArity(node!)).toBe(2); + }); +}); + +describe('cArityCompatibility', () => { + function makeDef(params: Partial): SymbolDefinition { + return { + nodeId: 'test', + filePath: 'test.c', + type: 'Function', + ...params, + }; + } + + function makeCallsite(arity: number): Callsite { + return { arity } as Callsite; + } + + it('returns compatible for exact match', () => { + const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 }); + expect(cArityCompatibility(def, makeCallsite(2))).toBe('compatible'); + }); + + it('returns incompatible for too few args', () => { + const def = makeDef({ parameterCount: 3, requiredParameterCount: 3 }); + expect(cArityCompatibility(def, makeCallsite(1))).toBe('incompatible'); + }); + + it('returns incompatible for too many args (non-variadic)', () => { + const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 }); + expect(cArityCompatibility(def, makeCallsite(5))).toBe('incompatible'); + }); + + it('returns compatible for variadic with enough args', () => { + const def = makeDef({ + requiredParameterCount: 1, + parameterTypes: ['const char *', '...'], + }); + expect(cArityCompatibility(def, makeCallsite(3))).toBe('compatible'); + }); + + it('returns unknown when no arity info on def', () => { + const def = makeDef({}); + expect(cArityCompatibility(def, makeCallsite(2))).toBe('unknown'); + }); + + it('returns unknown for negative callsite arity', () => { + const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 }); + expect(cArityCompatibility(def, makeCallsite(-1))).toBe('unknown'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/c/c-captures.test.ts b/gitnexus/test/unit/scope-resolution/c/c-captures.test.ts new file mode 100644 index 000000000..68c9ef22b --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/c/c-captures.test.ts @@ -0,0 +1,355 @@ +/** + * Unit tests for C scope query + captures orchestrator. + * + * Pins the capture-tag vocabulary + range shape for every construct + * the scope-resolution pipeline reads. Runs against tree-sitter-c + * so it catches grammar drift before the integration parity gate does. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { emitCScopeCaptures } from '../../../../src/core/ingestion/languages/c/captures.js'; +import { + clearStaticNames, + isStaticName, +} from '../../../../src/core/ingestion/languages/c/static-linkage.js'; + +function tagsFor(src: string, filePath = 'test.c'): string[][] { + const matches = emitCScopeCaptures(src, filePath); + return matches.map((m) => Object.keys(m).sort()); +} + +function findMatch(src: string, predicate: (tags: string[]) => boolean, filePath = 'test.c') { + const matches = emitCScopeCaptures(src, filePath); + return matches.find((m) => predicate(Object.keys(m))); +} + +function allMatches(src: string, predicate: (tags: string[]) => boolean, filePath = 'test.c') { + const matches = emitCScopeCaptures(src, filePath); + return matches.filter((m) => predicate(Object.keys(m))); +} + +describe('emitCScopeCaptures — scopes', () => { + it('captures translation_unit as @scope.module', () => { + const all = tagsFor('int x = 1;'); + expect(all.some((t) => t.includes('@scope.module'))).toBe(true); + }); + + it('captures struct_specifier as @scope.class', () => { + const all = tagsFor('struct Point { int x; int y; };'); + expect(all.some((t) => t.includes('@scope.class'))).toBe(true); + }); + + it('captures union_specifier as @scope.class', () => { + const all = tagsFor('union Data { int i; float f; };'); + expect(all.some((t) => t.includes('@scope.class'))).toBe(true); + }); + + it('captures function_definition as @scope.function', () => { + const all = tagsFor('void foo(void) { }'); + expect(all.some((t) => t.includes('@scope.function'))).toBe(true); + }); + + it('captures block-level scopes (if, for, while, do, switch, case)', () => { + const src = ` + void f(void) { + if (1) { } + for (;;) { } + while (1) { } + do { } while (0); + switch (0) { case 0: break; } + } + `; + const all = tagsFor(src); + const blocks = all.filter((t) => t.includes('@scope.block')); + // compound_statement + if + for + while + do + switch + case = at least 6 blocks + expect(blocks.length).toBeGreaterThanOrEqual(6); + }); +}); + +describe('emitCScopeCaptures — struct declarations', () => { + it('captures named struct with @declaration.struct', () => { + const m = findMatch('struct User { int age; };', (t) => t.includes('@declaration.struct')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('User'); + }); + + it('captures typedef struct with @declaration.struct (not typedef)', () => { + const m = findMatch('typedef struct { int age; } User;', (t) => + t.includes('@declaration.struct'), + ); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('User'); + }); + + it('suppresses @declaration.typedef when struct already captured same range', () => { + const matches = emitCScopeCaptures('typedef struct { int age; } User;', 'test.c'); + const typedefs = matches.filter((m) => '@declaration.typedef' in m); + expect(typedefs).toHaveLength(0); + }); +}); + +describe('emitCScopeCaptures — union declarations', () => { + it('captures named union with @declaration.union', () => { + const m = findMatch('union Data { int i; float f; };', (t) => t.includes('@declaration.union')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('Data'); + }); + + it('captures typedef union with @declaration.union', () => { + const m = findMatch('typedef union { int i; float f; } Value;', (t) => + t.includes('@declaration.union'), + ); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('Value'); + }); +}); + +describe('emitCScopeCaptures — enum declarations', () => { + it('captures enum with @declaration.enum', () => { + const m = findMatch('enum Color { RED, GREEN, BLUE };', (t) => t.includes('@declaration.enum')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('Color'); + }); + + it('captures enum constants as @declaration.const', () => { + const matches = allMatches('enum Color { RED, GREEN, BLUE };', (t) => + t.includes('@declaration.const'), + ); + const names = matches.map((m) => m['@declaration.name'].text); + expect(names).toContain('RED'); + expect(names).toContain('GREEN'); + expect(names).toContain('BLUE'); + }); +}); + +describe('emitCScopeCaptures — function declarations', () => { + it('captures function definition with @declaration.function', () => { + const m = findMatch('int add(int a, int b) { return a + b; }', (t) => + t.includes('@declaration.function'), + ); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('add'); + }); + + it('captures function prototype (declaration) with @declaration.function', () => { + const m = findMatch('int add(int a, int b);', (t) => t.includes('@declaration.function')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('add'); + }); + + it('captures pointer-return function definition', () => { + const m = findMatch('int *create(void) { return 0; }', (t) => + t.includes('@declaration.function'), + ); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('create'); + }); + + it('captures pointer-return function prototype', () => { + const m = findMatch('char *get_name(void);', (t) => t.includes('@declaration.function')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('get_name'); + }); +}); + +describe('emitCScopeCaptures — other declarations', () => { + it('captures typedef as @declaration.typedef', () => { + const m = findMatch('typedef int MyInt;', (t) => t.includes('@declaration.typedef')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('MyInt'); + }); + + it('captures function pointer typedef as @declaration.typedef', () => { + const m = findMatch('typedef void (*callback)(int, int);', (t) => + t.includes('@declaration.typedef'), + ); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('callback'); + }); + + it('captures struct field as @declaration.field', () => { + const m = findMatch('struct P { int x; };', (t) => t.includes('@declaration.field')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('x'); + }); + + it('captures pointer struct field as @declaration.field', () => { + const m = findMatch('struct N { struct N *next; };', (t) => t.includes('@declaration.field')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('next'); + }); + + it('captures variable with initializer as @declaration.variable', () => { + const m = findMatch('int x = 42;', (t) => t.includes('@declaration.variable')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('x'); + }); + + it('captures macro as @declaration.macro', () => { + const m = findMatch('#define MAX 100', (t) => t.includes('@declaration.macro')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('MAX'); + }); + + it('captures function-like macro as @declaration.macro', () => { + const m = findMatch('#define SQUARE(x) ((x) * (x))', (t) => t.includes('@declaration.macro')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('SQUARE'); + }); +}); + +describe('emitCScopeCaptures — imports', () => { + it('captures local #include as @import.statement with source', () => { + const m = findMatch('#include "header.h"', (t) => t.includes('@import.statement')); + expect(m).toBeDefined(); + expect(m!['@import.source'].text).toBe('header.h'); + expect(m!['@import.kind'].text).toBe('wildcard'); + }); + + it('captures system #include with @import.system tag', () => { + const m = findMatch('#include ', (t) => t.includes('@import.statement')); + expect(m).toBeDefined(); + expect(m!['@import.system']).toBeDefined(); + }); + + it('captures nested path includes', () => { + const m = findMatch('#include "utils/helpers.h"', (t) => t.includes('@import.statement')); + expect(m).toBeDefined(); + expect(m!['@import.source'].text).toBe('utils/helpers.h'); + }); +}); + +describe('emitCScopeCaptures — references', () => { + it('captures free call invocations', () => { + const m = findMatch('void f(void) { foo(); }', (t) => t.includes('@reference.call.free')); + expect(m).toBeDefined(); + expect(m!['@reference.name'].text).toBe('foo'); + }); + + it('captures member call via pointer (ptr->func())', () => { + const m = findMatch('void f(struct S *s) { s->method(); }', (t) => + t.includes('@reference.call.member'), + ); + expect(m).toBeDefined(); + expect(m!['@reference.name'].text).toBe('method'); + }); + + it('captures field reads', () => { + const m = findMatch('void f(struct S *s) { int x = s->field; }', (t) => + t.includes('@reference.read'), + ); + expect(m).toBeDefined(); + expect(m!['@reference.name'].text).toBe('field'); + }); + + it('captures field writes (assignment)', () => { + const m = findMatch('void f(struct S *s) { s->field = 1; }', (t) => + t.includes('@reference.write'), + ); + expect(m).toBeDefined(); + expect(m!['@reference.name'].text).toBe('field'); + }); +}); + +describe('emitCScopeCaptures — type bindings', () => { + it('captures parameter type annotations', () => { + const m = findMatch('void f(int x) { }', (t) => t.includes('@type-binding.parameter')); + expect(m).toBeDefined(); + expect(m!['@type-binding.name'].text).toBe('x'); + }); + + it('captures variable type bindings', () => { + const m = findMatch('void f(void) { int x = 1; }', (t) => + t.includes('@type-binding.assignment'), + ); + expect(m).toBeDefined(); + expect(m!['@type-binding.name'].text).toBe('x'); + }); +}); + +describe('emitCScopeCaptures — arity metadata', () => { + it('synthesizes parameter-count on function definitions', () => { + const m = findMatch('int add(int a, int b) { return a + b; }', (t) => + t.includes('@declaration.parameter-count'), + ); + expect(m).toBeDefined(); + expect(m!['@declaration.parameter-count'].text).toBe('2'); + }); + + it('synthesizes parameter-types on function definitions', () => { + const m = findMatch('int add(int a, float b) { return 0; }', (t) => + t.includes('@declaration.parameter-types'), + ); + expect(m).toBeDefined(); + const types = JSON.parse(m!['@declaration.parameter-types'].text); + expect(types).toEqual(['int', 'float']); + }); + + it('(void) parameter list yields zero parameters', () => { + const m = findMatch('void f(void) { }', (t) => t.includes('@declaration.function')); + expect(m).toBeDefined(); + expect(m!['@declaration.parameter-count'].text).toBe('0'); + expect(m!['@declaration.required-parameter-count'].text).toBe('0'); + }); + + it('variadic function has undefined parameter-count but defined required-parameter-count', () => { + const m = findMatch('int printf(const char *fmt, ...) { return 0; }', (t) => + t.includes('@declaration.function'), + ); + expect(m).toBeDefined(); + // variadic → parameterCount is undefined (not emitted) + expect(m!['@declaration.parameter-count']).toBeUndefined(); + expect(m!['@declaration.required-parameter-count'].text).toBe('1'); + }); + + it('synthesizes arity on call references', () => { + const m = findMatch( + 'void f(void) { add(1, 2); }', + (t) => t.includes('@reference.call.free') && t.includes('@reference.arity'), + ); + expect(m).toBeDefined(); + expect(m!['@reference.arity'].text).toBe('2'); + }); + + it('zero-argument call has arity 0', () => { + const m = findMatch( + 'void f(void) { init(); }', + (t) => t.includes('@reference.call.free') && t.includes('@reference.arity'), + ); + expect(m).toBeDefined(); + expect(m!['@reference.arity'].text).toBe('0'); + }); +}); + +describe('emitCScopeCaptures — static storage class', () => { + beforeEach(() => { + clearStaticNames(); + }); + + it('marks static function definitions as file-local', () => { + emitCScopeCaptures('static int helper(void) { return 0; }', 'a.c'); + expect(isStaticName('a.c', 'helper')).toBe(true); + }); + + it('does not mark non-static function definitions as file-local', () => { + emitCScopeCaptures('int helper(void) { return 0; }', 'a.c'); + expect(isStaticName('a.c', 'helper')).toBe(false); + }); + + it('marks static function prototypes as file-local', () => { + emitCScopeCaptures('static int helper(int x);', 'a.c'); + expect(isStaticName('a.c', 'helper')).toBe(true); + }); + + it('static functions are scoped to their file', () => { + emitCScopeCaptures('static int helper(void) { return 0; }', 'a.c'); + emitCScopeCaptures('int helper(void) { return 1; }', 'b.c'); + expect(isStaticName('a.c', 'helper')).toBe(true); + expect(isStaticName('b.c', 'helper')).toBe(false); + }); + + it('static pointer-return functions are detected', () => { + emitCScopeCaptures('static char *get_buffer(void) { return 0; }', 'a.c'); + expect(isStaticName('a.c', 'get_buffer')).toBe(true); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/c/c-header-scan.test.ts b/gitnexus/test/unit/scope-resolution/c/c-header-scan.test.ts new file mode 100644 index 000000000..1543db006 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/c/c-header-scan.test.ts @@ -0,0 +1,103 @@ +/** + * Unit tests for C header scanning — specifically the skip-list + * for build output directories. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { scanHeaderFiles } from '../../../../src/core/ingestion/languages/c/header-scan.js'; + +const TMP = join(__dirname, '__header_scan_tmp__'); + +function touch(rel: string): void { + const full = join(TMP, rel); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, ''); +} + +beforeEach(() => { + mkdirSync(TMP, { recursive: true }); +}); + +afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); +}); + +describe('scanHeaderFiles — build-directory skip list', () => { + it('finds .h files in source directories', () => { + touch('src/foo.h'); + touch('include/bar.h'); + const headers = scanHeaderFiles(TMP); + expect(headers).toContain('src/foo.h'); + expect(headers).toContain('include/bar.h'); + }); + + it('skips node_modules', () => { + touch('node_modules/dep/header.h'); + touch('src/real.h'); + const headers = scanHeaderFiles(TMP); + expect(headers).not.toContain('node_modules/dep/header.h'); + expect(headers).toContain('src/real.h'); + }); + + it('skips .git directory', () => { + touch('.git/refs/header.h'); + const headers = scanHeaderFiles(TMP); + expect(headers.size).toBe(0); + }); + + it('skips vendor directory', () => { + touch('vendor/lib/header.h'); + const headers = scanHeaderFiles(TMP); + expect(headers.size).toBe(0); + }); + + it('skips dist directory', () => { + touch('dist/generated.h'); + touch('src/real.h'); + const headers = scanHeaderFiles(TMP); + expect(headers).not.toContain('dist/generated.h'); + expect(headers).toContain('src/real.h'); + }); + + it('skips build directory', () => { + touch('build/config.h'); + const headers = scanHeaderFiles(TMP); + expect(headers).not.toContain('build/config.h'); + }); + + it('skips out directory', () => { + touch('out/gen/auto.h'); + const headers = scanHeaderFiles(TMP); + expect(headers.size).toBe(0); + }); + + it('skips target directory', () => { + touch('target/release/bindings.h'); + const headers = scanHeaderFiles(TMP); + expect(headers.size).toBe(0); + }); + + it('skips _build directory', () => { + touch('_build/default/lib.h'); + const headers = scanHeaderFiles(TMP); + expect(headers.size).toBe(0); + }); + + it('skips .next directory', () => { + touch('.next/cache/header.h'); + const headers = scanHeaderFiles(TMP); + expect(headers.size).toBe(0); + }); + + it('skips cmake-build-* directories', () => { + touch('cmake-build-debug/generated.h'); + touch('cmake-build-release/generated.h'); + touch('src/real.h'); + const headers = scanHeaderFiles(TMP); + expect(headers).not.toContain('cmake-build-debug/generated.h'); + expect(headers).not.toContain('cmake-build-release/generated.h'); + expect(headers).toContain('src/real.h'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/c/c-imports.test.ts b/gitnexus/test/unit/scope-resolution/c/c-imports.test.ts new file mode 100644 index 000000000..549e651bb --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/c/c-imports.test.ts @@ -0,0 +1,166 @@ +/** + * Unit tests for C import decomposition, interpretation, and target resolution. + */ + +import { describe, it, expect } from 'vitest'; +import { getCParser } from '../../../../src/core/ingestion/languages/c/query.js'; +import { splitCInclude } from '../../../../src/core/ingestion/languages/c/import-decomposer.js'; +import { interpretCImport } from '../../../../src/core/ingestion/languages/c/interpret.js'; +import { resolveCImportTarget } from '../../../../src/core/ingestion/languages/c/import-target.js'; +import type { SyntaxNode } from '../../../../src/core/ingestion/utils/ast-helpers.js'; + +function parseIncludeNode(src: string): SyntaxNode | null { + const tree = getCParser().parse(src); + for (let i = 0; i < tree.rootNode.namedChildCount; i++) { + const child = tree.rootNode.namedChild(i); + if (child?.type === 'preproc_include') return child as SyntaxNode; + } + return null; +} + +function capt(name: string, text: string) { + return { name, text, range: { startLine: 1, startCol: 1, endLine: 1, endCol: 1 } }; +} + +describe('C import decomposition (splitCInclude)', () => { + it('decomposes local include "#include \\"foo.h\\""', () => { + const node = parseIncludeNode('#include "foo.h"'); + expect(node).not.toBeNull(); + const match = splitCInclude(node!); + expect(match).not.toBeNull(); + expect(match!['@import.source'].text).toBe('foo.h'); + expect(match!['@import.kind'].text).toBe('wildcard'); + expect(match!['@import.system']).toBeUndefined(); + }); + + it('decomposes system include "#include "', () => { + const node = parseIncludeNode('#include '); + expect(node).not.toBeNull(); + const match = splitCInclude(node!); + expect(match).not.toBeNull(); + expect(match!['@import.source'].text).toBe('stdio.h'); + expect(match!['@import.system']).toBeDefined(); + }); + + it('decomposes nested path include', () => { + const node = parseIncludeNode('#include "utils/helpers.h"'); + expect(node).not.toBeNull(); + const match = splitCInclude(node!); + expect(match).not.toBeNull(); + expect(match!['@import.source'].text).toBe('utils/helpers.h'); + }); +}); + +describe('C import interpretation (interpretCImport)', () => { + it('interprets local include as wildcard import', () => { + const result = interpretCImport({ + '@import.kind': capt('@import.kind', 'wildcard'), + '@import.source': capt('@import.source', 'header.h'), + }); + expect(result).toEqual({ kind: 'wildcard', targetRaw: 'header.h' }); + }); + + it('returns null for system headers', () => { + const result = interpretCImport({ + '@import.kind': capt('@import.kind', 'wildcard'), + '@import.source': capt('@import.source', 'stdio.h'), + '@import.system': capt('@import.system', 'true'), + }); + expect(result).toBeNull(); + }); + + it('returns null when @import.source is missing', () => { + const result = interpretCImport({ + '@import.kind': capt('@import.kind', 'wildcard'), + }); + expect(result).toBeNull(); + }); +}); + +describe('C import target resolution (resolveCImportTarget)', () => { + it('resolves exact match', () => { + const result = resolveCImportTarget('foo.h', 'main.c', new Set(['foo.h', 'bar.h'])); + expect(result).toBe('foo.h'); + }); + + it('resolves suffix match to shortest path', () => { + const result = resolveCImportTarget( + 'foo.h', + 'main.c', + new Set(['src/include/foo.h', 'include/foo.h', 'other/bar.h']), + ); + expect(result).toBe('include/foo.h'); + }); + + it('resolves nested include path with directory components', () => { + const result = resolveCImportTarget( + 'utils/helpers.h', + 'main.c', + new Set(['src/utils/helpers.h', 'lib/utils/helpers.h']), + ); + // Both have same depth (3 components), so lexicographic tiebreak picks lib/ + expect(result).toBe('lib/utils/helpers.h'); + }); + + it('returns null for empty target', () => { + expect(resolveCImportTarget('', 'main.c', new Set(['foo.h']))).toBeNull(); + }); + + it('returns null when no match found', () => { + expect(resolveCImportTarget('missing.h', 'main.c', new Set(['foo.h']))).toBeNull(); + }); + + it('is deterministic on depth ties — lexicographic tiebreak', () => { + const files = new Set(['test/util/foo.h', 'src/util/foo.h']); + const result1 = resolveCImportTarget('foo.h', 'main.c', files); + const result2 = resolveCImportTarget('foo.h', 'main.c', files); + expect(result1).toBe(result2); + // Lexicographic: src/util/foo.h < test/util/foo.h + expect(result1).toBe('src/util/foo.h'); + }); + + it('prefers shallower path over lexicographic order', () => { + const result = resolveCImportTarget('foo.h', 'main.c', new Set(['a/b/c/foo.h', 'z/foo.h'])); + expect(result).toBe('z/foo.h'); + }); + + it('handles backslash paths (Windows)', () => { + const result = resolveCImportTarget('foo.h', 'main.c', new Set(['include\\foo.h'])); + expect(result).toBe('include\\foo.h'); + }); + + it('prefers same-directory sibling over deeper suffix match', () => { + // src/foo.c includes "bar.h" — src/bar.h should win over include/bar.h + const result = resolveCImportTarget( + 'bar.h', + 'src/foo.c', + new Set(['include/bar.h', 'src/bar.h']), + ); + expect(result).toBe('src/bar.h'); + }); + + it('prefers same-directory sibling over shallower suffix match', () => { + // deep/nested/main.c includes "foo.h" — deep/nested/foo.h wins over foo.h + const result = resolveCImportTarget( + 'foo.h', + 'deep/nested/main.c', + new Set(['foo.h', 'deep/nested/foo.h']), + ); + expect(result).toBe('deep/nested/foo.h'); + }); + + it('falls back to suffix match when no same-directory sibling exists', () => { + const result = resolveCImportTarget('missing.h', 'src/foo.c', new Set(['lib/missing.h'])); + expect(result).toBe('lib/missing.h'); + }); + + it('same-directory sibling with nested target path', () => { + // src/foo.c includes "sub/bar.h" — src/sub/bar.h should win + const result = resolveCImportTarget( + 'sub/bar.h', + 'src/foo.c', + new Set(['other/sub/bar.h', 'src/sub/bar.h']), + ); + expect(result).toBe('src/sub/bar.h'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/overload-narrowing.test.ts b/gitnexus/test/unit/scope-resolution/overload-narrowing.test.ts index 810c3a79b..9a14fdddd 100644 --- a/gitnexus/test/unit/scope-resolution/overload-narrowing.test.ts +++ b/gitnexus/test/unit/scope-resolution/overload-narrowing.test.ts @@ -69,11 +69,26 @@ describe('narrowOverloadCandidates — arity filtering', () => { expect(result.map((d) => d.nodeId)).toEqual(['v:1']); }); - it('falls back to the full overload list when arity filter empties it', () => { + it('returns empty when arity filter empties the set AND every candidate had definite bounds', () => { // argCount=5 doesn't match any overload (none variadic, all have max < 5). + // Post-commit af9af4a9 (PR #1497 / U1): the empty result is now authoritative + // because every rejected candidate had defined `parameterCount` / + // `requiredParameterCount`. The old "always fall back to full list" rescue + // was deliberately removed so resolvers actually drop calls that are + // definitively arity-incompatible (e.g., PHP `f(int $req, ...$rest)` + // called with zero args). const result = narrowOverloadCandidates([add1, add2, add3], 5, undefined); - expect(result.map((d) => d.nodeId)).toEqual(['add:1', 'add:2', 'add:3']); + expect(result.map((d) => d.nodeId)).toEqual([]); }); + + // Note: the `anyUnknownBounds ? overloads : []` branch in + // narrowOverloadCandidates is structurally unreachable in this caller's + // shape — a candidate with both `parameterCount` and `requiredParameterCount` + // undefined always passes the arity filter (neither `argCount > max` nor + // `argCount < min` can fire), so `arityMatches.length` is always > 0 + // whenever `anyUnknownBounds` is true. The branch is preserved in the + // source as a defensive guard for future refactors that might add + // additional rejection criteria in the filter. }); describe('narrowOverloadCandidates — type narrowing', () => { diff --git a/gitnexus/test/unit/scope-resolution/pick-implicit-this-overload.test.ts b/gitnexus/test/unit/scope-resolution/pick-implicit-this-overload.test.ts new file mode 100644 index 000000000..26f526382 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/pick-implicit-this-overload.test.ts @@ -0,0 +1,156 @@ +/** + * Unit tests for `pickImplicitThisOverload` — the implicit-`this` free-call + * resolver in `free-call-fallback.ts`. + * + * Codex PR #1497 review, finding 2: the previous implementation returned + * `candidates[0]` after `narrowOverloadCandidates` regardless of how many + * candidates survived narrowing. When two same-name methods on the same + * class had identical arity and unknown argument types, narrowing left both + * compatible and the resolver emitted a high-confidence CALLS edge whose + * target depended on registration order. The fix tightens the picker to + * require a UNIQUE post-narrowing candidate; otherwise the call is left + * unresolved. + * + * These tests exercise the function via synthetic stubs — no fixtures, no + * pipeline — because the failure shape (two same-arity overloads with + * indistinguishable types) cannot be produced by a PHP integration fixture + * (PHP forbids method overloading) and any C# fixture would entangle this + * unit's contract with the wider C# resolver. + */ + +import { describe, it, expect } from 'vitest'; +import type { Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import { pickImplicitThisOverload } from '../../../src/core/ingestion/scope-resolution/passes/free-call-fallback.js'; +import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js'; +import type { SemanticModel } from '../../../src/core/ingestion/model/semantic-model.js'; +import type { WorkspaceResolutionIndex } from '../../../src/core/ingestion/scope-resolution/workspace-index.js'; + +const CLASS_SCOPE_ID = 'scope:test.cs#1:1-100:1:Class' as ScopeId; +const CLASS_DEF_ID = 'def:test.cs:Foo'; + +const mkMethod = (overrides: Partial & { nodeId: string }): SymbolDefinition => ({ + nodeId: overrides.nodeId, + filePath: 'x.cs', + type: 'Method', + ...overrides, +}); + +const mkClassScope = (): Scope => + ({ + id: CLASS_SCOPE_ID, + parent: null, + kind: 'Class', + range: { startLine: 1, startCol: 1, endLine: 100, endCol: 1 }, + filePath: 'test.cs', + bindings: new Map(), + typeBindings: new Map(), + ownedDefs: [], + }) as unknown as Scope; + +const mkScopes = (scope: Scope): ScopeResolutionIndexes => + ({ + scopeTree: { + getScope: (id: ScopeId) => (id === scope.id ? scope : undefined), + }, + }) as unknown as ScopeResolutionIndexes; + +const mkWorkspaceIndex = (mapping: ReadonlyMap): WorkspaceResolutionIndex => + ({ + classScopeIdToDefId: mapping, + }) as unknown as WorkspaceResolutionIndex; + +const mkModel = ( + overloadsByName: ReadonlyMap, +): SemanticModel => + ({ + methods: { + lookupAllByOwner: (_classDefId: string, name: string) => + overloadsByName.get(name) ?? ([] as readonly SymbolDefinition[]), + }, + }) as unknown as SemanticModel; + +describe('pickImplicitThisOverload — uniqueness guard (Codex #1497 finding 2)', () => { + const site = { + inScope: CLASS_SCOPE_ID, + name: 'save', + arity: 1, + argumentTypes: undefined, + }; + + it('returns the sole overload when only one method exists on the owner', () => { + const sole = mkMethod({ nodeId: 'm:1', parameterCount: 1, requiredParameterCount: 1 }); + const scopes = mkScopes(mkClassScope()); + const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]])); + const model = mkModel(new Map([['save', [sole]]])); + + const result = pickImplicitThisOverload(site, scopes, workspace, model); + + expect(result?.nodeId).toBe('m:1'); + }); + + it('returns the single survivor when narrowing disambiguates by arity', () => { + const save1 = mkMethod({ nodeId: 'm:1', parameterCount: 1, requiredParameterCount: 1 }); + const save2 = mkMethod({ nodeId: 'm:2', parameterCount: 2, requiredParameterCount: 2 }); + const scopes = mkScopes(mkClassScope()); + const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]])); + const model = mkModel(new Map([['save', [save1, save2]]])); + + // site.arity = 1 → only save1 survives narrowing. + const result = pickImplicitThisOverload(site, scopes, workspace, model); + + expect(result?.nodeId).toBe('m:1'); + }); + + it('returns undefined when narrowing leaves two compatible candidates (the bug)', () => { + // Two same-arity, same-required-count overloads with no disambiguating + // parameter-type info on either def. `narrowOverloadCandidates` keeps + // both; pre-fix code returned `candidates[0]` (registration order); + // post-fix code returns undefined. + const save1 = mkMethod({ nodeId: 'm:1', parameterCount: 1, requiredParameterCount: 1 }); + const save2 = mkMethod({ nodeId: 'm:2', parameterCount: 1, requiredParameterCount: 1 }); + const scopes = mkScopes(mkClassScope()); + const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]])); + const model = mkModel(new Map([['save', [save1, save2]]])); + + const result = pickImplicitThisOverload(site, scopes, workspace, model); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when no method on the owner matches the call name', () => { + const scopes = mkScopes(mkClassScope()); + const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]])); + const model = mkModel(new Map()); + + const result = pickImplicitThisOverload(site, scopes, workspace, model); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when the call site is not inside a Class scope', () => { + // Module-scope sites: no enclosing class, so the implicit-this picker + // has nothing to pick from. Different from an empty-narrowing miss. + const moduleScope = { + id: 'scope:test.cs#1:1-100:1:Module' as ScopeId, + parent: null, + kind: 'Module', + range: { startLine: 1, startCol: 1, endLine: 100, endCol: 1 }, + filePath: 'test.cs', + bindings: new Map(), + typeBindings: new Map(), + ownedDefs: [], + } as unknown as Scope; + const scopes = mkScopes(moduleScope); + const workspace = mkWorkspaceIndex(new Map()); + const model = mkModel(new Map()); + + const result = pickImplicitThisOverload( + { ...site, inScope: moduleScope.id }, + scopes, + workspace, + model, + ); + + expect(result).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/registries.test.ts b/gitnexus/test/unit/scope-resolution/registries.test.ts index b204287b5..2681af69f 100644 --- a/gitnexus/test/unit/scope-resolution/registries.test.ts +++ b/gitnexus/test/unit/scope-resolution/registries.test.ts @@ -250,7 +250,13 @@ describe('Step 5: arity filter', () => { ); }); - it('keeps incompatible candidates when no compatible candidate exists (soft penalty)', () => { + it('drops every candidate when ALL are incompatible AND none unknown (hard rejection)', () => { + // Post-commit af9af4a9 (PR #1497 / U1): the old soft-penalty fallback + // that kept incompatible candidates with `arityMatchIncompatible` + // weight was deliberately removed at this layer too. When every + // candidate is definitively arity-incompatible, the registry returns + // no resolution — matching the PHP variadic case `f(int $req, ...$rest)` + // called with zero args. const save3 = mkDef({ nodeId: 'def:save-three', type: 'Method', @@ -268,8 +274,41 @@ describe('Step 5: arity filter', () => { const results = buildMethodRegistry(ctx).lookup('save', 'scope:m', { callsite: { arity: 1 }, }); - expect(results).toHaveLength(1); - expect(evidenceOfKind(results[0]!, 'arity-match')?.weight).toBe( + expect(results).toHaveLength(0); + }); + + it('keeps incompatible candidates when at least one verdict is unknown (soft penalty)', () => { + // The soft-rescue path is still active when at least one candidate's + // arity verdict is 'unknown' — that signals missing metadata rather + // than a definitive mismatch, so all candidates (including incompatible + // ones) are preserved with their evidence weights for downstream + // tie-breaking. + const save3 = mkDef({ + nodeId: 'def:save-three', + type: 'Method', + qualifiedName: 'User.save', + parameterCount: 3, + }); + const saveUnknown = mkDef({ + nodeId: 'def:save-unknown', + type: 'Method', + qualifiedName: 'User.save', + }); + const mod = mkScope({ + id: 'scope:m', + parent: null, + bindings: { save: [mkBinding(save3, 'local'), mkBinding(saveUnknown, 'local')] }, + }); + const ctx = makeCtx([mod], [save3, saveUnknown], { + arity: (_callsite, def) => (def.nodeId === 'def:save-unknown' ? 'unknown' : 'incompatible'), + }); + const results = buildMethodRegistry(ctx).lookup('save', 'scope:m', { + callsite: { arity: 1 }, + }); + expect(results).toHaveLength(2); + const incompat = results.find((r) => r.def.nodeId === 'def:save-three'); + expect(incompat).toBeDefined(); + expect(evidenceOfKind(incompat!, 'arity-match')?.weight).toBe( EvidenceWeights.arityMatchIncompatible, ); }); diff --git a/gitnexus/test/unit/skip-git-cli.test.ts b/gitnexus/test/unit/skip-git-cli.test.ts index 069a9686f..80c07ed17 100644 --- a/gitnexus/test/unit/skip-git-cli.test.ts +++ b/gitnexus/test/unit/skip-git-cli.test.ts @@ -17,9 +17,41 @@ describe('--skip-git CLI flag', () => { expect(helpOutput).toContain('--skip-git'); expect(helpOutput).toContain('--skip-agents-md'); + expect(helpOutput).toContain('--skip-skills'); + expect(helpOutput).toContain('--index-only'); expect(helpOutput).not.toContain('--no-git'); }); + it('warns when --index-only overrides --skills (PR 1485)', () => { + // `--index-only` suppresses the post-index skill step that `--skills` + // would otherwise trigger. Without an explicit warning, the user sees a + // pipeline re-index complete and silently no skill files written — the + // silent-contradiction case flagged in PR 1485 review. + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-index-only-skills-')); + const gitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-index-only-skills-home-')); + // Make tmpDir a git repo so analyze accepts it without --skip-git. + execSync('git init', { cwd: tmpDir, stdio: 'ignore' }); + fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'export const a = 1;\n'); + + const env = { + ...process.env, + HOME: gitnexusHome, + GITNEXUS_HOME: gitnexusHome, + GITNEXUS_LBUG_EXTENSION_INSTALL: 'never', + }; + + try { + const output = execSync( + `node "${cliPath}" analyze "${tmpDir}" --index-only --skills --skip-agents-md`, + { encoding: 'utf8', timeout: 60000, env }, + ); + expect(output).toContain('--index-only overrides --skills'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + fs.rmSync(gitnexusHome, { recursive: true, force: true }); + } + }); + it('rejects non-git folder without --skip-git', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-no-git-')); fs.writeFileSync(path.join(tmpDir, 'test.ts'), 'export const x = 1;'); diff --git a/gitnexus/test/unit/u8-redos-resource-exhaustion.test.ts b/gitnexus/test/unit/u8-redos-resource-exhaustion.test.ts deleted file mode 100644 index 6dd6d9955..000000000 --- a/gitnexus/test/unit/u8-redos-resource-exhaustion.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Regression tests for U8 — closes: - * #186 js/redos rust-workspace-extractor.ts - * #187 js/redos cobol-preprocessor.ts - * #184 js/resource-exhaustion cross-impact.ts - * - * These tests import the production symbols directly. A previous shape - * dynamic-imported names that did not exist (`extractRustWorkspace` vs. - * the real `extractRustWorkspaceLinks`) and `??`-fell-back to inline - * regex copies, so the tests stayed green even when the production - * fixes regressed. Static imports + named symbols make a regression in - * any of the three sites a hard test failure. - */ -import { describe, expect, it } from 'vitest'; -import { RE_SET_TO_TRUE, RE_SET_INDEX } from '../../src/core/ingestion/cobol/cobol-preprocessor.js'; -import { parseCargoPackageName } from '../../src/core/group/extractors/rust-workspace-extractor.js'; -import { - clampTimeout, - IMPACT_TIMEOUT_MIN_MS, - IMPACT_TIMEOUT_MAX_MS, -} from '../../src/core/group/cross-impact.js'; - -/** - * Time a single regex.exec call. Used by the linearity tests below to - * compute a 10k/5k ratio in addition to the absolute <500ms bound. - * - * Ratio assertions catch sub-exponential O(n²) regressions that fit - * inside the absolute cap on warm CI; the absolute cap catches - * catastrophic backtracking on cold CI. Two complementary signals. - */ -function timeRegex(re: RegExp, input: string): number { - // Reset regex.lastIndex for global/sticky regexes — ours are not, but - // be defensive in case future shape changes add the `g` flag. - re.lastIndex = 0; - const start = performance.now(); - re.exec(input); - return performance.now() - start; -} - -function timeFn(fn: () => T): number { - const start = performance.now(); - fn(); - return performance.now() - start; -} - -// Linear scaling is ~2.0× when input doubles; 3.0× allows generous -// slack for CI-runner GC and tier-up jitter. An O(n²) regression on a -// 2× input takes ~4× as long, well outside this bound. -const LINEAR_RATIO_BOUND = 3.0; - -/** - * Minimum elapsed time (in ms) below which `performance.now()` ratios - * are dominated by scheduler jitter and become meaningless. When both - * timed runs come in below this floor, we skip the ratio assertion — - * the absolute <500ms bound still catches catastrophic backtracking, - * and the next CI run will measure higher absolute times that the - * ratio assertion can evaluate reliably. - * - * Calibrated empirically: a flake on macOS reported ratio 5.29× - * between two sub-millisecond measurements (~0.5ms vs ~2.6ms), both - * genuinely linear but indistinguishable from noise. 5ms is a - * comfortable floor where individual measurements are well-separated - * from the ~10-100µs `performance.now()` resolution band. - */ -const RATIO_MEASUREMENT_FLOOR_MS = 5; - -/** - * Assert linear scaling between two timed runs on inputs that differ - * by 2×. When measurements are too small to be reliable, the ratio - * assertion is skipped (the absolute bound still fires elsewhere). - */ -function assertSubLinearRatio(elapsedSmall: number, elapsedLarge: number, label: string): void { - if (elapsedSmall < RATIO_MEASUREMENT_FLOOR_MS && elapsedLarge < RATIO_MEASUREMENT_FLOOR_MS) { - // Both runs completed faster than the noise floor — the ratio is - // not meaningful. The absolute <500ms bound elsewhere in this - // describe block still pins linearity; we skip rather than risk a - // flake on a genuinely-linear implementation. - return; - } - const ratio = elapsedLarge / Math.max(elapsedSmall, 0.001); - if (ratio >= LINEAR_RATIO_BOUND) { - throw new Error( - `${label}: ratio ${ratio.toFixed(2)}× exceeds bound ${LINEAR_RATIO_BOUND}× ` + - `(small=${elapsedSmall.toFixed(2)}ms, large=${elapsedLarge.toFixed(2)}ms)`, - ); - } -} - -describe('cobol-preprocessor RE_SET_TO_TRUE — linear time on pathological input', () => { - it('matches in <500ms on 50k repetitions of "A OF A " AND 100k/50k ratio is sub-linear when measurable', () => { - // 50k/100k repetitions chosen so timings exceed the - // RATIO_MEASUREMENT_FLOOR_MS noise floor on typical CI hardware. - // Pre-fix nested-quantifier shape would be exponential here; the - // post-fix `.+?` shape is linear (~2× when input doubles). - const inputSmall = 'SET ' + 'A OF A '.repeat(50_000) + 'TO TRUE'; - const inputLarge = 'SET ' + 'A OF A '.repeat(100_000) + 'TO TRUE'; - const elapsedSmall = timeRegex(RE_SET_TO_TRUE, inputSmall); - const elapsedLarge = timeRegex(RE_SET_TO_TRUE, inputLarge); - expect(RE_SET_TO_TRUE.exec(inputSmall)).not.toBeNull(); - expect(elapsedSmall).toBeLessThan(500); - expect(elapsedLarge).toBeLessThan(500); - assertSubLinearRatio(elapsedSmall, elapsedLarge, 'RE_SET_TO_TRUE'); - }); - - it('still matches a normal SET ... TO TRUE statement', () => { - const m = RE_SET_TO_TRUE.exec('SET WS-FLAG TO TRUE'); - expect(m).not.toBeNull(); - expect(m?.[1]).toBe('WS-FLAG'); - }); -}); - -describe('cobol-preprocessor RE_SET_INDEX — linear time on pathological input', () => { - it('rejects in <500ms on 50k tokens with no valid suffix AND 100k/50k ratio is sub-linear when measurable', () => { - // Forces backtracking against the (TO|UP\s+BY|DOWN\s+BY) alternation - // — the richer pathological surface of the two regexes. - const inputSmall = 'SET ' + 'A '.repeat(50_000) + 'X'; - const inputLarge = 'SET ' + 'A '.repeat(100_000) + 'X'; - const elapsedSmall = timeRegex(RE_SET_INDEX, inputSmall); - const elapsedLarge = timeRegex(RE_SET_INDEX, inputLarge); - expect(RE_SET_INDEX.exec(inputSmall)).toBeNull(); - expect(elapsedSmall).toBeLessThan(500); - expect(elapsedLarge).toBeLessThan(500); - assertSubLinearRatio(elapsedSmall, elapsedLarge, 'RE_SET_INDEX'); - }); - - it('still matches a normal SET INDEX statement', () => { - const m = RE_SET_INDEX.exec('SET WS-IDX TO 5'); - expect(m).not.toBeNull(); - expect(m?.[1]).toBe('WS-IDX'); - expect(m?.[2]).toBe('TO'); - expect(m?.[3]).toBe('5'); - }); -}); - -describe('rust-workspace parseCargoPackageName — linear-time line walk', () => { - it('extracts the package name in <500ms on 100k blank lines AND 200k/100k ratio is sub-linear when measurable', () => { - // 100k/200k blank lines chosen so timings exceed the - // RATIO_MEASUREMENT_FLOOR_MS noise floor. Earlier 10k/20k pairing - // produced sub-millisecond measurements where scheduler jitter - // dominated and the ratio became meaningless (a real macOS run - // saw 5.29× between two genuinely-linear sub-ms measurements). - const cargoTomlSmall = - '[package]\n' + '\n'.repeat(100_000) + 'name = "myrepo"\nversion = "0.1.0"\n'; - const cargoTomlLarge = - '[package]\n' + '\n'.repeat(200_000) + 'name = "myrepo"\nversion = "0.1.0"\n'; - const elapsedSmall = timeFn(() => parseCargoPackageName(cargoTomlSmall)); - const elapsedLarge = timeFn(() => parseCargoPackageName(cargoTomlLarge)); - expect(parseCargoPackageName(cargoTomlSmall)).toBe('myrepo'); - expect(elapsedSmall).toBeLessThan(500); - expect(elapsedLarge).toBeLessThan(500); - assertSubLinearRatio(elapsedSmall, elapsedLarge, 'parseCargoPackageName'); - }); - - it('returns null when [package] section is absent', () => { - expect(parseCargoPackageName('[workspace]\nmembers = ["a"]\n')).toBeNull(); - }); - - it('stops at the next section header (does not pick up a name= from a later section)', () => { - const toml = '[package]\nversion = "1.0"\n[other]\nname = "wrong"\n'; - expect(parseCargoPackageName(toml)).toBeNull(); - }); - - it('extracts the name from a normal [package] section', () => { - const toml = '[package]\nname = "real-crate"\nversion = "0.1.0"\n'; - expect(parseCargoPackageName(toml)).toBe('real-crate'); - }); -}); - -describe('cross-impact clampTimeout — bounds user-supplied impact timeouts', () => { - it('rejects negative and zero timeouts, returning MIN', () => { - expect(clampTimeout(0)).toBe(IMPACT_TIMEOUT_MIN_MS); - expect(clampTimeout(-1)).toBe(IMPACT_TIMEOUT_MIN_MS); - expect(clampTimeout(-999_999)).toBe(IMPACT_TIMEOUT_MIN_MS); - }); - - it('rejects NaN/Infinity, returning MIN', () => { - expect(clampTimeout(NaN)).toBe(IMPACT_TIMEOUT_MIN_MS); - expect(clampTimeout(Infinity)).toBe(IMPACT_TIMEOUT_MIN_MS); - expect(clampTimeout(-Infinity)).toBe(IMPACT_TIMEOUT_MIN_MS); - }); - - it('caps very large timeouts at MAX (5 minutes)', () => { - expect(clampTimeout(999_999_999)).toBe(IMPACT_TIMEOUT_MAX_MS); - expect(clampTimeout(IMPACT_TIMEOUT_MAX_MS + 1)).toBe(IMPACT_TIMEOUT_MAX_MS); - }); - - it('passes through a reasonable timeout unchanged (truncated to integer)', () => { - expect(clampTimeout(30_000)).toBe(30_000); - expect(clampTimeout(30_500.7)).toBe(30_500); - }); - - it('floors below-MIN positive values to MIN', () => { - expect(clampTimeout(50)).toBe(IMPACT_TIMEOUT_MIN_MS); - expect(clampTimeout(0.1)).toBe(IMPACT_TIMEOUT_MIN_MS); - }); -}); diff --git a/gitnexus/test/unit/wiki-mermaid-sanitizer.test.ts b/gitnexus/test/unit/wiki-mermaid-sanitizer.test.ts new file mode 100644 index 000000000..e8c74c8cb --- /dev/null +++ b/gitnexus/test/unit/wiki-mermaid-sanitizer.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; + +import { + sanitizeMermaidDiagram, + sanitizeMermaidMarkdown, +} from '../../src/core/wiki/mermaid-sanitizer.js'; + +describe('sanitizeMermaidMarkdown', () => { + it('replaces literal newline escapes inside rectangle and diamond labels', () => { + const markdown = [ + '```mermaid', + 'flowchart TD', + ' A[HTTP request\\nwith ID param] --> B{Preceding tei:zone\\nwith @start=#pid?}', + '```', + ].join('\n'); + + const sanitized = sanitizeMermaidMarkdown(markdown); + + expect(sanitized).toContain('A[HTTP request
with ID param]'); + expect(sanitized).toContain('B{Preceding tei:zone
with @start=#pid?}'); + expect(sanitized).not.toContain('\\n'); + }); + + it('quotes unsafe edge labels without changing safe labels', () => { + const diagram = [ + 'graph LR', + ' Script -->|doc()| eXist[(eXist-db XML)]', + ' Client -->|HTTP params| Script', + ].join('\n'); + + const sanitized = sanitizeMermaidDiagram(diagram); + + expect(sanitized).toContain('Script -->|"doc()"| eXist[(eXist-db XML)]'); + expect(sanitized).toContain('Client -->|HTTP params| Script'); + }); + + it('escapes backslashes and quotes in quoted edge labels', () => { + const diagram = ['graph LR', ' Script -->|doc("C:\\\\tmp")| Target'].join('\n'); + + const sanitized = sanitizeMermaidDiagram(diagram); + + expect(sanitized).toContain('Script -->|"doc(\\"C:\\\\\\\\tmp\\")"| Target'); + }); + + it('aliases bare node IDs that contain dots and keeps display labels', () => { + const diagram = [ + 'graph LR', + ' Client -->|xmlurl + xslurl| xslt-conversion.xq', + ' xslt-conversion.xq -->|stream-transform| lbpwebjs-main.xsl', + ' lbpwebjs-main.xsl -->|fetches| TEI-XML[(TEI XML in eXist)]', + ].join('\n'); + + const sanitized = sanitizeMermaidDiagram(diagram); + + expect(sanitized).toContain( + 'Client -->|xmlurl + xslurl| xslt-conversion_xq["xslt-conversion.xq"]', + ); + expect(sanitized).toContain( + 'xslt-conversion_xq["xslt-conversion.xq"] -->|stream-transform| lbpwebjs-main_xsl["lbpwebjs-main.xsl"]', + ); + expect(sanitized).toContain( + 'lbpwebjs-main_xsl["lbpwebjs-main.xsl"] -->|fetches| TEI-XML[(TEI XML in eXist)]', + ); + }); + + it('aliases unsafe node IDs while preserving existing inline labels', () => { + const diagram = [ + 'graph LR', + ' file.name.ts[(eXist-db XML)] --> target.node["Target node"]', + ].join('\n'); + + const sanitized = sanitizeMermaidDiagram(diagram); + + expect(sanitized).toContain('file_name_ts[(eXist-db XML)] --> target_node["Target node"]'); + }); + + it('only rewrites fenced Mermaid blocks in markdown', () => { + const markdown = [ + 'Regular text with doc() and file.name.ts.', + '', + '```ts', + 'const label = "A\\nB";', + '```', + '', + '```mermaid', + 'flowchart LR', + ' A -->|doc()| file.name.ts', + '```', + ].join('\n'); + + const sanitized = sanitizeMermaidMarkdown(markdown); + + expect(sanitized).toContain('Regular text with doc() and file.name.ts.'); + expect(sanitized).toContain('const label = "A\\nB";'); + expect(sanitized).toContain('A -->|"doc()"| file_name_ts["file.name.ts"]'); + }); +});