Compare commits
30
Commits
v1.6.7
...
v1.6.8-rc.20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b63d0c959 | ||
|
|
28f3b99822 | ||
|
|
bdb824cfe4 | ||
|
|
10d1e47df3 | ||
|
|
bde340a5b4 | ||
|
|
1150eea98f | ||
|
|
ae1ec82f5f | ||
|
|
5a590f052f | ||
|
|
df56d34f33 | ||
|
|
04aeb1f4bf | ||
|
|
792cd96d37 | ||
|
|
351edca0ca | ||
|
|
15e141f21b | ||
|
|
0b2c88518e | ||
|
|
8bb2fe9128 | ||
|
|
6424d8b09c | ||
|
|
5bf8a17cd5 | ||
|
|
e26002c37a | ||
|
|
31a2b19416 | ||
|
|
2870aa6248 | ||
|
|
3d30b94c46 | ||
|
|
7f0ab87782 | ||
|
|
ae5ec94fd9 | ||
|
|
7eaeb0a0c4 | ||
|
|
36ca096e75 | ||
|
|
292f26ece3 | ||
|
|
4f9d595c73 | ||
|
|
e46651e42c | ||
|
|
43e6a3f688 | ||
|
|
581943bbe5 |
@@ -36,6 +36,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
|
||||
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
|
||||
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
|
||||
| `detect_changes` | Git-diff impact — what do your current changes affect |
|
||||
| `check` | Check graph invariants such as circular imports |
|
||||
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
|
||||
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
|
||||
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
|
||||
|
||||
+16
-67
@@ -22,26 +22,10 @@
|
||||
# --format '{{json .Manifest.Digest}}'
|
||||
FROM mcr.microsoft.com/devcontainers/typescript-node@sha256:7c2e711a4f7b02f32d2da16192d5e05aa7c95279be4ce889cff5df316f251c1d
|
||||
|
||||
# Build args. We deliberately set no version defaults here. devcontainer.json
|
||||
# `build.args` is the single source of truth for versions. A standalone
|
||||
# `docker build .devcontainer/` (for example, a CI smoke test) must pass each
|
||||
# version with --build-arg. Without a default, the build fails loudly instead of
|
||||
# silently drifting from the version pinned in devcontainer.json.
|
||||
ARG CLAUDE_CODE_VERSION
|
||||
ARG CODEX_VERSION
|
||||
# Cursor is pinned by version plus a per-arch tarball sha256 hash. The install
|
||||
# step below verifies that hash. All three values live in devcontainer.json
|
||||
# build.args. They follow the same rule as the others: one source of truth, and
|
||||
# no default so the build fails loudly if a value is missing.
|
||||
ARG CURSOR_VERSION
|
||||
ARG CURSOR_SHA256_X64
|
||||
ARG CURSOR_SHA256_ARM64
|
||||
# Bun is installed via the official remote script (bun.sh/install), pinned by
|
||||
# version. UNLIKE Cursor and the npm packages, this install path runs an
|
||||
# UNVERIFIED remote script — there is no tarball-hash check. Chosen explicitly
|
||||
# at request time over the pin-by-sha256 alternative for install-script
|
||||
# simplicity. To harden later, switch to a pinned tarball + per-arch sha256 in
|
||||
# the Cursor style (release artifacts at github.com/oven-sh/bun/releases).
|
||||
# version. Claude Code and Cursor also use official install scripts (no version
|
||||
# to pin). To harden Bun: switch to a pinned tarball + per-arch sha256
|
||||
# (release artifacts at github.com/oven-sh/bun/releases).
|
||||
ARG BUN_VERSION
|
||||
ARG TZ=UTC
|
||||
ARG USERNAME=node
|
||||
@@ -50,10 +34,7 @@ ARG USERNAME=node
|
||||
# read them. We deliberately do not set CLAUDE_CONFIG_DIR here. Its one true
|
||||
# value lives in devcontainer.json `containerEnv`, and the runtime value wins
|
||||
# anyway.
|
||||
ENV CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION} \
|
||||
CODEX_VERSION=${CODEX_VERSION} \
|
||||
CURSOR_VERSION=${CURSOR_VERSION} \
|
||||
BUN_VERSION=${BUN_VERSION} \
|
||||
ENV BUN_VERSION=${BUN_VERSION} \
|
||||
BUN_INSTALL=/home/${USERNAME}/.bun \
|
||||
TZ=${TZ} \
|
||||
DEVCONTAINER=true \
|
||||
@@ -86,51 +67,19 @@ RUN mkdir -p \
|
||||
|
||||
USER ${USERNAME}
|
||||
|
||||
# Install Claude Code and the Codex CLI globally, as the `node` user. The base
|
||||
# image sets /usr/local/share/npm-global as the npm-global prefix and makes the
|
||||
# `npm` group writable by `node`. So `npm install -g` works without sudo. Both
|
||||
# versions come from build args. To upgrade, bump them in devcontainer.json and
|
||||
# rebuild.
|
||||
RUN npm install -g \
|
||||
@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \
|
||||
@openai/codex@${CODEX_VERSION}
|
||||
# Install Claude Code via the official native installer. Downloads the latest
|
||||
# self-contained binary for the running platform and places it at
|
||||
# ~/.local/bin/claude — no Node.js runtime dependency, no version to pin.
|
||||
RUN curl -fsSL https://claude.ai/install.sh | bash
|
||||
|
||||
# Install the Cursor CLI. It is pinned and hash-verified, and we run no remote
|
||||
# script. The cursor.com/install script just detects os/arch, downloads a
|
||||
# versioned tarball from
|
||||
# downloads.cursor.com/lab/<version>/<os>/<arch>/agent-cli-package.tar.gz,
|
||||
# extracts it, and symlinks `agent`/`cursor-agent` into ~/.local/bin. We do that
|
||||
# ourselves against a PINNED version plus a per-arch sha256 hash. So the build
|
||||
# runs no unverified remote code. This matches how we pin the base image and npm
|
||||
# packages by digest (issue #1451). The download is fail-closed: if the hash
|
||||
# does not match, the build aborts.
|
||||
#
|
||||
# To bump: set CURSOR_VERSION and both CURSOR_SHA256_* in devcontainer.json
|
||||
# build.args. Get each arch's hash with:
|
||||
# curl -fSL https://downloads.cursor.com/lab/<ver>/linux/<x64|arm64>/agent-cli-package.tar.gz | sha256sum
|
||||
#
|
||||
# TARGETARCH is the per-platform build arg that BuildKit sets automatically. It
|
||||
# must be (re)declared in this stage to be visible. When the build is a
|
||||
# non-BuildKit `docker build`, TARGETARCH is unset, so we fall back to `dpkg
|
||||
# --print-architecture`.
|
||||
ARG TARGETARCH
|
||||
RUN set -eux; \
|
||||
arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \
|
||||
case "$arch" in \
|
||||
amd64) cursor_arch=x64; cursor_sha="${CURSOR_SHA256_X64}";; \
|
||||
arm64) cursor_arch=arm64; cursor_sha="${CURSOR_SHA256_ARM64}";; \
|
||||
*) echo "unsupported architecture for Cursor: $arch" >&2; exit 1;; \
|
||||
esac; \
|
||||
url="https://downloads.cursor.com/lab/${CURSOR_VERSION}/linux/${cursor_arch}/agent-cli-package.tar.gz"; \
|
||||
curl -fSL --retry 3 --max-time 120 -o /tmp/cursor.tgz "$url"; \
|
||||
echo "${cursor_sha} /tmp/cursor.tgz" | sha256sum -c -; \
|
||||
dir="/home/${USERNAME}/.local/share/cursor-agent/versions/${CURSOR_VERSION}"; \
|
||||
install -d "$dir" "/home/${USERNAME}/.local/bin"; \
|
||||
tar --strip-components=1 -xzf /tmp/cursor.tgz -C "$dir"; \
|
||||
test -x "$dir/cursor-agent"; \
|
||||
ln -sf "$dir/cursor-agent" "/home/${USERNAME}/.local/bin/agent"; \
|
||||
ln -sf "$dir/cursor-agent" "/home/${USERNAME}/.local/bin/cursor-agent"; \
|
||||
rm -f /tmp/cursor.tgz
|
||||
# Install the Codex CLI globally via npm. No version pinned — @latest at build
|
||||
# time. (Codex has no native binary installer; npm is the canonical method.)
|
||||
RUN npm install -g @openai/codex
|
||||
|
||||
# Install the Cursor agent CLI via the official install script. Downloads the
|
||||
# latest agent-cli-package for the running platform and places `cursor-agent`
|
||||
# and `agent` into ~/.local/bin — no version or hash to pin.
|
||||
RUN curl -fsSL https://cursor.com/install | bash
|
||||
|
||||
# Install Bun via the official remote installer, pinned by version. The first
|
||||
# positional arg to `bash` is the release tag (`bun-vX.Y.Z`), so a specific
|
||||
|
||||
@@ -14,16 +14,6 @@
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": ".",
|
||||
"args": {
|
||||
"CLAUDE_CODE_VERSION": "2.1.156",
|
||||
"CODEX_VERSION": "0.134.0",
|
||||
// Cursor: a pinned version plus one sha256 hash per CPU arch. The
|
||||
// Dockerfile checks the tarball against the hash at build time, so it
|
||||
// never runs a remote install script. Bump all three values together.
|
||||
// Re-hash each arch with:
|
||||
// curl -fSL https://downloads.cursor.com/lab/<ver>/linux/<x64|arm64>/agent-cli-package.tar.gz | sha256sum
|
||||
"CURSOR_VERSION": "2026.05.28-a70ca7c",
|
||||
"CURSOR_SHA256_X64": "7f8b6a09393e0b84b288cc6952b292fc98d15775f644cc01b0b9aa4f04b268df",
|
||||
"CURSOR_SHA256_ARM64": "05a0ab361e038729aba25fe7f407531b3e8432912e499d0bffdf1dda0e7833e9",
|
||||
// Bun: pinned by version. Installed by the official bun.sh/install
|
||||
// script, which accepts the release tag as its first positional arg
|
||||
// (`bash -s bun-vX.Y.Z`). UNLIKE Cursor, the install path runs an
|
||||
@@ -324,17 +314,6 @@
|
||||
// dependency explicit instead of silently following the default.
|
||||
"containerEnv": {
|
||||
"CODEX_HOME": "/home/node/.codex",
|
||||
"DISABLE_AUTOUPDATER": "1",
|
||||
// post-create.sh removes `installMethod` from the seeded ~/.claude.json so
|
||||
// the npm-global binary detects its own install method. This is a backup
|
||||
// safeguard for Claude Code issue #17289. The install-checks routine probes
|
||||
// ~/.local/bin/claude just because that directory EXISTS. It does exist
|
||||
// here, because Cursor drops agent and cursor-agent symlinks there. So even
|
||||
// when installMethod is non-native, the routine reports a false "claude
|
||||
// command not found at ~/.local/bin/claude". DISABLE_AUTOUPDATER does NOT
|
||||
// turn that routine off. DISABLE_INSTALLATION_CHECKS is its dedicated kill
|
||||
// switch.
|
||||
"DISABLE_INSTALLATION_CHECKS": "1",
|
||||
"HISTFILE": "/commandhistory/.zsh_history"
|
||||
},
|
||||
|
||||
|
||||
@@ -141,15 +141,12 @@ sync_from_host /host/.codex/config.toml /home/node/.codex/config.toml 644
|
||||
# Seed $HOME/.claude.json from the host, but NOT as a straight copy. That file
|
||||
# mixes two kinds of state. Some is portable account and onboarding state we
|
||||
# want to keep: hasCompletedOnboarding, oauthAccount, userID, projects,
|
||||
# tipsHistory. The rest describes how Claude is installed on the host, and that
|
||||
# part is never valid here. This image installs Claude with `npm install -g`,
|
||||
# but the host's `installMethod` (for example "native") makes Claude look for
|
||||
# ~/.local/bin/claude and fail with
|
||||
# "claude command not found at /home/node/.local/bin/claude". The fix strips the
|
||||
# machine-specific fields and forces hasCompletedOnboarding, while handling a
|
||||
# host file that isn't a JSON object. That logic lives in seed-claude-config.cjs
|
||||
# so it can be unit-tested and prettier-checked
|
||||
# (translate-plugin-registries.test.cjs).
|
||||
# tipsHistory. The rest describes how Claude is installed on the HOST, and that
|
||||
# part is never valid here — for example the host's `installMethod` value only
|
||||
# makes sense for the host's binary. The fix strips the machine-specific fields
|
||||
# and forces hasCompletedOnboarding, while handling a host file that isn't a
|
||||
# JSON object. That logic lives in seed-claude-config.cjs so it can be
|
||||
# unit-tested and prettier-checked (translate-plugin-registries.test.cjs).
|
||||
node "$SCRIPT_DIR/seed-claude-config.cjs"
|
||||
|
||||
# Codex auth. Some hosts store credentials in the OS keyring instead of on disk
|
||||
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
matrix: ${{ steps.decide.outputs.matrix }}
|
||||
release_app: ${{ steps.relapp.outputs.configured }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0 # need base history to diff recorded versions
|
||||
persist-credentials: false
|
||||
@@ -270,7 +270,7 @@ jobs:
|
||||
# and compiling them under emulation on the arm runners is slow.
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false # this job uploads artifacts (artipacked)
|
||||
|
||||
@@ -280,7 +280,7 @@ jobs:
|
||||
|
||||
- name: Ensure Python (arm64 Windows only)
|
||||
if: matrix.platform_arch == 'win32-arm64'
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
@@ -431,7 +431,7 @@ jobs:
|
||||
app-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
persist-credentials: false
|
||||
@@ -477,7 +477,7 @@ jobs:
|
||||
NODE
|
||||
|
||||
- name: Attest build provenance (SLSA)
|
||||
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
with:
|
||||
subject-path: 'gitnexus/vendor/tree-sitter-*/prebuilds/**/*.node'
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
# persist-credentials: false — this job only reads (tests and syntax
|
||||
# checks) and never pushes. The setting keeps GITHUB_TOKEN out of
|
||||
# .git/config, which zizmor flags as the "artipacked" issue.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
# persist-credentials: false — this is a read-only build smoke that
|
||||
# never pushes. The setting keeps GITHUB_TOKEN out of .git/config,
|
||||
# which zizmor flags as the "artipacked" issue.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
outputs:
|
||||
web_changed: ${{ steps.filter.outputs.web }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v3
|
||||
id: filter
|
||||
with:
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Configure e2e GitNexus home
|
||||
run: echo "GITNEXUS_HOME=${RUNNER_TEMP}/gitnexus-home" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
- run: npx tsc --noEmit
|
||||
working-directory: gitnexus
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/setup-gitnexus-web
|
||||
- run: npx tsc -b --noEmit
|
||||
working-directory: gitnexus-web
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Validate workflow concurrency convention
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
|
||||
- name: Checkout (for vitest config)
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
sparse-checkout: gitnexus/vitest.config.ts
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
# test-reports artifact (if: always()). The default-persisted token in
|
||||
# .git/config must not be capturable through that upload (zizmor
|
||||
# credential-persistence / artipacked audit). The job never pushes.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
steps:
|
||||
# persist-credentials: false — runs tests only, never pushes (zizmor
|
||||
# credential-persistence / artipacked audit).
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
# from a tarball and never pushes back; the token in .git/config would
|
||||
# be at risk of leaking through any future artifact-upload step
|
||||
# (zizmor artipacked audit). Disable upfront.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
@@ -246,7 +246,7 @@ jobs:
|
||||
# and never pushes; the default-persisted token in .git/config would be at
|
||||
# risk of leaking through an artifact upload (zizmor credential-persistence
|
||||
# / artipacked audit). Mirrors the packaged-install-smoke job below.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
@@ -266,6 +266,16 @@ jobs:
|
||||
run: node --import tsx bench/scope-capture/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: CFG construction time / disk / memory guards (#2081 M1)
|
||||
# Build-free: asserts collectFunctionCfgs output is unchanged
|
||||
# (fingerprint) and that wall-time, cfgSideChannel disk bytes, AND
|
||||
# retained heap all stay sub-quadratic for the straight-line /
|
||||
# many-functions / branchy scenarios. Catches an O(n^2) re-regression in
|
||||
# the per-function CFG builder (e.g. an extendBlock concat chain) and a
|
||||
# memory/disk blow-up. --expose-gc enables the retained-heap measurement.
|
||||
run: node --expose-gc --import tsx bench/cfg/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial)
|
||||
env:
|
||||
GITNEXUS_BENCH: '1'
|
||||
|
||||
@@ -129,7 +129,7 @@ jobs:
|
||||
core.setOutput('code_review', isCodeReview ? 'true' : 'false');
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
repository: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.repo || github.repository }}
|
||||
ref: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.sha || '' }}
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
# Don't leave GITHUB_TOKEN in .git/config for downstream steps to read.
|
||||
persist-credentials: false
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
# When triggered by workflow_call the caller passes the RC tag as an input;
|
||||
# we check out that tag so the Dockerfile and package.json match the built image.
|
||||
# For tag-push events github.ref is already the tag ref — no override needed.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
|
||||
# Required for multi-platform (linux/arm64) emulation.
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
# Full history needed for the on-push full-history scan; on PRs the
|
||||
# action diffs against the base ref so the cost is bounded by the PR.
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5.0.4
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5.0.4
|
||||
with:
|
||||
repository: ${{ steps.locate.outputs.head_repo }}
|
||||
ref: ${{ steps.locate.outputs.head_sha }}
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
# PR head commit (not the synthetic merge ref) — we need the
|
||||
# exact tree the contributor pushed so suggestions line up.
|
||||
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
# Pinned to v7.2.0. Verify SHA via:
|
||||
# gh api repos/release-drafter/release-drafter/git/refs/tags/v7.2.0
|
||||
# v7 removed `disable-releaser`; use `dry-run: true` to only autolabel.
|
||||
- uses: release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927 # v7.3.0
|
||||
- uses: release-drafter/release-drafter@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
dry-run: true
|
||||
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
should_run: ${{ steps.decide.outputs.should_run }}
|
||||
head_sha: ${{ steps.decide.outputs.head_sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
@@ -332,7 +332,7 @@ jobs:
|
||||
# on the RC path.
|
||||
- name: Checkout (RC)
|
||||
if: needs.route.outputs.mode == 'rc'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
@@ -349,7 +349,7 @@ jobs:
|
||||
|
||||
- name: Checkout (stable)
|
||||
if: needs.route.outputs.mode == 'stable'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# No `token:` — actions/checkout uses GITHUB_TOKEN by default. Stable
|
||||
# path performs no git pushes; the default scope is sufficient.
|
||||
with:
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
# Needed to open/update the tracking issue on scheduled runs.
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts/triage
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -204,6 +204,10 @@ Language-agnostic scope-resolution resolver. This is the resolution path for eve
|
||||
Orchestrator: `runScopeResolution(input, provider)` in `scope-resolution/pipeline/run.ts`.
|
||||
Pipeline phase: `scopeResolutionPhase` in `scope-resolution/pipeline/phase.ts` — iterates the registered `SCOPE_RESOLVERS` over the worker-serialized `ParsedFile`s. (Per-language `emitScopeCaptures` hooks may reuse a cached Tree via the orchestrator's `treeCache`, but in worker-pool runs that cache is empty — Trees can't cross MessageChannels — so they consume the pre-extracted `ParsedFile` instead; § Performance notes.)
|
||||
|
||||
### Optional CFG/PDG emission (`--pdg`, #2081 M1)
|
||||
|
||||
On a `--pdg` run, the parse worker builds a per-function control-flow graph from the tree-sitter AST (`LanguageProvider.cfgVisitor`; TypeScript/JavaScript in M1) and serializes it onto `ParsedFile.cfgSideChannel` as plain data. Scope-resolution then emits `BasicBlock` nodes + `CFG` edges from that side-channel **inside Phase 4 of `runScopeResolution`, while the disk-backed ParsedFile store is still live** — the only window where the worker-built CFGs are loaded (the store is cleared right after the phase returns). A standalone post-`mro` phase would read an empty store, so the CFG emit deliberately lives in-phase, mirroring the `applyCaptureSideChannel` pattern. The opt-in is off by default (graph byte-identical), folded into the parse-cache key (a pdg-off warm cache is never reused on a `--pdg` run), and bounded by a per-function edge cap that logs any dropped edges. Edge *kind* (`seq`/`cond-true`/`loop-back`/…) rides in the `CFG` relationship's `reason` (CFG is a single `CodeRelation` type, not one type per kind). See `core/ingestion/cfg/`.
|
||||
|
||||
### `ScopeResolver` contract
|
||||
|
||||
Single interface a language implements to plug into the pipeline. Contract fully documented in `scope-resolution/contract/scope-resolver.ts`.
|
||||
|
||||
@@ -4,6 +4,10 @@ All notable changes to GitNexus will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Hook db-lock probe no longer strands unkillable `lsof`/`ps` orphans** — the probe's `lsof`/`ps` subprocesses are now wrapped in a self-tested coreutils `timeout`/`gtimeout` (`timeout -k 1 …`), so a hook SIGKILLed by the runner's 10s timeout can no longer leave `lsof` running forever (orphan lifetime bounded at ~3s); `acquireHookSlot` now also gates the probe itself, capping concurrent probes at 3 per repo. Opt out with `GITNEXUS_HOOK_TIMEOUT_PATH=disabled`. (#2163)
|
||||
|
||||
### Changed
|
||||
- Migrated from KuzuDB to LadybugDB v0.15 (`@ladybugdb/core`, `@ladybugdb/wasm-core`)
|
||||
- Renamed all internal paths from `kuzu` to `lbug` (storage: `.gitnexus/kuzu` → `.gitnexus/lbug`)
|
||||
|
||||
@@ -100,6 +100,22 @@ ENV HOME=/home/node \
|
||||
RUN su node -s /bin/sh -c "HOME=/home/node node /app/gitnexus/scripts/install-duckdb-extension.mjs fts" \
|
||||
&& su node -s /bin/sh -c "HOME=/home/node node /app/gitnexus/scripts/install-duckdb-extension.mjs fts --verify-only"
|
||||
|
||||
# Published runtime assets (in package.json `files`). Placed AFTER the DuckDB
|
||||
# FTS-extension RUN above so editing hook/skill content does not invalidate that
|
||||
# network-fetching cache layer; they have no input dependency on it.
|
||||
# `hooks/`: dist/cli/resolve-invocation.js does
|
||||
# `require('../../hooks/claude/resolve-analyze-cmd.cjs')` at module load — the
|
||||
# single source of truth for the npm-11 npx-crash invocation decision (#1939).
|
||||
# Without it, `gitnexus analyze` inside the image crashes with MODULE_NOT_FOUND
|
||||
# before it does any work (#2130). `skills/`: the CLI reads the bundled SKILL.md
|
||||
# templates from `<pkg>/skills/` for `gitnexus analyze --skills` and `gitnexus
|
||||
# setup`/`uninstall`; absent, those degrade silently (placeholder content / zero
|
||||
# skills installed). (The web UI bundle `web/`, also in `files`, is deliberately
|
||||
# NOT shipped: this builder never builds gitnexus-web, so the image is API-only;
|
||||
# the UI is the separate Dockerfile.web image / hosted app.)
|
||||
COPY --from=builder --chown=node:node /app/gitnexus/hooks ./gitnexus/hooks
|
||||
COPY --from=builder --chown=node:node /app/gitnexus/skills ./gitnexus/skills
|
||||
|
||||
USER node
|
||||
|
||||
# The web UI defaults to http://localhost:4747 - keep that contract.
|
||||
|
||||
@@ -703,6 +703,8 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
|
||||
|
||||
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
|
||||
|
||||
**Control flow (CFG, opt-in `--pdg`)** — per-function control-flow graphs (`BasicBlock` nodes + `CFG` edges) feeding the PDG/taint substrate, currently **TypeScript & JavaScript** (#2081 M1); other languages planned. Off by default.
|
||||
|
||||
---
|
||||
|
||||
## Tool Examples
|
||||
|
||||
@@ -110,10 +110,20 @@ function hasGitNexusServerOwner(gitNexusDir) {
|
||||
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether opt-in diagnostics should be written to the hook's stderr. Strict
|
||||
* hook runners (e.g. Codex `PreToolUse`) validate hook output, so normal,
|
||||
* non-error skip paths must stay silent unless the operator explicitly asks
|
||||
* for diagnostics via GITNEXUS_DEBUG. See issue #1913.
|
||||
*/
|
||||
function isDebugEnabled() {
|
||||
return process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
}
|
||||
|
||||
function extractAugmentContext(stderr) {
|
||||
const output = (stderr || '').trim();
|
||||
const marker = output.indexOf('[GitNexus]');
|
||||
const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
const debug = isDebugEnabled();
|
||||
if (debug && output.length > 0) {
|
||||
// Emit the FULL discarded prefix (everything before the marker, or all of
|
||||
// it when no marker is present) so suppressed diagnostics — LadybugDB lock
|
||||
@@ -266,16 +276,34 @@ function handlePreToolUse(input) {
|
||||
|
||||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
|
||||
// Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
|
||||
// itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
|
||||
// as the augment, or concurrent sessions fan out unbounded probe
|
||||
// subprocesses. Keep the acquire right after the cheap guards above —
|
||||
// moving it earlier would churn slot files on tool calls that never probe.
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) {
|
||||
// Normal skip path: all per-repo hook slots are held by concurrent
|
||||
// sessions. Stay silent for strict hook runners (issue #1913); surface
|
||||
// the reason only when diagnostics are explicitly requested.
|
||||
if (isDebugEnabled()) {
|
||||
process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) return;
|
||||
|
||||
let result = '';
|
||||
try {
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
// Normal skip path: the MCP server owns the DB, so the CLI augment would
|
||||
// contend on the lock. Stay silent for strict hook runners (issue #1913);
|
||||
// surface the reason only when diagnostics are explicitly requested.
|
||||
if (isDebugEnabled()) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const child = runGitNexusCli(['augment', '--', pattern], cwd, 7000);
|
||||
if (!child.error && child.status === 0) {
|
||||
result = extractAugmentContext(child.stderr || '');
|
||||
@@ -366,7 +394,7 @@ function main() {
|
||||
const handler = handlers[input.hook_event_name || ''];
|
||||
if (handler) handler(input);
|
||||
} catch (err) {
|
||||
if (process.env.GITNEXUS_DEBUG) {
|
||||
if (isDebugEnabled()) {
|
||||
console.error('GitNexus hook error:', (err.message || '').slice(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,22 @@
|
||||
*
|
||||
* Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or
|
||||
* PowerShell ETIMEDOUT (Windows), matching the hook contract.
|
||||
*
|
||||
* Unix subprocess containment contract (#2163):
|
||||
* - lsof/ps are wrapped in coreutils `timeout`/`gtimeout` when a working
|
||||
* wrapper is found (`timeout -k 1 <budget> lsof ...`). If this hook process
|
||||
* is itself SIGKILLed (e.g. by the runner's 10s hook timeout) the wrapper
|
||||
* survives, SIGTERMs its child at the budget (2s lsof / 1s ps) and SIGKILLs
|
||||
* it 1s later — orphan lifetime is bounded at ~3s instead of unbounded.
|
||||
* - GITNEXUS_HOOK_TIMEOUT_PATH: the sentinel value `disabled` switches the
|
||||
* wrapper off deterministically; any other value is adopted only when it
|
||||
* exists AND passes a one-shot `-k` self-test — otherwise resolution FALLS
|
||||
* THROUGH to the built-in candidate list (first self-test pass wins), so
|
||||
* no malformed value of any shape can silently disable orphan containment.
|
||||
* - The gitnexus server is lazy-open + sticky-hold: an idle MCP server holds
|
||||
* ZERO lbug fds until the repo's first MCP query, then keeps the fd open.
|
||||
* A probe before that first query is therefore always false — a known,
|
||||
* pre-existing race, not a bug in this probe.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
@@ -46,6 +62,80 @@ function resolveHookBinary(tool) {
|
||||
return tool;
|
||||
}
|
||||
|
||||
// Sentinel:
|
||||
// undefined = not resolved yet (resolve lazily, on first lsof/ps fallback)
|
||||
// string = self-tested coreutils timeout/gtimeout path (use as wrapper)
|
||||
// null = no usable wrapper (disabled, none found, or self-test failed)
|
||||
let unixGuardTimeoutCache;
|
||||
|
||||
/**
|
||||
* Resolve a coreutils `timeout`/`gtimeout` binary to wrap lsof/ps with
|
||||
* (#2163). Dead code on Windows (the win32 dispatch returns earlier).
|
||||
*
|
||||
* GITNEXUS_HOOK_TIMEOUT_PATH semantics: the sentinel `disabled` turns the
|
||||
* wrapper off; any other value is only a CANDIDATE — an existing file path
|
||||
* is tried first, but it must pass the `-k` self-test to be adopted. On any
|
||||
* failure (non-existent path, directory, non-executable file, wrapper
|
||||
* without `-k` support, …) resolution falls through to the built-in
|
||||
* candidates below, tried in order, first self-test pass wins. This is
|
||||
* strictly stronger than the sibling GITNEXUS_HOOK_LSOF_PATH /
|
||||
* GITNEXUS_HOOK_PS_PATH overrides (which only check existence): no bad env
|
||||
* value of ANY shape can silently disable orphan containment.
|
||||
*
|
||||
* Lazy self-test: candidates are probed only when the lsof/ps fallback is
|
||||
* first reached, and the result is memoized. A candidate is adopted only
|
||||
* when `timeout -k 1 1 /bin/sh -c :` exits 0. This rejects wrappers that do
|
||||
* not support the coreutils `-k` flag — busybox <1.34, toybox, broken
|
||||
* symlinks — which would otherwise exit with a usage error without ever
|
||||
* running lsof, silently converting the lsof-ETIMEDOUT fail-closed contract
|
||||
* into fail-open (#1492 regression). Only when EVERY candidate fails does
|
||||
* the probe fall back to the unwrapped status quo (memoized null).
|
||||
* busybox ≥1.34 passes the test and is fully usable (capability, not
|
||||
* identity, decides).
|
||||
*/
|
||||
function passesGuardSelfTest(guard) {
|
||||
try {
|
||||
const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', ':'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 3000,
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
windowsHide: true,
|
||||
});
|
||||
return !selfTest.error && selfTest.status === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUnixGuardTimeout() {
|
||||
if (unixGuardTimeoutCache !== undefined) return unixGuardTimeoutCache;
|
||||
unixGuardTimeoutCache = null;
|
||||
const fromEnv = process.env.GITNEXUS_HOOK_TIMEOUT_PATH;
|
||||
const trimmed = fromEnv ? String(fromEnv).trim() : '';
|
||||
if (trimmed === 'disabled') return unixGuardTimeoutCache;
|
||||
const candidates = [];
|
||||
if (trimmed && fs.existsSync(trimmed)) candidates.push(trimmed);
|
||||
for (const builtin of [
|
||||
'/usr/bin/timeout',
|
||||
'/bin/timeout',
|
||||
'/opt/homebrew/bin/gtimeout',
|
||||
'/usr/local/bin/gtimeout',
|
||||
]) {
|
||||
try {
|
||||
if (fs.existsSync(builtin)) candidates.push(builtin);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (passesGuardSelfTest(candidate)) {
|
||||
unixGuardTimeoutCache = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return unixGuardTimeoutCache;
|
||||
}
|
||||
|
||||
function resolveWindowsPowerShellPath() {
|
||||
const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH;
|
||||
if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) {
|
||||
@@ -188,20 +278,46 @@ function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
|
||||
}
|
||||
|
||||
function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
|
||||
const guard = resolveUnixGuardTimeout();
|
||||
const lsofPath = resolveHookBinary('lsof');
|
||||
const lsof = spawnSync(lsofPath, ['-nP', '-t', '--', dbPathAbs], {
|
||||
// The spawnSync timeouts below (lsof 1000ms / ps 500ms) are deliberately
|
||||
// SHORTER than the wrapper budgets (2s / 1s): on the supervised path Node's
|
||||
// SIGTERM always fires first, so `error.code === 'ETIMEDOUT'` and the
|
||||
// fail-closed contract are untouched. The wrapper only matters once this
|
||||
// hook process has been SIGKILLed and can no longer deliver that SIGTERM.
|
||||
const [lsofCmd, lsofArgs] = guard
|
||||
? [guard, ['-k', '1', '2', lsofPath, '-nP', '-t', '--', dbPathAbs]]
|
||||
: [lsofPath, ['-nP', '-t', '--', dbPathAbs]];
|
||||
const lsof = spawnSync(lsofCmd, lsofArgs, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 1000,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (lsof.error) return lsof.error.code === 'ETIMEDOUT';
|
||||
// Guard-mediated deaths map to "unresponsive holder" (fail-closed). Three
|
||||
// result shapes, verified against coreutils 9.1:
|
||||
// - signal-death: when `-k` escalates to SIGKILL, coreutils timeout
|
||||
// SELF-RAISES the signal, so spawnSync reports {status: null, signal}
|
||||
// with no .error (spawnSync's own ETIMEDOUT was handled above). The
|
||||
// same shape appears when this hook is frozen >2s (SIGSTOP, laptop
|
||||
// suspend) and the guard expires while it sleeps. By construction, a
|
||||
// guard-wrapped probe that died by signal without spawnSync ETIMEDOUT
|
||||
// is a budget/kill outcome.
|
||||
// - 124: budget expired and the child exited after the plain SIGTERM.
|
||||
// - 137: NOT the coreutils -k path — only exit-code-propagating wrappers,
|
||||
// or a child SIGKILLed externally (e.g. the OOM killer).
|
||||
if (guard && lsof.status === null && lsof.signal) return true;
|
||||
if (guard && (lsof.status === 124 || lsof.status === 137)) return true;
|
||||
|
||||
const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean);
|
||||
const psPath = resolveHookBinary('ps');
|
||||
for (const pid of pids) {
|
||||
if (Number(pid) === myPid) continue;
|
||||
const ps = spawnSync(psPath, ['-p', pid, '-o', 'command='], {
|
||||
const [psCmd, psArgs] = guard
|
||||
? [guard, ['-k', '1', '1', psPath, '-p', pid, '-o', 'command=']]
|
||||
: [psPath, ['-p', pid, '-o', 'command=']];
|
||||
const ps = spawnSync(psCmd, psArgs, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 500,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
@@ -211,6 +327,11 @@ function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
|
||||
if (ps.error.code === 'ETIMEDOUT') return true;
|
||||
continue;
|
||||
}
|
||||
// Same guard-mediated-death mapping as the lsof call above (signal-death
|
||||
// from the -k escalation or a frozen hook; 124 budget expiry; 137 only
|
||||
// for exit-code-propagating wrappers / external SIGKILL).
|
||||
if (guard && ps.status === null && ps.signal) return true;
|
||||
if (guard && (ps.status === 124 || ps.status === 137)) return true;
|
||||
if (isGitNexusServerCommand(ps.stdout || '')) return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -98,4 +98,26 @@ export interface ParsedFile {
|
||||
* side effects — the contract default) leave this undefined.
|
||||
*/
|
||||
readonly captureSideChannel?: unknown;
|
||||
|
||||
/**
|
||||
* Per-function control-flow graphs for this file (#2081 M1, PDG/taint
|
||||
* substrate). A DISTINCT field from {@link captureSideChannel} — different
|
||||
* producer, consumer, and lifecycle: the worker builds it from the
|
||||
* tree-sitter AST via `LanguageProvider.cfgVisitor` (only on a `--pdg` run),
|
||||
* and scope-resolution emits BasicBlock nodes + CFG edges from it while the
|
||||
* disk-backed ParsedFile store is still live (it is NOT a capture-time
|
||||
* marker the resolver restores into module maps). Kept separate so a future
|
||||
* change to either channel's shape invalidates independently.
|
||||
*
|
||||
* Shared / ingestion code treats this as opaque (`unknown`) per AGENTS.md.
|
||||
* Concretely it is a `readonly FunctionCfg[]` (see
|
||||
* `core/ingestion/cfg/types.ts`) — plain JSON-serializable data (no AST
|
||||
* refs, no class instances) so it round-trips through the parse cache and
|
||||
* the `parsedfile-store` (whose interning reviver keys on `nodeId`, which
|
||||
* these blocks/edges deliberately lack).
|
||||
*
|
||||
* Optional: `undefined` on non-`--pdg` runs and for languages with no
|
||||
* `cfgVisitor` — the default for every run today.
|
||||
*/
|
||||
readonly cfgSideChannel?: unknown;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ export interface SymbolDefinition {
|
||||
* Currently used by C++ overload ranking to exclude explicit constructors
|
||||
* from implicit user-defined conversion candidates. */
|
||||
isExplicit?: boolean;
|
||||
/** True when the callable is declared unavailable (for example C++ `= delete`).
|
||||
* Unavailable callables still participate in overload selection, but a
|
||||
* selected unavailable target must suppress edge emission. */
|
||||
isDeleted?: boolean;
|
||||
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
|
||||
ownerId?: string;
|
||||
/** #1982/#1993: bridge-held enclosing-namespace path (e.g. `NS1`, `Outer.Inner`)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* E2E for the browser folder-upload flow (replaces the removed server-side
|
||||
* directory picker). Mocks the backend so no live gitnexus server is needed.
|
||||
*/
|
||||
|
||||
const BACKEND_URL = 'http://localhost:4747';
|
||||
|
||||
let fixtureDir: string;
|
||||
|
||||
test.beforeAll(() => {
|
||||
// A tiny "repo" folder; Playwright sets webkitRelativePath = <folder>/<file>.
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-upload-e2e-'));
|
||||
fixtureDir = path.join(root, 'myrepo');
|
||||
fs.mkdirSync(path.join(fixtureDir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(path.join(fixtureDir, 'README.md'), '# hi\n');
|
||||
fs.writeFileSync(path.join(fixtureDir, 'src', 'index.ts'), 'export const x = 1;\n');
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route(`${BACKEND_URL}/api/repos`, (route) => route.fulfill({ json: [] }));
|
||||
await page.route(`${BACKEND_URL}/api/info`, (route) =>
|
||||
route.fulfill({ json: { version: '1.0.0', launchContext: 'npx', nodeVersion: 'v22.0.0' } }),
|
||||
);
|
||||
await page.route(`${BACKEND_URL}/api/heartbeat`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
body: ':ok\n\n',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('uploading a folder posts a multipart upload and starts analysis', async ({ page }) => {
|
||||
let uploadContentType = '';
|
||||
await page.route(`${BACKEND_URL}/api/analyze/upload`, async (route) => {
|
||||
uploadContentType = route.request().headers()['content-type'] ?? '';
|
||||
await route.fulfill({ json: { jobId: 'job-e2e', status: 'analyzing' } });
|
||||
});
|
||||
// SSE progress → immediately complete.
|
||||
await page.route(`${BACKEND_URL}/api/analyze/job-e2e/progress`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
|
||||
body: 'event: complete\ndata: {"repoName":"myrepo"}\n\n',
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('tab', { name: 'Local Folder' })).toBeVisible({ timeout: 20_000 });
|
||||
await page.getByRole('tab', { name: 'Local Folder' }).click();
|
||||
|
||||
await expect(page.locator('[data-testid="upload-folder"]')).toBeVisible();
|
||||
|
||||
// Select the fixture folder via the hidden webkitdirectory input.
|
||||
await page.locator('[data-testid="folder-upload-input"]').setInputFiles(fixtureDir);
|
||||
|
||||
// The upload endpoint should be hit with a multipart body, and the UI should
|
||||
// leave the input phase (upload button no longer shown).
|
||||
await expect.poll(() => uploadContentType).toContain('multipart/form-data');
|
||||
await expect(page.locator('[data-testid="upload-folder"]')).toBeHidden({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('switching modes mid-upload aborts it and never shows progress', async ({ page }) => {
|
||||
// Hold the upload response until the test releases it, so the mode switch
|
||||
// happens while the POST is in flight (the review 4470339833 repro).
|
||||
let releaseUpload!: () => void;
|
||||
const uploadGate = new Promise<void>((res) => (releaseUpload = res));
|
||||
let uploadAborted = false;
|
||||
let progressOpened = false;
|
||||
|
||||
// The client-side AbortController kills the POST at mode-switch time; that
|
||||
// surfaces as a failed request (net::ERR_ABORTED), not as a response.
|
||||
page.on('requestfailed', (req) => {
|
||||
if (req.url().includes('/api/analyze/upload') && /ABORTED/.test(req.failure()?.errorText ?? ''))
|
||||
uploadAborted = true;
|
||||
});
|
||||
await page.route(`${BACKEND_URL}/api/analyze/upload`, async (route) => {
|
||||
await uploadGate;
|
||||
await route.fulfill({ json: { jobId: 'job-stale', status: 'analyzing' } }).catch(() => {}); // the request may already be gone — that's the point
|
||||
});
|
||||
await page.route(`${BACKEND_URL}/api/analyze/job-stale/progress`, (route) => {
|
||||
progressOpened = true;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
|
||||
body: 'event: complete\ndata: {"repoName":"myrepo"}\n\n',
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('tab', { name: 'Local Folder' })).toBeVisible({ timeout: 20_000 });
|
||||
await page.getByRole('tab', { name: 'Local Folder' }).click();
|
||||
await page.locator('[data-testid="folder-upload-input"]').setInputFiles(fixtureDir);
|
||||
await expect(page.locator('[data-testid="upload-progress"]')).toBeVisible();
|
||||
|
||||
// Switch back to GitHub while the upload POST is still pending, then let
|
||||
// the (now-stale) route handler finish.
|
||||
await page.getByRole('tab', { name: 'GitHub URL' }).click();
|
||||
await expect.poll(() => uploadAborted, { timeout: 10_000 }).toBe(true);
|
||||
releaseUpload();
|
||||
|
||||
// The GitHub form stays clean (no error, immediately usable), and no SSE
|
||||
// progress stream is ever opened by the stale upload.
|
||||
await expect(page.getByPlaceholder('https://github.com/owner/repo')).toBeEditable();
|
||||
await expect(page.locator('[data-testid="upload-progress"]')).toBeHidden();
|
||||
expect(progressOpened).toBe(false);
|
||||
});
|
||||
@@ -218,8 +218,8 @@ test.describe('Flow 3: Analyze form', () => {
|
||||
// Switch to Local Folder tab
|
||||
await page.getByRole('tab', { name: 'Local Folder' }).click();
|
||||
|
||||
// Browse button should be visible
|
||||
await expect(page.getByText('Browse for folder')).toBeVisible();
|
||||
// Upload-a-folder button should be visible (browser folder upload)
|
||||
await expect(page.locator('[data-testid="upload-folder"]')).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath('local-folder-tab.png') });
|
||||
});
|
||||
|
||||
|
||||
Generated
+75
-75
@@ -18,7 +18,7 @@
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"axios": "^1.16.1",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.7",
|
||||
"dompurify": "^3.4.8",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
@@ -28,7 +28,7 @@
|
||||
"graphology-utils": "^2.3.0",
|
||||
"i18next": "^26.3.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"langchain": "^1.4.2",
|
||||
"langchain": "^1.4.4",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^1.16.0",
|
||||
"mermaid": "^11.15.0",
|
||||
@@ -41,7 +41,7 @@
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sigma": "^3.0.2",
|
||||
"sigma": "^3.0.3",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"uuid": "^14.0.0",
|
||||
"zod": "^4.4.3"
|
||||
@@ -57,9 +57,9 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.8.8",
|
||||
"@vercel/node": "^5.8.12",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
@@ -2930,9 +2930,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/build-utils": {
|
||||
"version": "13.26.4",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.26.4.tgz",
|
||||
"integrity": "sha512-0g3ZxtZUJZbt4y0Vu4pkHtu1UN58FbVF9cqGT8T6jHp0EHdLGFj5TVCiME8ALeK4tjPImSmxnKZvvB5yb2hqEw==",
|
||||
"version": "13.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.27.1.tgz",
|
||||
"integrity": "sha512-BD9H2U8I/IPGS1c1stSIkdPxBRu6bkCQFqtRjcT2dcdnBawyHXvzTsnnBQhgB3fJVQtgJT47gVZe1oHDmv0Ktg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -2949,9 +2949,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vercel/error-utils": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/error-utils/-/error-utils-2.1.0.tgz",
|
||||
"integrity": "sha512-DiJcXBOB9N6QM4d7hYPM9Ck/AUjzBl58XNQPxS74o7CuvIanjzrGgygP/70VsyEASeIJMazk1LrhwcNTR/eZGQ==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/error-utils/-/error-utils-2.2.0.tgz",
|
||||
"integrity": "sha512-WFWiRxfPzoYWYifaj4thSKvAaZZwUOqD4k5GINRIgZgCiS2E3iAJbWbIsIZmkQdTecWFHcWGA6q48CjisgpOBA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
@@ -2983,9 +2983,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/node": {
|
||||
"version": "5.8.8",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.8.8.tgz",
|
||||
"integrity": "sha512-+uRT9evnGWUE6klrJJED4fCvlSxNShbIc/UY4FeUzt2sdcy5a5b1IoYlo94RJd7tAY9Jg2lR2cVfGfsnWH81ZA==",
|
||||
"version": "5.8.12",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.8.12.tgz",
|
||||
"integrity": "sha512-XK2ML9YVdAlZ3BmGTW4jQL0D55ZHeRWKS+CLPSWReDyOBKaC4tTnTL2tp3z76bAs0pfgbT55nplMiX2mneSbLA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -2993,8 +2993,8 @@
|
||||
"@edge-runtime/primitives": "4.1.0",
|
||||
"@edge-runtime/vm": "3.2.0",
|
||||
"@types/node": "20.11.0",
|
||||
"@vercel/build-utils": "13.26.4",
|
||||
"@vercel/error-utils": "2.1.0",
|
||||
"@vercel/build-utils": "13.27.1",
|
||||
"@vercel/error-utils": "2.2.0",
|
||||
"@vercel/nft": "1.10.0",
|
||||
"@vercel/static-config": "3.4.0",
|
||||
"async-listen": "3.0.0",
|
||||
@@ -3101,14 +3101,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz",
|
||||
"integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz",
|
||||
"integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
@@ -3122,8 +3122,8 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.5",
|
||||
"vitest": "4.1.5"
|
||||
"@vitest/browser": "4.1.8",
|
||||
"vitest": "4.1.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
@@ -3132,16 +3132,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
|
||||
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
|
||||
"integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.5",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/spy": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -3150,13 +3150,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
|
||||
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
|
||||
"integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.5",
|
||||
"@vitest/spy": "4.1.8",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -3187,9 +3187,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
|
||||
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
|
||||
"integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3200,13 +3200,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
|
||||
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
|
||||
"integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -3214,14 +3214,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
|
||||
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
|
||||
"integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.5",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -3230,9 +3230,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
|
||||
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
|
||||
"integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -3240,13 +3240,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
|
||||
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
|
||||
"integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.5",
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -4461,9 +4461,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
|
||||
"integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
|
||||
"version": "3.4.8",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
|
||||
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -5752,9 +5752,9 @@
|
||||
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
|
||||
},
|
||||
"node_modules/langchain": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.4.2.tgz",
|
||||
"integrity": "sha512-SLGipy0r4nqQD0aiUOBYLMeGFfB/QiYnMndfZ8sGN89vXDCIXbYqcE7G/4QDDX3nZsM7/emQpoScmlxEX6sDnQ==",
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.4.4.tgz",
|
||||
"integrity": "sha512-tepOCwUDaIZOYJ9Eo0O6o5dXEN/0KJheiFDnHHFL8Tx8rfkDLL4cOTSTln4Vpn9LpWzXYkjQ8lkHnnNDQWZPeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/langgraph": "^1.3.2",
|
||||
@@ -8153,9 +8153,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sigma": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.2.tgz",
|
||||
"integrity": "sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ==",
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.3.tgz",
|
||||
"integrity": "sha512-5H0zFlx6/NTQpqBg4Rm569ZOpnBOXMaS25UQThIWMU3XyzI5AhmorK/gnl87BvJBLhQd0tW4C0LIp3enWzMoNw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"events": "^3.3.0",
|
||||
@@ -8822,19 +8822,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
|
||||
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
|
||||
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.5",
|
||||
"@vitest/mocker": "4.1.5",
|
||||
"@vitest/pretty-format": "4.1.5",
|
||||
"@vitest/runner": "4.1.5",
|
||||
"@vitest/snapshot": "4.1.5",
|
||||
"@vitest/spy": "4.1.5",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/expect": "4.1.8",
|
||||
"@vitest/mocker": "4.1.8",
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"@vitest/runner": "4.1.8",
|
||||
"@vitest/snapshot": "4.1.8",
|
||||
"@vitest/spy": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -8862,12 +8862,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.5",
|
||||
"@vitest/browser-preview": "4.1.5",
|
||||
"@vitest/browser-webdriverio": "4.1.5",
|
||||
"@vitest/coverage-istanbul": "4.1.5",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"@vitest/ui": "4.1.5",
|
||||
"@vitest/browser-playwright": "4.1.8",
|
||||
"@vitest/browser-preview": "4.1.8",
|
||||
"@vitest/browser-webdriverio": "4.1.8",
|
||||
"@vitest/coverage-istanbul": "4.1.8",
|
||||
"@vitest/coverage-v8": "4.1.8",
|
||||
"@vitest/ui": "4.1.8",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"axios": "^1.16.1",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.7",
|
||||
"dompurify": "^3.4.8",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
@@ -38,7 +38,7 @@
|
||||
"graphology-utils": "^2.3.0",
|
||||
"i18next": "^26.3.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"langchain": "^1.4.2",
|
||||
"langchain": "^1.4.4",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^1.16.0",
|
||||
"mermaid": "^11.15.0",
|
||||
@@ -51,7 +51,7 @@
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sigma": "^3.0.2",
|
||||
"sigma": "^3.0.3",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"uuid": "^14.0.0",
|
||||
"zod": "^4.4.3"
|
||||
@@ -67,9 +67,9 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.8.8",
|
||||
"@vercel/node": "^5.8.12",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
|
||||
@@ -21,9 +21,11 @@ import {
|
||||
startAnalyze,
|
||||
cancelAnalyze,
|
||||
streamAnalyzeProgress,
|
||||
uploadFolder,
|
||||
type JobProgress,
|
||||
} from '../services/backend-client';
|
||||
import { AnalyzeProgress } from './AnalyzeProgress';
|
||||
import { filterRepoFiles } from '@/lib/upload-filter';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -165,8 +167,11 @@ export interface RepoAnalyzerProps {
|
||||
export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProps) => {
|
||||
const { t } = useTranslation(['common', 'errors', 'onboarding']);
|
||||
const inputId = useId();
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
const [mode, setMode] = useState<InputMode>('github');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadSummary, setUploadSummary] = useState<{ count: number; dropped: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [githubUrl, setGithubUrl] = useState('');
|
||||
const [gitlabUrl, setGitlabUrl] = useState('');
|
||||
const [localPath, setLocalPath] = useState('');
|
||||
@@ -181,28 +186,73 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
|
||||
const jobIdRef = useRef<string | null>(null);
|
||||
const sseControllerRef = useRef<AbortController | null>(null);
|
||||
// Owns the in-flight analyze/upload request. The controller doubles as the
|
||||
// staleness token: each request captures its own controller in a closure and
|
||||
// bails after the await when that controller was aborted, so a resolution
|
||||
// arriving after a mode switch / cancel / unmount can never drive state.
|
||||
const requestControllerRef = useRef<AbortController | null>(null);
|
||||
const completeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
sseControllerRef.current?.abort();
|
||||
requestControllerRef.current?.abort();
|
||||
if (completeTimerRef.current) clearTimeout(completeTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Abort any in-flight analyze/upload request so its settlement can't drive
|
||||
// state. Aborting is load-bearing: once a mode switch resets `uploading`,
|
||||
// the `uploading || isLoading` re-entry guard no longer covers the stale
|
||||
// request — only its aborted signal does.
|
||||
const invalidateRequest = (): void => {
|
||||
requestControllerRef.current?.abort();
|
||||
requestControllerRef.current = null;
|
||||
};
|
||||
|
||||
// Invalidate the previous request and hand the caller a fresh controller.
|
||||
const renewRequestController = (): AbortController => {
|
||||
invalidateRequest();
|
||||
const controller = new AbortController();
|
||||
requestControllerRef.current = controller;
|
||||
return controller;
|
||||
};
|
||||
|
||||
// An upload that resolved after invalidation has still created a server-side
|
||||
// job; cancel it so the single analyze slot isn't held for the job's full
|
||||
// duration. Upload-path only: every upload owns a fresh job (the server
|
||||
// stages each upload into a unique dir, never dedup-aliasing), whereas URL
|
||||
// analyzes dedup-alias by repo — the returned jobId may belong to a job
|
||||
// another session (or this user's own resubmit) is actively watching, so
|
||||
// cancelling on that path could kill a live analysis. A stale URL job is
|
||||
// left to finish: a same-URL resubmit re-attaches to it via dedup, and the
|
||||
// server's job timeout/TTL sweep bounds the slot occupancy.
|
||||
const cancelStaleUploadJob = (jobId: string): void => {
|
||||
void cancelAnalyze(jobId).catch(() => {});
|
||||
};
|
||||
|
||||
const handleModeChange = (m: InputMode) => {
|
||||
// ModeTabs fires onChange on every click, including the already-active
|
||||
// tab — never abort the user's own in-flight request for a no-op click.
|
||||
if (m === mode) return;
|
||||
invalidateRequest();
|
||||
setMode(m);
|
||||
setGithubUrl('');
|
||||
setGitlabUrl('');
|
||||
setLocalPath('');
|
||||
setValidationError(null);
|
||||
setUploadSummary(null);
|
||||
setUploading(false);
|
||||
// An aborted request no longer resolves to move `phase` off 'starting';
|
||||
// reset so the new mode's form is immediately usable (also clears a stale
|
||||
// 'error' phase). Only reachable while showInput is true.
|
||||
setPhase('input');
|
||||
};
|
||||
|
||||
// Use the browser's native directory picker (webkitdirectory doesn't give paths,
|
||||
// so we use a text input + a "Browse" button that opens a standard file input
|
||||
// to let users pick files from the folder — the path is typed manually since
|
||||
// browsers don't expose absolute paths for security reasons).
|
||||
// For local paths, the user types or pastes the absolute path.
|
||||
// Local-folder mode uploads the selected folder's files (the browser never
|
||||
// exposes an absolute path, so the old typed-path/browse approach couldn't
|
||||
// work — see handleFolderUpload). A typed server path is also still accepted.
|
||||
|
||||
const canSubmit =
|
||||
mode === 'github'
|
||||
@@ -228,6 +278,10 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
setValidationError(null);
|
||||
setPhase('starting');
|
||||
|
||||
// Staleness guard only (no wire abort): the POST is short-lived and
|
||||
// self-terminates, but its resolution must not drive state after a mode
|
||||
// switch / cancel / unmount invalidated this request.
|
||||
const controller = renewRequestController();
|
||||
try {
|
||||
const request =
|
||||
mode === 'github'
|
||||
@@ -236,8 +290,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
? { url: gitlabUrl.trim() }
|
||||
: { path: localPath.trim() };
|
||||
const { jobId } = await startAnalyze(request);
|
||||
jobIdRef.current = jobId;
|
||||
setPhase('analyzing');
|
||||
// Stale resolution: return without cancelling — URL jobIds may be
|
||||
// dedup-aliased to a job another session owns (see cancelStaleUploadJob).
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
const nameSource =
|
||||
mode === 'github'
|
||||
@@ -245,29 +300,84 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
: mode === 'gitlab'
|
||||
? gitlabUrl.trim()
|
||||
: localPath.trim();
|
||||
const controller = streamAnalyzeProgress(
|
||||
jobId,
|
||||
(p) => setProgress(p),
|
||||
(data) => {
|
||||
const name =
|
||||
data.repoName ??
|
||||
nameSource.split(/[/\\]/).filter(Boolean).at(-1) ??
|
||||
t('onboarding:repoAnalyzer.defaultRepoName');
|
||||
setCompletedRepoName(name);
|
||||
setPhase('done');
|
||||
sseControllerRef.current = null;
|
||||
completeTimerRef.current = setTimeout(() => {
|
||||
completeTimerRef.current = null;
|
||||
onComplete(name);
|
||||
}, 1200);
|
||||
},
|
||||
(errMsg) => {
|
||||
setValidationError(errMsg || t('errors:analysisFailed'));
|
||||
setPhase('error');
|
||||
},
|
||||
);
|
||||
sseControllerRef.current = controller;
|
||||
trackJob(jobId, nameSource);
|
||||
} catch (err) {
|
||||
// Unmount aborts the controller, so this also covers the unmounted case.
|
||||
if (controller.signal.aborted) return;
|
||||
setValidationError(err instanceof Error ? err.message : t('errors:startAnalysisFailed'));
|
||||
setPhase('error');
|
||||
}
|
||||
};
|
||||
|
||||
// Drive an already-created analysis job through the SSE progress stream to
|
||||
// completion. Shared by the path/URL analyze flow and the folder-upload flow.
|
||||
const trackJob = (jobId: string, fallbackNameSource: string | null) => {
|
||||
// Callers reach here only with a live (non-aborted) request controller, so
|
||||
// the component is mounted — unmount aborts the controller.
|
||||
jobIdRef.current = jobId;
|
||||
setPhase('analyzing');
|
||||
const controller = streamAnalyzeProgress(
|
||||
jobId,
|
||||
(p) => setProgress(p),
|
||||
(data) => {
|
||||
const name =
|
||||
data.repoName ??
|
||||
(fallbackNameSource
|
||||
? fallbackNameSource.split(/[/\\]/).filter(Boolean).at(-1)
|
||||
: undefined) ??
|
||||
t('onboarding:repoAnalyzer.defaultRepoName');
|
||||
setCompletedRepoName(name);
|
||||
setPhase('done');
|
||||
sseControllerRef.current = null;
|
||||
completeTimerRef.current = setTimeout(() => {
|
||||
completeTimerRef.current = null;
|
||||
onComplete(name);
|
||||
}, 1200);
|
||||
},
|
||||
(errMsg) => {
|
||||
setValidationError(errMsg || t('errors:analysisFailed'));
|
||||
setPhase('error');
|
||||
},
|
||||
);
|
||||
sseControllerRef.current = controller;
|
||||
};
|
||||
|
||||
// Upload a browser-selected folder (webkitdirectory) and start analysis. The
|
||||
// upload endpoint returns a jobId, which then joins the normal SSE flow.
|
||||
const handleFolderUpload = async (fileList: FileList) => {
|
||||
if (uploading || isLoading) return; // guard against a concurrent upload
|
||||
const { files, manifest, droppedCount } = filterRepoFiles(fileList);
|
||||
if (files.length === 0) {
|
||||
setValidationError(t('onboarding:repoAnalyzer.upload.empty'));
|
||||
return;
|
||||
}
|
||||
setValidationError(null);
|
||||
setUploadSummary({ count: files.length, dropped: droppedCount });
|
||||
setUploading(true);
|
||||
setPhase('starting');
|
||||
// The selected folder's name (manifest entries are `<folder>/<rest>`) is a
|
||||
// sensible fallback if the server's complete event omits repoName.
|
||||
const folderName = manifest[0]?.split('/')[0] ?? null;
|
||||
const controller = renewRequestController();
|
||||
try {
|
||||
const { jobId } = await uploadFolder(files, manifest, controller.signal);
|
||||
if (controller.signal.aborted) {
|
||||
// The abort raced the response: the server already created the job.
|
||||
// (Unmount aborts the controller, so this also covers unmounted.)
|
||||
cancelStaleUploadJob(jobId);
|
||||
return;
|
||||
}
|
||||
setUploading(false);
|
||||
trackJob(jobId, folderName);
|
||||
} catch (err) {
|
||||
// An abort surfaces in two shapes — BackendError('Request aborted')
|
||||
// when it lands during fetch, raw AbortError when it lands during the
|
||||
// response-body read — so branch on the closure controller's signal,
|
||||
// never on the error identity. In the second shape the server may have
|
||||
// already launched a job whose id we never learn; that orphan is bounded
|
||||
// by the server's job timeout and terminal-job TTL sweep.
|
||||
if (controller.signal.aborted) return;
|
||||
setUploading(false);
|
||||
setValidationError(err instanceof Error ? err.message : t('errors:startAnalysisFailed'));
|
||||
setPhase('error');
|
||||
}
|
||||
@@ -276,6 +386,10 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
const handleCancel = async () => {
|
||||
sseControllerRef.current?.abort();
|
||||
sseControllerRef.current = null;
|
||||
// Defensive: no UI path can reach handleCancel while a request is in
|
||||
// flight (the cancel affordance renders only at phase === 'analyzing'),
|
||||
// but invalidate it anyway so the guard topology has no holes.
|
||||
invalidateRequest();
|
||||
if (jobIdRef.current) {
|
||||
try {
|
||||
await cancelAnalyze(jobIdRef.current);
|
||||
@@ -284,6 +398,8 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
}
|
||||
setPhase('input');
|
||||
setProgress({ phase: 'queued', percent: 0, message: t('common:analyzePhases.queued') });
|
||||
setUploading(false);
|
||||
setUploadSummary(null);
|
||||
};
|
||||
|
||||
const isLoading = phase === 'starting';
|
||||
@@ -443,35 +559,51 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
|
||||
)}
|
||||
</div>
|
||||
{/* Native folder picker + Browse button — below the input */}
|
||||
{/* Upload a folder from your computer — no server path or mount needed.
|
||||
The browser can't expose an absolute path, so we upload the files. */}
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
// @ts-expect-error -- webkitdirectory is non-standard but widely supported
|
||||
webkitdirectory=""
|
||||
multiple
|
||||
className="hidden"
|
||||
data-testid="folder-upload-input"
|
||||
onChange={(e) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
const rel = files[0].webkitRelativePath;
|
||||
const folderName = rel.split('/')[0];
|
||||
if (folderName) {
|
||||
setLocalPath(folderName);
|
||||
setValidationError(null);
|
||||
}
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleFolderUpload(e.target.files);
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="upload-folder"
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-border-subtle bg-elevated px-3 py-2 text-xs font-medium text-text-secondary transition-all duration-150 hover:bg-hover hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
{t('onboarding:repoAnalyzer.browseForFolder')}
|
||||
{t('onboarding:repoAnalyzer.upload.button')}
|
||||
</button>
|
||||
{uploading && (
|
||||
<div role="status" aria-busy="true" data-testid="upload-progress" className="space-y-1">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-elevated">
|
||||
<div className="h-full w-1/3 animate-pulse rounded-full bg-accent" />
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t('onboarding:repoAnalyzer.upload.uploading')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{uploadSummary && !uploading && phase !== 'error' && (
|
||||
<p className="text-xs text-text-muted" data-testid="upload-summary">
|
||||
{t('onboarding:repoAnalyzer.upload.selected', {
|
||||
fileCount: uploadSummary.count,
|
||||
dropped: uploadSummary.dropped,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { filterRepoFiles, MAX_FILE_BYTES } from './upload-filter';
|
||||
|
||||
type FileLike = { name: string; size: number; webkitRelativePath?: string };
|
||||
|
||||
function f(webkitRelativePath: string, size = 10): FileLike {
|
||||
const name = webkitRelativePath.split('/').pop() ?? webkitRelativePath;
|
||||
return { name, size, webkitRelativePath };
|
||||
}
|
||||
|
||||
describe('filterRepoFiles', () => {
|
||||
it('keeps source files and builds an order-aligned manifest', () => {
|
||||
const input = [f('repo/src/index.ts', 100), f('repo/README.md', 50)];
|
||||
const r = filterRepoFiles(input);
|
||||
expect(r.files).toHaveLength(2);
|
||||
expect(r.manifest).toEqual(['repo/src/index.ts', 'repo/README.md']);
|
||||
expect(r.totalBytes).toBe(150);
|
||||
expect(r.droppedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('excludes .git / node_modules / build dirs anywhere in the path', () => {
|
||||
const input = [
|
||||
f('repo/.git/HEAD'),
|
||||
f('repo/node_modules/x/index.js'),
|
||||
f('repo/dist/bundle.js'),
|
||||
f('repo/src/app.ts'),
|
||||
f('repo/.gitnexus/meta.json'),
|
||||
];
|
||||
const r = filterRepoFiles(input);
|
||||
expect(r.manifest).toEqual(['repo/src/app.ts']);
|
||||
expect(r.droppedCount).toBe(4);
|
||||
});
|
||||
|
||||
it('drops files over the per-file size cap', () => {
|
||||
const input = [f('repo/big.bin', MAX_FILE_BYTES + 1), f('repo/small.ts', 10)];
|
||||
const r = filterRepoFiles(input);
|
||||
expect(r.manifest).toEqual(['repo/small.ts']);
|
||||
expect(r.droppedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to name when webkitRelativePath is absent', () => {
|
||||
const r = filterRepoFiles([{ name: 'lone.ts', size: 5 }]);
|
||||
expect(r.manifest).toEqual(['lone.ts']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Client-side pre-filter for a webkitdirectory folder upload.
|
||||
*
|
||||
* Drops VCS metadata, dependency/build directories, and oversized files before
|
||||
* upload — `.git` alone is often larger than the working tree — so payloads
|
||||
* stay small and the upload matches what the analyzer actually needs. Produces
|
||||
* an order-aligned `manifest` of webkitRelativePaths (the server keys on this,
|
||||
* not the multipart filename, which browsers rewrite).
|
||||
*/
|
||||
|
||||
/** Directory names excluded anywhere in a file's path. */
|
||||
export const EXCLUDED_DIRS = new Set([
|
||||
'.git',
|
||||
'.hg',
|
||||
'.svn',
|
||||
'node_modules',
|
||||
'vendor',
|
||||
'.venv',
|
||||
'__pycache__',
|
||||
'target',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.cache',
|
||||
'coverage',
|
||||
'.idea',
|
||||
'.gitnexus',
|
||||
]);
|
||||
|
||||
/** Per-file size cap; matches the server's per-file limit. */
|
||||
export const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
export interface FilterResult {
|
||||
files: File[];
|
||||
manifest: string[];
|
||||
droppedCount: number;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
type FileLike = Pick<File, 'name' | 'size'> & { webkitRelativePath?: string };
|
||||
|
||||
/**
|
||||
* Filter a webkitdirectory `FileList` (or array) into the files to upload plus
|
||||
* their relative-path manifest.
|
||||
*/
|
||||
export function filterRepoFiles(input: ArrayLike<FileLike>): FilterResult {
|
||||
const files: File[] = [];
|
||||
const manifest: string[] = [];
|
||||
let droppedCount = 0;
|
||||
let totalBytes = 0;
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const f = input[i];
|
||||
const rel =
|
||||
f.webkitRelativePath && f.webkitRelativePath.length > 0 ? f.webkitRelativePath : f.name;
|
||||
const segments = rel.split('/');
|
||||
if (segments.some((s) => EXCLUDED_DIRS.has(s))) {
|
||||
droppedCount++;
|
||||
continue;
|
||||
}
|
||||
if (f.size > MAX_FILE_BYTES) {
|
||||
droppedCount++;
|
||||
continue;
|
||||
}
|
||||
files.push(f as File);
|
||||
manifest.push(rel);
|
||||
totalBytes += f.size;
|
||||
}
|
||||
|
||||
return { files, manifest, droppedCount, totalBytes };
|
||||
}
|
||||
@@ -61,7 +61,12 @@
|
||||
"gitlabRepositoryUrl": "GitLab Repository URL",
|
||||
"gitlabSupported": "Supports GitLab.com and self-hosted GitLab instances.",
|
||||
"localFolderPath": "Local Folder Path",
|
||||
"browseForFolder": "Browse for folder",
|
||||
"hideBackground": "Hide (analysis continues in background)"
|
||||
"hideBackground": "Hide (analysis continues in background)",
|
||||
"upload": {
|
||||
"button": "Upload a folder",
|
||||
"uploading": "Uploading…",
|
||||
"selected": "{{fileCount}} files ready ({{dropped}} skipped: .git, node_modules, build output)",
|
||||
"empty": "No analyzable files found in that folder."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,12 @@
|
||||
"gitlabRepositoryUrl": "GitLab 仓库 URL",
|
||||
"gitlabSupported": "支持 GitLab.com 和自托管 GitLab 实例。",
|
||||
"localFolderPath": "本地文件夹路径",
|
||||
"browseForFolder": "浏览文件夹",
|
||||
"hideBackground": "隐藏(分析继续在后台进行)"
|
||||
"hideBackground": "隐藏(分析继续在后台进行)",
|
||||
"upload": {
|
||||
"button": "上传文件夹",
|
||||
"uploading": "上传中…",
|
||||
"selected": "已准备 {{fileCount}} 个文件(已跳过 {{dropped}} 个:.git、node_modules、构建产物)",
|
||||
"empty": "该文件夹中未找到可分析的文件。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,12 +283,11 @@ const fetchWithTimeout = async (
|
||||
): Promise<Response> => {
|
||||
// Merge the external caller signal (if any) with an
|
||||
// `AbortSignal.timeout()` so a timer-fired abort produces a
|
||||
// `DOMException` with `name === 'TimeoutError'` — which
|
||||
// `resilientFetch` correctly classifies as terminal-network (no
|
||||
// retry, no breaker hit). A manual `AbortController.abort()` would
|
||||
// produce `name === 'AbortError'` and route through the
|
||||
// retryable-network branch, which mis-penalizes the breaker for
|
||||
// user-side network slowness.
|
||||
// `DOMException` with `name === 'TimeoutError'`. Both shapes are
|
||||
// breaker-safe: `resilientFetch` classifies TimeoutError AND a manual
|
||||
// `AbortController.abort()`'s AbortError as terminal-network (no
|
||||
// retry, breaker-neutral via recordNeutral), so caller-driven
|
||||
// cancellation never penalizes the breaker.
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
const externalSignal = init.signal;
|
||||
const signal = externalSignal ? AbortSignal.any([timeoutSignal, externalSignal]) : timeoutSignal;
|
||||
@@ -755,6 +754,35 @@ export const fetchClusterDetail = async (repo: string, name: string): Promise<un
|
||||
return response.json();
|
||||
};
|
||||
|
||||
// ── Upload API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Upload a folder (selected via `<input webkitdirectory>`) and start analysis.
|
||||
* Sends the file blobs plus a JSON `manifest` of their relative paths — the
|
||||
* multipart filename can't carry the path (browsers strip separators), so the
|
||||
* manifest is the source of truth. Routed through fetchWithTimeout (the shared,
|
||||
* origin-validated request path) rather than a raw XHR; returns the analysis
|
||||
* jobId, which the caller drives through the normal SSE flow.
|
||||
*/
|
||||
export const uploadFolder = async (
|
||||
files: File[],
|
||||
manifest: string[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ jobId: string; status: string }> => {
|
||||
const form = new FormData();
|
||||
// Manifest MUST precede the file parts (the server enforces this).
|
||||
form.append('manifest', JSON.stringify(manifest));
|
||||
for (const f of files) form.append('files', f);
|
||||
|
||||
const response = await fetchWithTimeout(
|
||||
`${_backendUrl}/api/analyze/upload`,
|
||||
{ method: 'POST', body: form, signal },
|
||||
5 * 60_000, // up to 5 min for large repos
|
||||
);
|
||||
await assertOk(response);
|
||||
return response.json() as Promise<{ jobId: string; status: string }>;
|
||||
};
|
||||
|
||||
// ── Analyze API ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Start a server-side analysis job. */
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Stale-request guards in RepoAnalyzer (PR #1850 review 4470339833).
|
||||
*
|
||||
* An analyze request (folder upload or URL analyze) that is still in flight
|
||||
* when the user switches modes, cancels, or unmounts must not drive state
|
||||
* when it later settles: no SSE stream, no phase/error flip — and a
|
||||
* stale-but-created server job gets a fire-and-forget cancel so the single
|
||||
* analyze slot is freed.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { RepoAnalyzer } from '../../src/components/RepoAnalyzer';
|
||||
import { i18nReady } from '../../src/i18n';
|
||||
import {
|
||||
cancelAnalyze,
|
||||
startAnalyze,
|
||||
streamAnalyzeProgress,
|
||||
uploadFolder,
|
||||
} from '../../src/services/backend-client';
|
||||
|
||||
vi.mock('../../src/services/backend-client', () => ({
|
||||
startAnalyze: vi.fn(),
|
||||
cancelAnalyze: vi.fn(),
|
||||
streamAnalyzeProgress: vi.fn(),
|
||||
uploadFolder: vi.fn(),
|
||||
}));
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (err: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const JOB = { jobId: 'job-1', status: 'queued' };
|
||||
|
||||
/** Gate uploadFolder on a deferred promise and expose the signal it received. */
|
||||
function mockUploadWith(d: { promise: Promise<typeof JOB> }) {
|
||||
let captured: AbortSignal | undefined;
|
||||
vi.mocked(uploadFolder).mockImplementation((_files, _manifest, signal) => {
|
||||
captured = signal;
|
||||
return d.promise;
|
||||
});
|
||||
return { signal: () => captured };
|
||||
}
|
||||
|
||||
/** Render, switch to Local Folder mode, and fire a folder selection. */
|
||||
function startUpload() {
|
||||
const view = render(<RepoAnalyzer variant="onboarding" onComplete={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
|
||||
fireEvent.change(screen.getByTestId('folder-upload-input'), {
|
||||
target: { files: [new File(['x'], 'a.ts')] },
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await i18nReady;
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(cancelAnalyze).mockResolvedValue(undefined as never);
|
||||
vi.mocked(streamAnalyzeProgress).mockImplementation(() => new AbortController());
|
||||
});
|
||||
|
||||
describe('folder upload', () => {
|
||||
it('a mode switch mid-upload makes the resolution inert and cancels the job', async () => {
|
||||
const d = deferred<typeof JOB>();
|
||||
const upload = mockUploadWith(d);
|
||||
|
||||
startUpload();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'GitHub URL' }));
|
||||
|
||||
// The wire abort happened at mode-switch time, not at resolution time.
|
||||
expect(upload.signal()?.aborted).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
d.resolve(JOB);
|
||||
});
|
||||
|
||||
expect(streamAnalyzeProgress).not.toHaveBeenCalled();
|
||||
expect(cancelAnalyze).toHaveBeenCalledWith('job-1');
|
||||
// The GitHub form is clean and submittable (phase back to 'input').
|
||||
expect(screen.getByRole('textbox')).toBeEnabled();
|
||||
expect(screen.queryByTestId('upload-progress')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['BackendError shape', new Error('Request aborted')],
|
||||
['raw AbortError shape', new DOMException('The operation was aborted.', 'AbortError')],
|
||||
])('an aborted rejection is silent — %s', async (_label, err) => {
|
||||
const d = deferred<typeof JOB>();
|
||||
vi.mocked(uploadFolder).mockReturnValue(d.promise);
|
||||
|
||||
startUpload();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'GitHub URL' }));
|
||||
await act(async () => {
|
||||
d.reject(err);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Request aborted')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('The operation was aborted.')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('a same-tab click does not abort the in-flight upload', async () => {
|
||||
const d = deferred<typeof JOB>();
|
||||
const upload = mockUploadWith(d);
|
||||
|
||||
startUpload();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
|
||||
|
||||
expect(upload.signal()?.aborted).toBe(false);
|
||||
await act(async () => {
|
||||
d.resolve(JOB);
|
||||
});
|
||||
|
||||
expect(streamAnalyzeProgress).toHaveBeenCalledTimes(1);
|
||||
expect(cancelAnalyze).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('an unmount mid-upload makes the resolution inert', async () => {
|
||||
const d = deferred<typeof JOB>();
|
||||
const upload = mockUploadWith(d);
|
||||
|
||||
const { unmount } = startUpload();
|
||||
unmount();
|
||||
|
||||
expect(upload.signal()?.aborted).toBe(true);
|
||||
await act(async () => {
|
||||
d.resolve(JOB);
|
||||
});
|
||||
|
||||
expect(streamAnalyzeProgress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the happy path still tracks the job', async () => {
|
||||
const d = deferred<typeof JOB>();
|
||||
vi.mocked(uploadFolder).mockReturnValue(d.promise);
|
||||
|
||||
startUpload();
|
||||
await act(async () => {
|
||||
d.resolve(JOB);
|
||||
});
|
||||
|
||||
expect(streamAnalyzeProgress).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(streamAnalyzeProgress).mock.calls[0][0]).toBe('job-1');
|
||||
expect(cancelAnalyze).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a genuine error still surfaces', async () => {
|
||||
const d = deferred<typeof JOB>();
|
||||
vi.mocked(uploadFolder).mockReturnValue(d.promise);
|
||||
|
||||
startUpload();
|
||||
await act(async () => {
|
||||
d.reject(new Error('upload exploded'));
|
||||
});
|
||||
|
||||
expect(screen.getByText('upload exploded')).toBeInTheDocument();
|
||||
expect(streamAnalyzeProgress).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL analyze', () => {
|
||||
function startGithubAnalyze() {
|
||||
render(<RepoAnalyzer variant="onboarding" onComplete={vi.fn()} />);
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
target: { value: 'https://github.com/owner/repo' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Analyze Repository/ }));
|
||||
}
|
||||
|
||||
it('a mode switch mid-analyze makes the resolution inert without cancelling', async () => {
|
||||
const d = deferred<typeof JOB>();
|
||||
vi.mocked(startAnalyze).mockReturnValue(d.promise);
|
||||
|
||||
startGithubAnalyze();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
|
||||
await act(async () => {
|
||||
d.resolve({ jobId: 'job-2', status: 'queued' });
|
||||
});
|
||||
|
||||
expect(streamAnalyzeProgress).not.toHaveBeenCalled();
|
||||
// No cancel on the URL path: the jobId may be dedup-aliased to a job
|
||||
// another session owns, so cancelling could kill a live analysis.
|
||||
expect(cancelAnalyze).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a stale rejection is silent', async () => {
|
||||
const d = deferred<typeof JOB>();
|
||||
vi.mocked(startAnalyze).mockReturnValue(d.promise);
|
||||
|
||||
startGithubAnalyze();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
|
||||
await act(async () => {
|
||||
d.reject(new Error('analyze exploded'));
|
||||
});
|
||||
|
||||
expect(screen.queryByText('analyze exploded')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('upload-folder')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('the happy path still tracks the job', async () => {
|
||||
const d = deferred<typeof JOB>();
|
||||
vi.mocked(startAnalyze).mockReturnValue(d.promise);
|
||||
|
||||
startGithubAnalyze();
|
||||
await act(async () => {
|
||||
d.resolve({ jobId: 'job-3', status: 'queued' });
|
||||
});
|
||||
|
||||
expect(streamAnalyzeProgress).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(streamAnalyzeProgress).mock.calls[0][0]).toBe('job-3');
|
||||
expect(cancelAnalyze).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -436,6 +436,26 @@ After scope resolution, analyze prunes inert block-local value symbols (a functi
|
||||
|
||||
Programmatic callers can pass `keepLocalValueSymbols: true` in `PipelineOptions` instead of setting the env var.
|
||||
|
||||
### Hook augmentation/notifications are silently skipped
|
||||
|
||||
The Claude Code / Antigravity hooks intentionally stay **silent** on normal skip
|
||||
paths so strict hook runners (e.g. Codex `PreToolUse`) never see unexpected
|
||||
output. A search may not be augmented — or a stale-index reminder may not appear
|
||||
on stderr — when the GitNexus MCP server owns the repo DB, when the DB-lock probe
|
||||
times out and fails closed, or when the index is already current.
|
||||
|
||||
To see why a hook skipped, set `GITNEXUS_DEBUG=1` and re-run the action — the hook
|
||||
writes the reason (e.g. `[GitNexus] augment skipped: MCP server owns DB`) and the
|
||||
stale-index hint to its stderr:
|
||||
|
||||
```bash
|
||||
GITNEXUS_DEBUG=1 <your command> # surfaces hook skip/diagnostic reasons on stderr
|
||||
```
|
||||
|
||||
Only `GITNEXUS_DEBUG=1` and `GITNEXUS_DEBUG=true` enable diagnostics; every other
|
||||
value (including `0` and `false`) is treated as off. Diagnostics go to stderr
|
||||
only — the hook's structured stdout (the JSON the agent consumes) is unaffected.
|
||||
|
||||
## Privacy
|
||||
|
||||
- All processing happens locally on your machine
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"straight-line": {
|
||||
"fingerprint": "792229965a726d2c6b527f9ee65440a2b3023839ee71cb51522fc30e2f2cb454",
|
||||
"scaling_budget": 1.5,
|
||||
"disk_bytes_budget": 1.2,
|
||||
"heap_budget": 1.3,
|
||||
"rd_scaling_budget": 2.0,
|
||||
"disk_bytes_large_max": 1309481,
|
||||
"_note": "#2081 M1 / #2082 M2: ONE function, N coalescing statements (extendBlock text accumulation + per-statement fact harvest). Runs at 2000->8000. M2 REWROTE the old 'output is constant 4 blocks' note: statement facts make disk/heap LINEAR in N (a free gate on the harvest payload); TIME still guards the concat path (array-join ~1.0; a genuine O(n^2) re-join accumulation is ~3.8). M2 adds rd_scaling_budget (measured ~0.74) and disk_bytes_large_max -- an ABSOLUTE ceiling ~1.35x the measured indexed-encoding bytes (969,986 at N=8000, ~121 B/stmt); a named-record encoding regression (~4x facts bytes) blows it. Re-baseline the fingerprint only on an intentional CFG/harvest-shape change (the canon now includes statements+bindings)."
|
||||
},
|
||||
"many-functions": {
|
||||
"fingerprint": "f3bcc5e6ef4cf58aefe4e7d801a8fea0215494b9688833e501c2afc6df029c1b",
|
||||
"scaling_budget": 1.5,
|
||||
"disk_bytes_budget": 1.2,
|
||||
"heap_budget": 1.3,
|
||||
"rd_scaling_budget": 2.0,
|
||||
"_note": "#2081 M1 / #2082 M2: N small branchy functions (collect walk + per-function build + per-function solve). Time ~1.0, disk ~1.01, heap ~1.0, rd ~0.86 (solver is per-function; N functions scale linearly)."
|
||||
},
|
||||
"branchy": {
|
||||
"fingerprint": "5b5886521ab21604df8f78af98c8c28a6be8e64c24f3d67b165c2d96ba2a3d52",
|
||||
"scaling_budget": 1.8,
|
||||
"disk_bytes_budget": 1.2,
|
||||
"heap_budget": 1.3,
|
||||
"rd_scaling_budget": 2.0,
|
||||
"_note": "#2081 M1 / #2082 M2: ONE function, N sequential ifs (block/edge growth in one CFG). Time ~1.1-1.25 (noisiest scenario; budget 1.8 absorbs noise, catches ~4.0 quadratic), disk ~1.03, heap ~1.0, rd ~0.7."
|
||||
},
|
||||
"dense-bindings": {
|
||||
"fingerprint": "e4d7eb3c7e8b3772423af25cef391e0e6b68067b554819e81b543439a487403f",
|
||||
"scaling_budget": 1.8,
|
||||
"disk_bytes_budget": 1.2,
|
||||
"heap_budget": 1.3,
|
||||
"rd_scaling_budget": 10.0,
|
||||
"_note": "#2082 M2: N bindings live across ~N blocks in one loop -- bindings x blocks scale JOINTLY (the solver-lattice stressor). The overlay design measures rd ~5.2 normalized: the OUT spine copy on genning blocks is O(V) per block, which is quadratic when V scales with B (bounded in prod by maxFunctionLines; real functions have V~10-40). Budget 10 deliberately tolerates that known shape and exists to catch the repo's recurring per-item-rescan class (a per-use scan over all defs is O(n^3) here, ratio >=16). If rd drops well below 5, tighten."
|
||||
},
|
||||
"fact-fanout": {
|
||||
"fingerprint": "488e63e072d514a9229e21872615e32c7b099ccbd65ec8c045ba517568fd3e5d",
|
||||
"scaling_budget": 1.8,
|
||||
"disk_bytes_budget": 1.2,
|
||||
"heap_budget": 1.3,
|
||||
"rd_scaling_budget": 3.0,
|
||||
"facts_large_max": 16000,
|
||||
"_note": "#2082 M2: N switch-arm defs of one variable + N later uses -- facts are O(defs x uses) BY SPEC, so the gate is BOUNDEDNESS, not linearity: with the production fact limit engaged (DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION=16000) the materialized fact count stays pinned at the limit as N grows (facts_large_max), and rd time stays bounded (measured ~1.4). Losing the maxFacts early-stop shows as facts_large exploding quadratically."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Build-free CFG-construction measurement harness (#2081 M1).
|
||||
*
|
||||
* Times `collectFunctionCfgs` (the per-function CFG builder the parse worker
|
||||
* runs on a `--pdg` run) on synthetic TS sources at two sizes, in three
|
||||
* scenarios that each stress a distinct cost dimension:
|
||||
* - `straight-line`: ONE function with N coalescing statements — stresses the
|
||||
* basic-block text accumulation (the `extendBlock` path);
|
||||
* - `many-functions`: N small branchy functions — stresses the collect walk +
|
||||
* per-function build + the tree-sitter `namedChildren` accesses;
|
||||
* - `branchy`: ONE function with N sequential `if`s — stresses block/edge
|
||||
* growth within a single CFG.
|
||||
*
|
||||
* For each scenario it reports three scaling ratios at small→large
|
||||
* (`(metric_large/metric_small)/(N_large/N_small)`: ~1.0 is linear, ~4.0 is the
|
||||
* O(n²) shape the M1 perf review flagged for `extendBlock`'s concat chain):
|
||||
* - TIME — wall-clock of `collectFunctionCfgs` (median of reps);
|
||||
* - DISK — utf8 byte size of the serialized `cfgSideChannel` (what a `--pdg`
|
||||
* run writes onto every ParsedFile shard);
|
||||
* - MEMORY — retained JS heap of the `cfgSideChannel` payload, by the
|
||||
* release-delta method (heap held minus heap after dropping it). Requires
|
||||
* `node --expose-gc`; without it the heap metric is null and its gate skips.
|
||||
* It also computes an order-independent sha256 fingerprint over the emitted
|
||||
* blocks/edges of a fixed-size source — the correctness gate that a structural
|
||||
* speedup must leave behavior-identical.
|
||||
*
|
||||
* Build-free: imports the `.ts` hotpaths through tsx
|
||||
* (`node --expose-gc --import tsx bench/cfg/measure.mjs`). Parsing happens ONCE
|
||||
* per size and the tree is reused across reps so the time measurement isolates
|
||||
* CFG build cost, not tree-sitter parse time. `maxFunctionLines` is 0 (no cap)
|
||||
* here on purpose — the bench measures the algorithm; the production default cap
|
||||
* is a separate safety net (and would otherwise skip the large straight-line fn).
|
||||
*
|
||||
* Without args: prints one JSON object per scenario.
|
||||
* With `--check`: asserts each scenario's fingerprint == its committed baseline
|
||||
* (baselines.json) AND each of the time / disk / heap ratios is below its
|
||||
* recorded budget; exits non-zero on any drift/regression.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import Parser from 'tree-sitter';
|
||||
import TypeScript from 'tree-sitter-typescript';
|
||||
import { collectFunctionCfgs } from '../../src/core/ingestion/cfg/collect.ts';
|
||||
import { computeReachingDefs } from '../../src/core/ingestion/cfg/reaching-defs.ts';
|
||||
import { DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION } from '../../src/core/ingestion/cfg/emit.ts';
|
||||
import { createTypeScriptCfgVisitor } from '../../src/core/ingestion/cfg/visitors/typescript.ts';
|
||||
import { getTreeSitterBufferSize } from '../../src/core/ingestion/constants.ts';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
|
||||
|
||||
const visitor = createTypeScriptCfgVisitor();
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(TypeScript.typescript);
|
||||
// Large synthetic sources exceed tree-sitter's default read buffer; size it
|
||||
// from the content exactly as the parse worker does (getTreeSitterBufferSize).
|
||||
const parse = (src) => parser.parse(src, undefined, { bufferSize: getTreeSitterBufferSize(src) });
|
||||
|
||||
// ---- synthetic generators (one cost dimension each) ----
|
||||
|
||||
const SCENARIOS = [
|
||||
{
|
||||
name: 'straight-line',
|
||||
// One function, N coalescing simple statements → all fold into one basic
|
||||
// block whose text is accumulated statement-by-statement (extendBlock).
|
||||
// Uses LARGER sizes than the other scenarios: this scenario's only cost
|
||||
// dimension is text accumulation (output size is constant — 4 blocks at any
|
||||
// N — so the disk/heap ratios can't see it), so the TIME ratio is the sole
|
||||
// guard against an extendBlock O(n²)-concat re-regression. At small N a
|
||||
// quadratic is masked by V8 cons-strings + the linear tree-walk and slips
|
||||
// under the budget; these larger sizes make a real quadratic separate
|
||||
// cleanly (verified: a `+=` regression here exceeds the budget, the
|
||||
// array-join impl stays ~1).
|
||||
small: 2000,
|
||||
large: 8000,
|
||||
gen: (n) => {
|
||||
let s = 'function f() {\n';
|
||||
for (let i = 0; i < n; i++) s += ` let v${i} = ${i} + 1;\n`;
|
||||
return s + ' return v0;\n}\n';
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'many-functions',
|
||||
// N independent small functions with a branch + return → stresses the
|
||||
// tree walk in collectFunctionCfgs and the per-function build.
|
||||
gen: (n) => {
|
||||
let s = '';
|
||||
for (let i = 0; i < n; i++) {
|
||||
s += `function f${i}(x: number) { if (x > ${i}) { a(); } else { b(); } return x + ${i}; }\n`;
|
||||
}
|
||||
return s;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'branchy',
|
||||
// One function, N sequential `if`s → N condition blocks + 2N+ edges in a
|
||||
// single CFG; stresses block/edge growth and namedChildren on the body.
|
||||
gen: (n) => {
|
||||
let s = 'function f(x: number) {\n';
|
||||
for (let i = 0; i < n; i++) s += ` if (x > ${i}) { s${i}(); }\n`;
|
||||
return s + '}\n';
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'dense-bindings',
|
||||
// #2082 M2: N bindings live across ~N blocks inside one loop — bindings ×
|
||||
// blocks scale JOINTLY, the discriminator for solver-lattice quadratics.
|
||||
// The overlay design (KTD2: sets shared by reference, OUT spine-copied
|
||||
// only on gen) is expected to scale ~linearly-with-a-spine-copy here
|
||||
// (normalized ratio low single digits); the regression this scenario
|
||||
// exists to catch is the repo's recurring per-item-rescan shape — a
|
||||
// per-use scan over all defs (O(n³) here) blows the ratio past ~16.
|
||||
// rd time is the gated metric (rd_scaling_budget).
|
||||
rdMaxFacts: 0, // measure the algorithm, not the cap
|
||||
gen: (n) => {
|
||||
let s = 'function f(c: number) {\n';
|
||||
for (let i = 0; i < n; i++) s += ` let v${i} = ${i};\n`;
|
||||
s += ' while (c > 0) {\n';
|
||||
for (let i = 0; i < n; i++) s += ` if (c > ${i}) { v${i} = v${(i + 1) % n} + 1; }\n`;
|
||||
return s + ' c = c - 1;\n }\n return v0;\n}\n';
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'fact-fanout',
|
||||
// #2082 M2: N parallel case-arm defs of one variable + N later uses —
|
||||
// facts are O(defs×uses) BY SPEC, so a linearity ratio gate is the wrong
|
||||
// shape. The gate here is BOUNDEDNESS: with the production fact limit
|
||||
// engaged, the materialized fact count stays FLAT (== limit) as N grows
|
||||
// past it (facts_large_max), and rd time stays bounded. An unbounded
|
||||
// materialization regression (losing the maxFacts early-stop) shows as
|
||||
// facts_large exploding quadratically.
|
||||
rdMaxFacts: DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
|
||||
gen: (n) => {
|
||||
let s = 'function f(c: number) {\n let x = 0;\n switch (c) {\n';
|
||||
for (let i = 0; i < n; i++) s += ` case ${i}: x = ${i}; break;\n`;
|
||||
s += ' }\n';
|
||||
for (let i = 0; i < n; i++) s += ` u${i}(x);\n`;
|
||||
return s + '}\n';
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const SMALL = 500;
|
||||
const LARGE = 2000; // 4× — O(n) ⇒ ratio ~1, O(n²) ⇒ ratio ~4
|
||||
const REPS = 15; // median over more reps → stabler time signal at small absolute ms
|
||||
const FP_SIZE = 15; // fixed size for the behavior fingerprint
|
||||
const NO_CAP = 0; // measure the algorithm, not the production safety cap
|
||||
|
||||
// ---- timing ----
|
||||
|
||||
function median(xs) {
|
||||
const s = [...xs].sort((a, b) => a - b);
|
||||
const m = Math.floor(s.length / 2);
|
||||
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
||||
}
|
||||
|
||||
function measureCollect(src, file, reps) {
|
||||
const root = parse(src).rootNode; // parse ONCE; reuse across reps
|
||||
collectFunctionCfgs(root, visitor, `warmup-${file}`, NO_CAP); // warm JIT (uncounted)
|
||||
const samples = [];
|
||||
let out;
|
||||
for (let i = 0; i < reps; i++) {
|
||||
const start = process.hrtime.bigint();
|
||||
out = collectFunctionCfgs(root, visitor, file, NO_CAP);
|
||||
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
|
||||
}
|
||||
return {
|
||||
ms: median(samples),
|
||||
cfgs: out.cfgs,
|
||||
blockCount: out.cfgs.reduce((a, c) => a + c.blocks.length, 0),
|
||||
// DISK growth: utf8 byte size of the serialized cfgSideChannel — exactly
|
||||
// what a --pdg run writes onto every ParsedFile shard in the durable store
|
||||
// + parse cache (the field is plain JSON, so this is the on-disk delta).
|
||||
// Should scale linearly with source covered; a super-linear ratio means the
|
||||
// CFG duplicates text and bloats warm-cache shards at scale.
|
||||
diskBytes: Buffer.byteLength(JSON.stringify(out.cfgs), 'utf8'),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- reaching-defs solve cost (#2082 M2) ----
|
||||
|
||||
// Times computeReachingDefs over a scenario's collected CFGs (the exact work
|
||||
// the scope-resolution emit loop adds per file on a --pdg run). `maxFacts`
|
||||
// mirrors the per-scenario production posture: 0 (unlimited) measures the
|
||||
// algorithm; the production default exercises the boundedness contract.
|
||||
function measureReachingDefs(cfgs, reps, maxFacts) {
|
||||
for (const c of cfgs) computeReachingDefs(c, { maxFacts }); // warm JIT
|
||||
const samples = [];
|
||||
let facts = 0;
|
||||
for (let i = 0; i < reps; i++) {
|
||||
const start = process.hrtime.bigint();
|
||||
facts = 0;
|
||||
for (const c of cfgs) facts += computeReachingDefs(c, { maxFacts }).facts.length;
|
||||
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
|
||||
}
|
||||
return { ms: median(samples), facts };
|
||||
}
|
||||
|
||||
// ---- memory growth: retained heap of the cfgSideChannel payload ----
|
||||
|
||||
// Needs `node --expose-gc` to force collection for a clean delta; without it the
|
||||
// heap metric is reported as null and its --check gate is skipped (so a local
|
||||
// run without the flag still works).
|
||||
const GC = typeof global.gc === 'function' ? () => (global.gc(), global.gc()) : null;
|
||||
|
||||
function retainedHeapBytes(src, file) {
|
||||
if (!GC) return null;
|
||||
// Retained-size-by-RELEASE: measure the heap with the CFGs held, drop them,
|
||||
// GC, measure again. The drop isolates exactly the JS heap the cfgSideChannel
|
||||
// payload retains (the extra RAM a --pdg run carries per file until the shard
|
||||
// is flushed) — robust to pre-existing garbage, which is constant across both
|
||||
// measurements. The parse tree is a temporary (its native memory isn't on the
|
||||
// JS heap); block text strings are fresh copies, so they count here.
|
||||
let cfgs = collectFunctionCfgs(parse(src).rootNode, visitor, file, NO_CAP).cfgs;
|
||||
GC();
|
||||
const withCfgs = process.memoryUsage().heapUsed;
|
||||
if (cfgs.length < 0) throw new Error('unreachable'); // keep cfgs live past withCfgs
|
||||
cfgs = null;
|
||||
GC();
|
||||
const withoutCfgs = process.memoryUsage().heapUsed;
|
||||
return Math.max(0, withCfgs - withoutCfgs);
|
||||
}
|
||||
|
||||
// ---- correctness fingerprint (order-independent over blocks + edges) ----
|
||||
|
||||
function canonicalizeCfg(cfg) {
|
||||
const blocks = cfg.blocks
|
||||
.map(
|
||||
(b) =>
|
||||
`B|${b.index}|${b.startLine}-${b.endLine}|${b.kind}|${b.text}|` +
|
||||
// #2082 M2: statement facts join the canon so harvest drift (lost
|
||||
// defs/uses, changed binding resolution) trips the fingerprint gate.
|
||||
JSON.stringify(b.statements ?? null),
|
||||
)
|
||||
.sort();
|
||||
const edges = cfg.edges.map((e) => `E|${e.from}->${e.to}|${e.kind}`).sort();
|
||||
const bindings = JSON.stringify(cfg.bindings ?? null);
|
||||
return `${cfg.functionStartLine}:${cfg.functionStartColumn}\n${bindings}\n${blocks.join('\n')}\n${edges.join('\n')}`;
|
||||
}
|
||||
|
||||
function fingerprint(scenario) {
|
||||
const out = collectFunctionCfgs(parse(scenario.gen(FP_SIZE)).rootNode, visitor, 'fp.ts', NO_CAP);
|
||||
const canon = out.cfgs.map(canonicalizeCfg).sort().join('\n====\n');
|
||||
return {
|
||||
fingerprint: crypto.createHash('sha256').update(canon).digest('hex'),
|
||||
fp_cfgs: out.cfgs.length,
|
||||
fp_blocks: out.cfgs.reduce((a, c) => a + c.blocks.length, 0),
|
||||
fp_edges: out.cfgs.reduce((a, c) => a + c.edges.length, 0),
|
||||
};
|
||||
}
|
||||
|
||||
function measureScenario(scenario) {
|
||||
// Per-scenario sizes (straight-line needs larger N to separate a concat
|
||||
// quadratic from noise — see its comment); the rest default to the globals.
|
||||
const nSmall = scenario.small ?? SMALL;
|
||||
const nLarge = scenario.large ?? LARGE;
|
||||
const small = measureCollect(scenario.gen(nSmall), `${scenario.name}.ts`, REPS);
|
||||
const large = measureCollect(scenario.gen(nLarge), `${scenario.name}.ts`, REPS);
|
||||
const sizeRatio = nLarge / nSmall;
|
||||
const scalingRatio = small.ms > 0 ? large.ms / small.ms / sizeRatio : 0;
|
||||
const diskRatio = small.diskBytes > 0 ? large.diskBytes / small.diskBytes / sizeRatio : 0;
|
||||
|
||||
// Memory growth (only when --expose-gc gave us a forced GC).
|
||||
const heapSmall = retainedHeapBytes(scenario.gen(nSmall), `${scenario.name}.ts`);
|
||||
const heapLarge = retainedHeapBytes(scenario.gen(nLarge), `${scenario.name}.ts`);
|
||||
const heapRatio =
|
||||
heapSmall !== null && heapLarge !== null && heapSmall > 0
|
||||
? heapLarge / heapSmall / sizeRatio
|
||||
: null;
|
||||
|
||||
// #2082 M2: reaching-defs solve cost over the same CFGs.
|
||||
const rdMaxFacts = scenario.rdMaxFacts ?? 0;
|
||||
const rdSmall = measureReachingDefs(small.cfgs, REPS, rdMaxFacts);
|
||||
const rdLarge = measureReachingDefs(large.cfgs, REPS, rdMaxFacts);
|
||||
// Clamp the denominator: a 0.000ms small-N median would otherwise yield
|
||||
// ratio 0 and the gate would self-disable exactly when the solver is fast.
|
||||
const rdRatio = rdLarge.ms / Math.max(rdSmall.ms, 0.001) / sizeRatio;
|
||||
|
||||
return {
|
||||
scenario: scenario.name,
|
||||
elapsed_ms_small: Number(small.ms.toFixed(3)),
|
||||
elapsed_ms_large: Number(large.ms.toFixed(3)),
|
||||
scaling_ratio: Number(scalingRatio.toFixed(3)),
|
||||
disk_bytes_small: small.diskBytes,
|
||||
disk_bytes_large: large.diskBytes,
|
||||
disk_bytes_ratio: Number(diskRatio.toFixed(3)),
|
||||
heap_bytes_small: heapSmall,
|
||||
heap_bytes_large: heapLarge,
|
||||
heap_ratio: heapRatio === null ? null : Number(heapRatio.toFixed(3)),
|
||||
blocks_small: small.blockCount,
|
||||
blocks_large: large.blockCount,
|
||||
rd_ms_small: Number(rdSmall.ms.toFixed(3)),
|
||||
rd_ms_large: Number(rdLarge.ms.toFixed(3)),
|
||||
rd_scaling_ratio: Number(rdRatio.toFixed(3)),
|
||||
facts_small: rdSmall.facts,
|
||||
facts_large: rdLarge.facts,
|
||||
...fingerprint(scenario),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- run ----
|
||||
|
||||
const CHECK = process.argv.includes('--check');
|
||||
|
||||
// The retained-heap budget is a primary regression detector, but it can only be
|
||||
// measured with a forced GC. Rather than let `--check` silently PASS with the
|
||||
// heap gate skipped (a green no-op if someone drops --expose-gc), fail loudly.
|
||||
if (CHECK && !GC) {
|
||||
process.stderr.write(
|
||||
'[cfg --check] FAIL: retained-heap gate requires --expose-gc. ' +
|
||||
'Run: node --expose-gc --import tsx bench/cfg/measure.mjs --check\n',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = SCENARIOS.map(measureScenario);
|
||||
|
||||
if (!CHECK) {
|
||||
for (const r of results) process.stdout.write(JSON.stringify(r) + '\n');
|
||||
} else {
|
||||
const baselines = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'));
|
||||
const failures = [];
|
||||
for (const r of results) {
|
||||
const base = baselines[r.scenario];
|
||||
if (base === undefined) {
|
||||
failures.push(`${r.scenario}: no baseline recorded`);
|
||||
continue;
|
||||
}
|
||||
if (r.fingerprint !== base.fingerprint) {
|
||||
failures.push(
|
||||
`${r.scenario}: CFG fingerprint drift (got ${r.fingerprint}, expected ${base.fingerprint})`,
|
||||
);
|
||||
}
|
||||
if (r.scaling_ratio >= base.scaling_budget) {
|
||||
failures.push(
|
||||
`${r.scenario}: scaling ratio ${r.scaling_ratio} >= budget ${base.scaling_budget} ` +
|
||||
`(${SMALL}->${LARGE} stmts/fns, ms ${r.elapsed_ms_small}->${r.elapsed_ms_large})`,
|
||||
);
|
||||
}
|
||||
if (base.disk_bytes_budget !== undefined && r.disk_bytes_ratio >= base.disk_bytes_budget) {
|
||||
failures.push(
|
||||
`${r.scenario}: cfgSideChannel disk-bytes ratio ${r.disk_bytes_ratio} >= budget ` +
|
||||
`${base.disk_bytes_budget} (bytes ${r.disk_bytes_small}->${r.disk_bytes_large})`,
|
||||
);
|
||||
}
|
||||
// #2082 M2 gates — rd solve-time scaling, fact-count boundedness, and an
|
||||
// ABSOLUTE side-channel size ceiling (a ratio gate is blind to a
|
||||
// constant-factor encoding bloat like named records vs indexed facts).
|
||||
if (base.rd_scaling_budget !== undefined && r.rd_scaling_ratio >= base.rd_scaling_budget) {
|
||||
failures.push(
|
||||
`${r.scenario}: reaching-defs scaling ratio ${r.rd_scaling_ratio} >= budget ` +
|
||||
`${base.rd_scaling_budget} (ms ${r.rd_ms_small}->${r.rd_ms_large})`,
|
||||
);
|
||||
}
|
||||
if (base.facts_large_max !== undefined && r.facts_large > base.facts_large_max) {
|
||||
failures.push(
|
||||
`${r.scenario}: fact materialization ${r.facts_large} > bound ${base.facts_large_max} ` +
|
||||
`(the maxFacts early-stop is the boundedness contract)`,
|
||||
);
|
||||
}
|
||||
if (base.disk_bytes_large_max !== undefined && r.disk_bytes_large > base.disk_bytes_large_max) {
|
||||
failures.push(
|
||||
`${r.scenario}: cfgSideChannel absolute size ${r.disk_bytes_large} > ceiling ` +
|
||||
`${base.disk_bytes_large_max} bytes (constant-factor encoding bloat)`,
|
||||
);
|
||||
}
|
||||
// Heap gate only when measured (--expose-gc present) AND a budget exists.
|
||||
if (
|
||||
base.heap_budget !== undefined &&
|
||||
r.heap_ratio !== null &&
|
||||
r.heap_ratio >= base.heap_budget
|
||||
) {
|
||||
failures.push(
|
||||
`${r.scenario}: retained-heap ratio ${r.heap_ratio} >= budget ${base.heap_budget} ` +
|
||||
`(heap ${r.heap_bytes_small}->${r.heap_bytes_large})`,
|
||||
);
|
||||
}
|
||||
process.stdout.write(JSON.stringify(r) + '\n');
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
for (const f of failures) process.stderr.write(`[cfg --check] FAIL: ${f}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stderr.write(`[cfg --check] PASS (${results.length} scenarios)\n`);
|
||||
}
|
||||
@@ -18,10 +18,10 @@
|
||||
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)."
|
||||
},
|
||||
"cpp": {
|
||||
"fingerprint": "f56625342f73e182170e2c964d538e316c079fa6e9466a7f076bff2ebcf8aac4",
|
||||
"fingerprint": "9b5b4393d158d76dcf1ef9807e0326462c45a5266310f0ae7894d017f3858219",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.",
|
||||
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression).",
|
||||
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression). #2094: deleted C++ declarations retain @declaration.is-deleted metadata; deleted operator and pointer-return shapes plus the expanded deleted-overload fixture are included. Intended capture drift; scaling remains linear (1.139 < 1.5).",
|
||||
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5)."
|
||||
},
|
||||
"csharp": {
|
||||
|
||||
@@ -91,10 +91,20 @@ function hasGitNexusServerOwner(gitNexusDir) {
|
||||
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether opt-in diagnostics should be written to the hook's stderr. Strict
|
||||
* hook runners validate hook output, so normal, non-error skip paths must stay
|
||||
* silent unless the operator explicitly asks for diagnostics via GITNEXUS_DEBUG.
|
||||
* See issue #1913.
|
||||
*/
|
||||
function isDebugEnabled() {
|
||||
return process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
}
|
||||
|
||||
function extractAugmentContext(stderr) {
|
||||
const output = (stderr || '').trim();
|
||||
const marker = output.indexOf('[GitNexus]');
|
||||
const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
const debug = isDebugEnabled();
|
||||
if (debug && output.length > 0) {
|
||||
// Emit the FULL discarded prefix (everything before the marker, or all of
|
||||
// it when no marker is present) so suppressed diagnostics — LadybugDB lock
|
||||
@@ -258,8 +268,14 @@ function buildAfterToolContext(input) {
|
||||
if (/\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(command)) {
|
||||
const hint = buildStaleIndexHint(gitNexusDir, cwd);
|
||||
if (hint) {
|
||||
process.stderr.write(`${hint}\n`);
|
||||
// The hint always reaches the agent via additionalContext (parts). Mirror
|
||||
// it to stderr (for terminal users) only under GITNEXUS_DEBUG, so strict
|
||||
// hook runners see no unexpected output on this normal path (#1913). The
|
||||
// claude hook never mirrored this to stderr — this aligns the two adapters.
|
||||
parts.push(hint);
|
||||
if (isDebugEnabled()) {
|
||||
process.stderr.write(`${hint}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -268,14 +284,32 @@ function buildAfterToolContext(input) {
|
||||
}
|
||||
|
||||
function runAugment(gitNexusDir, cwd, pattern) {
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
// Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
|
||||
// itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
|
||||
// as the augment, or concurrent sessions fan out unbounded probe
|
||||
// subprocesses. The cheap guards (extractPattern, gitNexusDir lookup) run in
|
||||
// buildAfterToolContext before this — moving the acquire any earlier would
|
||||
// churn slot files on tool calls that never probe.
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) {
|
||||
// Normal skip path: all per-repo hook slots are held by concurrent
|
||||
// sessions. Stay silent for strict hook runners (issue #1913); surface
|
||||
// the reason only under GITNEXUS_DEBUG.
|
||||
if (isDebugEnabled()) {
|
||||
process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) return '';
|
||||
const cliPath = resolveCliPath();
|
||||
try {
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
// Normal skip path: the MCP server owns the DB. Stay silent for strict
|
||||
// hook runners (issue #1913); surface the reason only under GITNEXUS_DEBUG.
|
||||
if (isDebugEnabled()) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
const cliPath = resolveCliPath();
|
||||
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
|
||||
if (!child.error && child.status === 0) {
|
||||
return extractAugmentContext(child.stderr || '');
|
||||
@@ -338,7 +372,7 @@ function main() {
|
||||
const handler = handlers[input.hook_event_name || ''];
|
||||
if (handler) handler(input);
|
||||
} catch (err) {
|
||||
if (process.env.GITNEXUS_DEBUG) {
|
||||
if (isDebugEnabled()) {
|
||||
console.error('GitNexus antigravity hook error:', (err.message || '').slice(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,10 +110,20 @@ function hasGitNexusServerOwner(gitNexusDir) {
|
||||
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether opt-in diagnostics should be written to the hook's stderr. Strict
|
||||
* hook runners (e.g. Codex `PreToolUse`) validate hook output, so normal,
|
||||
* non-error skip paths must stay silent unless the operator explicitly asks
|
||||
* for diagnostics via GITNEXUS_DEBUG. See issue #1913.
|
||||
*/
|
||||
function isDebugEnabled() {
|
||||
return process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
}
|
||||
|
||||
function extractAugmentContext(stderr) {
|
||||
const output = (stderr || '').trim();
|
||||
const marker = output.indexOf('[GitNexus]');
|
||||
const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
const debug = isDebugEnabled();
|
||||
if (debug && output.length > 0) {
|
||||
// Emit the FULL discarded prefix (everything before the marker, or all of
|
||||
// it when no marker is present) so suppressed diagnostics — KuzuDB lock
|
||||
@@ -249,17 +259,35 @@ function handlePreToolUse(input) {
|
||||
|
||||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
|
||||
// Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
|
||||
// itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
|
||||
// as the augment, or concurrent sessions fan out unbounded probe
|
||||
// subprocesses. Keep the acquire right after the cheap guards above —
|
||||
// moving it earlier would churn slot files on tool calls that never probe.
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) {
|
||||
// Normal skip path: all per-repo hook slots are held by concurrent
|
||||
// sessions. Stay silent for strict hook runners (issue #1913); surface
|
||||
// the reason only when diagnostics are explicitly requested.
|
||||
if (isDebugEnabled()) {
|
||||
process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) return;
|
||||
|
||||
const cliPath = resolveCliPath();
|
||||
let result = '';
|
||||
try {
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
// Normal skip path: the MCP server owns the DB, so the CLI augment would
|
||||
// contend on the lock. Stay silent for strict hook runners (issue #1913);
|
||||
// surface the reason only when diagnostics are explicitly requested.
|
||||
if (isDebugEnabled()) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const cliPath = resolveCliPath();
|
||||
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
|
||||
if (!child.error && child.status === 0) {
|
||||
result = extractAugmentContext(child.stderr || '');
|
||||
@@ -361,7 +389,7 @@ function main() {
|
||||
const handler = handlers[input.hook_event_name || ''];
|
||||
if (handler) handler(input);
|
||||
} catch (err) {
|
||||
if (process.env.GITNEXUS_DEBUG) {
|
||||
if (isDebugEnabled()) {
|
||||
console.error('GitNexus hook error:', (err.message || '').slice(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,22 @@
|
||||
*
|
||||
* Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or
|
||||
* PowerShell ETIMEDOUT (Windows), matching the hook contract.
|
||||
*
|
||||
* Unix subprocess containment contract (#2163):
|
||||
* - lsof/ps are wrapped in coreutils `timeout`/`gtimeout` when a working
|
||||
* wrapper is found (`timeout -k 1 <budget> lsof ...`). If this hook process
|
||||
* is itself SIGKILLed (e.g. by the runner's 10s hook timeout) the wrapper
|
||||
* survives, SIGTERMs its child at the budget (2s lsof / 1s ps) and SIGKILLs
|
||||
* it 1s later — orphan lifetime is bounded at ~3s instead of unbounded.
|
||||
* - GITNEXUS_HOOK_TIMEOUT_PATH: the sentinel value `disabled` switches the
|
||||
* wrapper off deterministically; any other value is adopted only when it
|
||||
* exists AND passes a one-shot `-k` self-test — otherwise resolution FALLS
|
||||
* THROUGH to the built-in candidate list (first self-test pass wins), so
|
||||
* no malformed value of any shape can silently disable orphan containment.
|
||||
* - The gitnexus server is lazy-open + sticky-hold: an idle MCP server holds
|
||||
* ZERO lbug fds until the repo's first MCP query, then keeps the fd open.
|
||||
* A probe before that first query is therefore always false — a known,
|
||||
* pre-existing race, not a bug in this probe.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
@@ -46,6 +62,80 @@ function resolveHookBinary(tool) {
|
||||
return tool;
|
||||
}
|
||||
|
||||
// Sentinel:
|
||||
// undefined = not resolved yet (resolve lazily, on first lsof/ps fallback)
|
||||
// string = self-tested coreutils timeout/gtimeout path (use as wrapper)
|
||||
// null = no usable wrapper (disabled, none found, or self-test failed)
|
||||
let unixGuardTimeoutCache;
|
||||
|
||||
/**
|
||||
* Resolve a coreutils `timeout`/`gtimeout` binary to wrap lsof/ps with
|
||||
* (#2163). Dead code on Windows (the win32 dispatch returns earlier).
|
||||
*
|
||||
* GITNEXUS_HOOK_TIMEOUT_PATH semantics: the sentinel `disabled` turns the
|
||||
* wrapper off; any other value is only a CANDIDATE — an existing file path
|
||||
* is tried first, but it must pass the `-k` self-test to be adopted. On any
|
||||
* failure (non-existent path, directory, non-executable file, wrapper
|
||||
* without `-k` support, …) resolution falls through to the built-in
|
||||
* candidates below, tried in order, first self-test pass wins. This is
|
||||
* strictly stronger than the sibling GITNEXUS_HOOK_LSOF_PATH /
|
||||
* GITNEXUS_HOOK_PS_PATH overrides (which only check existence): no bad env
|
||||
* value of ANY shape can silently disable orphan containment.
|
||||
*
|
||||
* Lazy self-test: candidates are probed only when the lsof/ps fallback is
|
||||
* first reached, and the result is memoized. A candidate is adopted only
|
||||
* when `timeout -k 1 1 /bin/sh -c :` exits 0. This rejects wrappers that do
|
||||
* not support the coreutils `-k` flag — busybox <1.34, toybox, broken
|
||||
* symlinks — which would otherwise exit with a usage error without ever
|
||||
* running lsof, silently converting the lsof-ETIMEDOUT fail-closed contract
|
||||
* into fail-open (#1492 regression). Only when EVERY candidate fails does
|
||||
* the probe fall back to the unwrapped status quo (memoized null).
|
||||
* busybox ≥1.34 passes the test and is fully usable (capability, not
|
||||
* identity, decides).
|
||||
*/
|
||||
function passesGuardSelfTest(guard) {
|
||||
try {
|
||||
const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', ':'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 3000,
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
windowsHide: true,
|
||||
});
|
||||
return !selfTest.error && selfTest.status === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUnixGuardTimeout() {
|
||||
if (unixGuardTimeoutCache !== undefined) return unixGuardTimeoutCache;
|
||||
unixGuardTimeoutCache = null;
|
||||
const fromEnv = process.env.GITNEXUS_HOOK_TIMEOUT_PATH;
|
||||
const trimmed = fromEnv ? String(fromEnv).trim() : '';
|
||||
if (trimmed === 'disabled') return unixGuardTimeoutCache;
|
||||
const candidates = [];
|
||||
if (trimmed && fs.existsSync(trimmed)) candidates.push(trimmed);
|
||||
for (const builtin of [
|
||||
'/usr/bin/timeout',
|
||||
'/bin/timeout',
|
||||
'/opt/homebrew/bin/gtimeout',
|
||||
'/usr/local/bin/gtimeout',
|
||||
]) {
|
||||
try {
|
||||
if (fs.existsSync(builtin)) candidates.push(builtin);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (passesGuardSelfTest(candidate)) {
|
||||
unixGuardTimeoutCache = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return unixGuardTimeoutCache;
|
||||
}
|
||||
|
||||
function resolveWindowsPowerShellPath() {
|
||||
const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH;
|
||||
if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) {
|
||||
@@ -188,20 +278,46 @@ function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
|
||||
}
|
||||
|
||||
function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
|
||||
const guard = resolveUnixGuardTimeout();
|
||||
const lsofPath = resolveHookBinary('lsof');
|
||||
const lsof = spawnSync(lsofPath, ['-nP', '-t', '--', dbPathAbs], {
|
||||
// The spawnSync timeouts below (lsof 1000ms / ps 500ms) are deliberately
|
||||
// SHORTER than the wrapper budgets (2s / 1s): on the supervised path Node's
|
||||
// SIGTERM always fires first, so `error.code === 'ETIMEDOUT'` and the
|
||||
// fail-closed contract are untouched. The wrapper only matters once this
|
||||
// hook process has been SIGKILLed and can no longer deliver that SIGTERM.
|
||||
const [lsofCmd, lsofArgs] = guard
|
||||
? [guard, ['-k', '1', '2', lsofPath, '-nP', '-t', '--', dbPathAbs]]
|
||||
: [lsofPath, ['-nP', '-t', '--', dbPathAbs]];
|
||||
const lsof = spawnSync(lsofCmd, lsofArgs, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 1000,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (lsof.error) return lsof.error.code === 'ETIMEDOUT';
|
||||
// Guard-mediated deaths map to "unresponsive holder" (fail-closed). Three
|
||||
// result shapes, verified against coreutils 9.1:
|
||||
// - signal-death: when `-k` escalates to SIGKILL, coreutils timeout
|
||||
// SELF-RAISES the signal, so spawnSync reports {status: null, signal}
|
||||
// with no .error (spawnSync's own ETIMEDOUT was handled above). The
|
||||
// same shape appears when this hook is frozen >2s (SIGSTOP, laptop
|
||||
// suspend) and the guard expires while it sleeps. By construction, a
|
||||
// guard-wrapped probe that died by signal without spawnSync ETIMEDOUT
|
||||
// is a budget/kill outcome.
|
||||
// - 124: budget expired and the child exited after the plain SIGTERM.
|
||||
// - 137: NOT the coreutils -k path — only exit-code-propagating wrappers,
|
||||
// or a child SIGKILLed externally (e.g. the OOM killer).
|
||||
if (guard && lsof.status === null && lsof.signal) return true;
|
||||
if (guard && (lsof.status === 124 || lsof.status === 137)) return true;
|
||||
|
||||
const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean);
|
||||
const psPath = resolveHookBinary('ps');
|
||||
for (const pid of pids) {
|
||||
if (Number(pid) === myPid) continue;
|
||||
const ps = spawnSync(psPath, ['-p', pid, '-o', 'command='], {
|
||||
const [psCmd, psArgs] = guard
|
||||
? [guard, ['-k', '1', '1', psPath, '-p', pid, '-o', 'command=']]
|
||||
: [psPath, ['-p', pid, '-o', 'command=']];
|
||||
const ps = spawnSync(psCmd, psArgs, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 500,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
@@ -211,6 +327,11 @@ function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
|
||||
if (ps.error.code === 'ETIMEDOUT') return true;
|
||||
continue;
|
||||
}
|
||||
// Same guard-mediated-death mapping as the lsof call above (signal-death
|
||||
// from the -k escalation or a frozen hook; 124 budget expiry; 137 only
|
||||
// for exit-code-propagating wrappers / external SIGKILL).
|
||||
if (guard && ps.status === null && ps.signal) return true;
|
||||
if (guard && (ps.status === 124 || ps.status === 137)) return true;
|
||||
if (isGitNexusServerCommand(ps.stdout || '')) return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
Generated
+49
-8
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.7",
|
||||
"version": "1.6.8-rc.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.7",
|
||||
"version": "1.6.8-rc.20",
|
||||
"hasInstallScript": true,
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"dependencies": {
|
||||
@@ -14,6 +14,7 @@
|
||||
"@ladybugdb/core": "^0.17.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"busboy": "^1.6.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
"commander": "^14.0.3",
|
||||
"cors": "^2.8.5",
|
||||
@@ -51,6 +52,7 @@
|
||||
"gitnexus": "dist/cli/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/cli-progress": "^3.11.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.6",
|
||||
@@ -1703,6 +1705,16 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/busboy": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.4.tgz",
|
||||
"integrity": "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
@@ -1810,9 +1822,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||
"version": "25.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz",
|
||||
"integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
@@ -2213,6 +2225,17 @@
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -3432,9 +3455,19 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
@@ -4815,6 +4848,14 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.7",
|
||||
"version": "1.6.8-rc.20",
|
||||
"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",
|
||||
@@ -49,7 +49,7 @@
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:cross-platform": "tsx scripts/run-cross-platform.ts",
|
||||
"postinstall": "node scripts/materialize-vendor-grammars.cjs && node scripts/build-tree-sitter-grammars.cjs",
|
||||
"postinstall": "node scripts/build-tree-sitter-grammars.cjs",
|
||||
"assert-publish-coverage": "node scripts/assert-publish-grammar-coverage.cjs",
|
||||
"prepare": "node scripts/build.js",
|
||||
"prepack": "node scripts/assert-publish-grammar-coverage.cjs && node scripts/build.js"
|
||||
@@ -59,6 +59,7 @@
|
||||
"@ladybugdb/core": "^0.17.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"busboy": "^1.6.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
"commander": "^14.0.3",
|
||||
"cors": "^2.8.5",
|
||||
@@ -93,6 +94,7 @@
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/cli-progress": "^3.11.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.6",
|
||||
|
||||
@@ -112,6 +112,24 @@ function findCoverageProblems({ grammars }) {
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stray local source-build outputs under `vendor/<name>/build/`. These would
|
||||
* ship in the tarball (`files: ["vendor"]` overrides .gitignore/.npmignore) AND
|
||||
* shadow the committed prebuilds — `node-gyp-build` resolves `build/Release`
|
||||
* BEFORE `prebuilds/`, so a consumer on the publisher's platform would load the
|
||||
* stray (possibly stale/wrong) binding instead of the curated prebuild. The
|
||||
* build dir is gitignored and only appears if a maintainer source-built locally
|
||||
* (e.g. on a no-prebuild platform); refuse to publish it. (#2144 review.)
|
||||
*/
|
||||
function findStrayBuildArtifacts(vendorDir) {
|
||||
if (!fs.existsSync(vendorDir)) return [];
|
||||
return fs
|
||||
.readdirSync(vendorDir)
|
||||
.filter((d) => /^tree-sitter-/.test(d))
|
||||
.filter((d) => fs.existsSync(path.join(vendorDir, d, 'build')))
|
||||
.map((d) => `vendor/${d}/build`);
|
||||
}
|
||||
|
||||
function collectGrammars(vendorDir, shipsVendorSource) {
|
||||
if (!fs.existsSync(vendorDir)) return [];
|
||||
return fs
|
||||
@@ -141,6 +159,18 @@ function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const stray = findStrayBuildArtifacts(vendorDir);
|
||||
if (stray.length > 0) {
|
||||
console.error(
|
||||
'[publish-guard] Refusing to publish — stray source-build output under vendor/ would\n' +
|
||||
'ship and shadow the committed prebuilds (node-gyp-build loads build/Release before\n' +
|
||||
'prebuilds/):',
|
||||
);
|
||||
for (const s of stray) console.error(` - ${s}`);
|
||||
console.error('\nFix: remove it before packing, e.g. `rm -rf gitnexus/vendor/*/build`.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const problems = findCoverageProblems({ grammars });
|
||||
if (problems.length > 0) {
|
||||
console.error('[publish-guard] Refusing to publish — a vendored grammar would ship unusable:');
|
||||
@@ -163,6 +193,7 @@ if (require.main === module) main();
|
||||
|
||||
module.exports = {
|
||||
findCoverageProblems,
|
||||
findStrayBuildArtifacts,
|
||||
filesShipsVendorSource,
|
||||
isBuildableFromSource,
|
||||
sourceBuildSet,
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Activate the vendored tree-sitter native bindings after
|
||||
* materialize-vendor-grammars.cjs. One registry-driven script replaces the
|
||||
* former per-grammar build-tree-sitter-<name>.cjs files (they were ~95%
|
||||
* identical).
|
||||
* Activate the vendored tree-sitter native bindings IN PLACE under `vendor/`.
|
||||
* One registry-driven script replaces the former per-grammar
|
||||
* build-tree-sitter-<name>.cjs files (they were ~95% identical).
|
||||
*
|
||||
* The grammars (tree-sitter-c/dart/proto/swift/kotlin) are loaded from
|
||||
* `vendor/<name>/` by absolute path at runtime (see
|
||||
* src/core/tree-sitter/vendored-grammars.ts) and are NEVER copied into
|
||||
* node_modules — an undeclared package under node_modules is "extraneous" to
|
||||
* every subsequent npm/npx reify, which prunes/relocates it (Windows
|
||||
* `EPERM: …, symlink` + a silent grammar deletion on the 2nd run; #2111/#1728).
|
||||
*
|
||||
* For each grammar the resolution order is identical:
|
||||
* 1. If the package isn't materialized (no binding.gyp) or the binding is
|
||||
* 1. If the vendored source is absent (no binding.gyp) or the binding is
|
||||
* already built, do nothing.
|
||||
* 2. Prefer a committed prebuild for this platform-arch (toolchain-free) via
|
||||
* node-gyp-build — the goal once build-tree-sitter-prebuilds.yml has
|
||||
* populated all six tuples.
|
||||
* node-gyp-build — `vendor/<name>/prebuilds/` ships all six tuples, so on a
|
||||
* supported platform this returns immediately and writes nothing.
|
||||
* 3. Otherwise source-build from the vendored grammar source (binding.gyp +
|
||||
* src/) so parsing still works on any toolchain host — e.g. CI, before the
|
||||
* prebuilds land.
|
||||
* src/) into `vendor/<name>/build/` (gitignored) so parsing still works on
|
||||
* a toolchain host that lacks a matching prebuild.
|
||||
*
|
||||
* HARD INVARIANT: this runs in `gitnexus`'s postinstall, so it MUST NEVER throw
|
||||
* or exit non-zero — a failure for any single grammar must not break the install.
|
||||
@@ -53,7 +59,7 @@ function buildGrammar(short) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = path.join(__dirname, '..', 'node_modules', `tree-sitter-${short}`);
|
||||
const dir = path.join(__dirname, '..', 'vendor', `tree-sitter-${short}`);
|
||||
const bindingGyp = path.join(dir, 'binding.gyp');
|
||||
const bindingNode = path.join(dir, 'build', 'Release', `tree_sitter_${short}_binding.node`);
|
||||
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Copy vendored tree-sitter grammars into node_modules/ using real files (fs.cpSync).
|
||||
*
|
||||
* Published gitnexus used to declare these as optionalDependencies with
|
||||
* `file:./vendor/...`, which makes npm symlink/junction vendor → node_modules on
|
||||
* install. Windows without Developer Mode often fails with EPERM (#1728).
|
||||
*
|
||||
* Vendor trees stay read-only in gitnexus/vendor/; build artifacts must only
|
||||
* land under node_modules/ (see #836).
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
// tree-sitter-c is a REQUIRED grammar that we vendor prebuild-only purely to
|
||||
// close upstream's ARM prebuild gap (#2116) — it needs no toolchain and is not a
|
||||
// language the user opts out of, so it is always materialized, even under
|
||||
// GITNEXUS_SKIP_OPTIONAL_GRAMMARS. The rest are optional (user-skippable, and
|
||||
// Dart/Proto compile from source) and honor the skip flag.
|
||||
const REQUIRED_VENDORED = ['tree-sitter-c'];
|
||||
const OPTIONAL_VENDORED = [
|
||||
'tree-sitter-dart',
|
||||
'tree-sitter-proto',
|
||||
'tree-sitter-swift',
|
||||
'tree-sitter-kotlin',
|
||||
];
|
||||
|
||||
const skipOptional = process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1';
|
||||
if (skipOptional) {
|
||||
console.warn(
|
||||
'[gitnexus] GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1: skipping optional Dart/Proto/Swift/Kotlin materialize (required C is still materialized).',
|
||||
);
|
||||
}
|
||||
const VENDORED_GRAMMARS = skipOptional
|
||||
? REQUIRED_VENDORED
|
||||
: [...REQUIRED_VENDORED, ...OPTIONAL_VENDORED];
|
||||
|
||||
for (const name of VENDORED_GRAMMARS) {
|
||||
const src = path.join(ROOT, 'vendor', name);
|
||||
const dest = path.join(ROOT, 'node_modules', name);
|
||||
|
||||
if (!fs.existsSync(src)) {
|
||||
console.warn(`[gitnexus] vendor/${name} missing; skipping materialize.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sequence: copy src → partial; rename dest → backup; rename partial → dest;
|
||||
// remove backup. If any step fails, restore from backup so a previously-
|
||||
// materialized grammar is never lost. Targets the #1728 EPERM scenario plus
|
||||
// narrower failure modes (Windows AV scanner racing on rename, EBUSY mid-swap).
|
||||
const partial = `${dest}.materialize-tmp`;
|
||||
const backup = `${dest}.materialize-bak`;
|
||||
try {
|
||||
fs.mkdirSync(path.join(ROOT, 'node_modules'), { recursive: true });
|
||||
fs.rmSync(partial, { recursive: true, force: true });
|
||||
fs.rmSync(backup, { recursive: true, force: true });
|
||||
fs.cpSync(src, partial, { recursive: true, verbatim: true });
|
||||
if (fs.existsSync(dest)) {
|
||||
fs.renameSync(dest, backup);
|
||||
}
|
||||
try {
|
||||
fs.renameSync(partial, dest);
|
||||
} catch (renameErr) {
|
||||
// Best-effort rollback: restore the previous dest from backup.
|
||||
let restored = false;
|
||||
if (fs.existsSync(backup)) {
|
||||
try {
|
||||
fs.renameSync(backup, dest);
|
||||
restored = true;
|
||||
} catch {
|
||||
// Rollback also failed — dest is now missing. Leave the backup in
|
||||
// place (the catch below will NOT remove it) and surface where it is.
|
||||
}
|
||||
}
|
||||
if (!restored && fs.existsSync(backup)) {
|
||||
console.warn(
|
||||
`[gitnexus] CRITICAL: could not materialize vendor/${name} AND could not restore the ` +
|
||||
`previous node_modules/${name}. A recoverable copy remains at ${backup} — ` +
|
||||
`restore it (e.g. \`mv ${backup} ${dest}\`) or reinstall to recover ${name}.`,
|
||||
);
|
||||
}
|
||||
throw renameErr;
|
||||
}
|
||||
fs.rmSync(backup, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
// Fail-soft: a single locked/inaccessible file (common on Windows) must not
|
||||
// abort the whole gitnexus install. Matches build-tree-sitter-*.cjs pattern.
|
||||
// Only remove the scratch `partial`; never the `backup` (it may be the sole
|
||||
// recoverable copy after a failed rollback above).
|
||||
fs.rmSync(partial, { recursive: true, force: true });
|
||||
console.warn(`[gitnexus] Could not materialize vendor/${name}: ${err.message}`);
|
||||
console.warn(
|
||||
`[gitnexus] ${name} parsing will be unavailable. Other functionality is unaffected.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ type ValueKind =
|
||||
| 'boolean'
|
||||
| 'boolean-negate'
|
||||
| 'string'
|
||||
| 'string-array'
|
||||
| 'numeric-string'
|
||||
| 'embeddings'
|
||||
| 'branch';
|
||||
@@ -84,6 +85,7 @@ const KEY_SPECS: Record<string, KeySpec> = {
|
||||
skipContextFiles: { target: 'skipAgentsMd', kind: 'boolean' },
|
||||
skipAiContext: { target: 'skipAgentsMd', kind: 'boolean' },
|
||||
skipSkills: { target: 'skipSkills', kind: 'boolean' },
|
||||
pdg: { target: 'pdg', kind: 'boolean' },
|
||||
indexOnly: { target: 'indexOnly', kind: 'boolean' },
|
||||
stats: { target: 'stats', kind: 'boolean' },
|
||||
noStats: { target: 'stats', kind: 'boolean-negate' },
|
||||
@@ -99,6 +101,12 @@ const KEY_SPECS: Record<string, KeySpec> = {
|
||||
embeddingBatchSize: { target: 'embeddingBatchSize', kind: 'numeric-string' },
|
||||
embeddingSubBatchSize: { target: 'embeddingSubBatchSize', kind: 'numeric-string' },
|
||||
embeddingDevice: { target: 'embeddingDevice', kind: 'string' },
|
||||
// #1589/#1852 residual — extra fetch-wrapper function names to treat as HTTP
|
||||
// consumers. The auto-detector only flags functions that call the bare global
|
||||
// `fetch()`; a wrapper built on axios / a custom client, or named outside the
|
||||
// built-in convention set, is otherwise invisible to route_map consumers.
|
||||
// Listing it here adds it to the cross-file consumer scan.
|
||||
fetchWrappers: { target: 'fetchWrappers', kind: 'string-array' },
|
||||
};
|
||||
|
||||
/** Top-level container key for the nested form; not itself an `AnalyzeOptions` field. */
|
||||
@@ -230,6 +238,41 @@ const normalizeValue = (kind: ValueKind, value: unknown, key: string): unknown =
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
case 'string-array': {
|
||||
// Generic shared validator — `source` already names the config key, so
|
||||
// messages here stay key-agnostic (no fetch-wrapper coupling in the
|
||||
// shared normalizer; #1589/#1852 review F7).
|
||||
if (!Array.isArray(value)) {
|
||||
throw new GitNexusRcError(`${source} must be an array of strings.`);
|
||||
}
|
||||
const names: string[] = [];
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'string') {
|
||||
throw new GitNexusRcError(`${source} entries must all be strings.`);
|
||||
}
|
||||
const trimmed = item.trim();
|
||||
if (!trimmed) {
|
||||
throw new GitNexusRcError(`${source} entries must not be empty.`);
|
||||
}
|
||||
assertNoHiddenChars(trimmed, source);
|
||||
// Values may be interpolated into a RegExp downstream. Restrict to
|
||||
// identifier / member-access shapes so a config value can never smuggle
|
||||
// regex metacharacters into a consumer.
|
||||
if (!/^[A-Za-z_$][A-Za-z0-9_$.]*$/.test(trimmed)) {
|
||||
throw new GitNexusRcError(
|
||||
`${source} entry "${trimmed}" must be an identifier or member name ` +
|
||||
`(letters, digits, _, $, . — e.g. "client.get").`,
|
||||
);
|
||||
}
|
||||
names.push(trimmed);
|
||||
}
|
||||
if (names.length === 0) {
|
||||
throw new GitNexusRcError(`${source} must list at least one string.`);
|
||||
}
|
||||
// De-duplicate and cap to a sane bound so a pathological config cannot
|
||||
// blow up the consumer scan's alternation.
|
||||
return Array.from(new Set(names)).slice(0, 100);
|
||||
}
|
||||
case 'numeric-string': {
|
||||
// Mirror Commander's contract: these options reach the existing CLI
|
||||
// validation as strings. Accept a JSON number or a string; normalize to a
|
||||
|
||||
@@ -599,6 +599,12 @@ export interface AnalyzeOptions {
|
||||
verbose?: boolean;
|
||||
/** Skip AGENTS.md and CLAUDE.md gitnexus block updates. */
|
||||
skipAgentsMd?: boolean;
|
||||
/**
|
||||
* Build the control-flow-graph / PDG substrate (#2081 M1). Opt-in; off by
|
||||
* default. Threaded to both the worker (CFG build) and scope-resolution
|
||||
* (BasicBlock/CFG emit).
|
||||
*/
|
||||
pdg?: boolean;
|
||||
/**
|
||||
* Stats inclusion in AGENTS.md and CLAUDE.md.
|
||||
*
|
||||
@@ -620,6 +626,14 @@ export interface AnalyzeOptions {
|
||||
* before being threaded into the generated AGENTS.md / CLAUDE.md content.
|
||||
*/
|
||||
defaultBranch?: string;
|
||||
/**
|
||||
* Index-branch selector (#2106). From `--branch`. Distinct from
|
||||
* `defaultBranch` (cosmetic base_ref): this routes the index to a per-branch
|
||||
* slot. NOT sourced from `.gitnexusrc` — the `.gitnexusrc` `branch` key is an
|
||||
* alias for `defaultBranch` and must not change index placement. Defaults to
|
||||
* the checked-out branch inside `runFullAnalysis` when omitted.
|
||||
*/
|
||||
branch?: string;
|
||||
/** Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills). */
|
||||
indexOnly?: boolean;
|
||||
/** Index the folder even when no .git directory is present. */
|
||||
@@ -655,6 +669,14 @@ export interface AnalyzeOptions {
|
||||
embeddingBatchSize?: string;
|
||||
embeddingSubBatchSize?: string;
|
||||
embeddingDevice?: string;
|
||||
/**
|
||||
* Extra fetch-wrapper function names to treat as HTTP consumers (#1589/#1852
|
||||
* residual). Supplied via `.gitnexusrc` `fetchWrappers: [...]`. Threaded into
|
||||
* the routes phase, where the cross-file consumer scan unions them with the
|
||||
* auto-detected `fetch()` wrappers so a custom/axios-based wrapper named
|
||||
* outside the built-in convention still produces `route_map` consumers.
|
||||
*/
|
||||
fetchWrappers?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -762,6 +784,21 @@ const analyzeCommandImpl = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the index-branch selector (#2106) the same way, so a malformed
|
||||
// `--branch` exits before any expensive analysis starts. Capture the TRIMMED
|
||||
// return so a whitespace-padded value (e.g. " feature" from shell completion)
|
||||
// normalizes before the checked-out-branch mismatch guard and slug — otherwise
|
||||
// it would false-reject on-branch or create a ghost index when detached.
|
||||
if (cliOptions?.branch !== undefined) {
|
||||
try {
|
||||
cliOptions.branch = validateBranchName(cliOptions.branch, '--branch');
|
||||
} catch (err) {
|
||||
cliError(` ${err instanceof Error ? err.message : String(err)}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load .gitnexusrc and merge: CLI flags override config (#243) ───
|
||||
// Parse/validate before the progress bar so a malformed config produces an
|
||||
// actionable error and exits before any expensive analysis starts.
|
||||
@@ -1091,9 +1128,15 @@ const analyzeCommandImpl = async (
|
||||
skipGit: options.skipGit,
|
||||
skipAgentsMd,
|
||||
skipSkills,
|
||||
// CFG/PDG substrate opt-in (#2081 M1) — threaded to both sinks downstream.
|
||||
pdg: options.pdg === true,
|
||||
// Resolved default branch (CLI > .gitnexusrc > auto-detect > "main")
|
||||
// threaded into the generated regression-compare example (#243).
|
||||
defaultBranch: resolvedDefaultBranch,
|
||||
// Index-branch selector (#2106). Read straight from the CLI flag (not
|
||||
// the .gitnexusrc-merged options) so the cosmetic defaultBranch config
|
||||
// can never change index placement. Undefined → auto-detect in pipeline.
|
||||
branch: cliOptions?.branch,
|
||||
// commander.js `.option('--no-stats', …)` registers the flag as
|
||||
// `options.stats` (boolean, default true; `false` when the user
|
||||
// passed --no-stats). Reading `options.noStats` here returns
|
||||
@@ -1110,6 +1153,9 @@ const analyzeCommandImpl = async (
|
||||
// GITNEXUS_WORKER_POOL_SIZE env mutation. `undefined` defers to the
|
||||
// env / auto-formula fallback inside the pipeline.
|
||||
workerPoolSize,
|
||||
// Extra fetch-wrapper names from `.gitnexusrc` (#1589/#1852 residual);
|
||||
// forwarded to the routes phase consumer scan.
|
||||
fetchWrappers: options.fetchWrappers,
|
||||
},
|
||||
{
|
||||
onProgress: (_phase, percent, message) => {
|
||||
@@ -1131,14 +1177,20 @@ const analyzeCommandImpl = async (
|
||||
// preserving the rest of the block (incl. --skills community rows). No-op
|
||||
// when the value already matches, so a routine up-to-date run is silent
|
||||
// (#1996 tri-review P2).
|
||||
// Only refresh the repo-root AGENTS.md/CLAUDE.md base_ref for the
|
||||
// PRIMARY/flat index (#2106 R2). A non-primary branch's up-to-date
|
||||
// analyze must not churn the committed AGENTS.md — this mirrors the
|
||||
// in-pipeline `if (!placement.branch)` gate around generateAIContextFiles.
|
||||
let baseRefRefreshed: string[] = [];
|
||||
try {
|
||||
const { refreshBaseRefLine } = await import('./ai-context.js');
|
||||
baseRefRefreshed = (
|
||||
await refreshBaseRefLine(repoPath, resolvedDefaultBranch, { skipAgentsMd })
|
||||
).files;
|
||||
} catch {
|
||||
/* best-effort — never fail the fast path over a context refresh */
|
||||
if (result.isPrimaryBranch !== false) {
|
||||
try {
|
||||
const { refreshBaseRefLine } = await import('./ai-context.js');
|
||||
baseRefRefreshed = (
|
||||
await refreshBaseRefLine(repoPath, resolvedDefaultBranch, { skipAgentsMd })
|
||||
).files;
|
||||
} catch {
|
||||
/* best-effort — never fail the fast path over a context refresh */
|
||||
}
|
||||
}
|
||||
clearInterval(elapsedTimer);
|
||||
process.removeListener('SIGINT', sigintHandler);
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
unregisterRepo,
|
||||
listRegisteredRepos,
|
||||
assertSafeStoragePath,
|
||||
getStoragePaths,
|
||||
removeBranchIndex,
|
||||
UnsafeStoragePathError,
|
||||
} from '../storage/repo-manager.js';
|
||||
import {
|
||||
@@ -26,7 +28,50 @@ export const cleanCommand = async (options?: {
|
||||
force?: boolean;
|
||||
all?: boolean;
|
||||
lbugSidecars?: boolean;
|
||||
branch?: string;
|
||||
}) => {
|
||||
// --branch <name>: remove a single non-primary branch's index (#2106 R7).
|
||||
// Resolve against the RECORDED branches[] summary (never by slugging the
|
||||
// user's raw input, which can disagree with the index-time-sanitized label).
|
||||
if (options?.branch) {
|
||||
const cwd = process.cwd();
|
||||
const repo = await findRepo(cwd);
|
||||
if (!repo) {
|
||||
console.log(t('clean.notFoundHere'));
|
||||
return;
|
||||
}
|
||||
const entries = await listRegisteredRepos();
|
||||
const entry = entries.find((e) => path.resolve(e.path) === path.resolve(repo.repoPath));
|
||||
const summary = entry?.branches?.find((b) => b.branch === options.branch);
|
||||
if (!summary) {
|
||||
console.log(t('clean.branchNotIndexed', { branch: options.branch }));
|
||||
return;
|
||||
}
|
||||
const { storagePath, lbugPath } = getStoragePaths(repo.repoPath, summary.branch);
|
||||
const branchDir = path.dirname(lbugPath);
|
||||
// Safety guard: the target MUST live under <repo>/.gitnexus/branches/.
|
||||
// assertSafeStoragePath only validates the flat `<repo>/.gitnexus`, so this
|
||||
// is a dedicated branches-sub-dir check before any destructive fs.rm.
|
||||
const branchesRoot = path.join(storagePath, 'branches') + path.sep;
|
||||
if (!branchDir.startsWith(branchesRoot)) {
|
||||
logger.error(`Refusing to clean branch index outside .gitnexus/branches: ${branchDir}`);
|
||||
return;
|
||||
}
|
||||
if (!options.force) {
|
||||
console.log(t('clean.deleteBranch', { branch: summary.branch, path: branchDir }));
|
||||
console.log(`\n${t('common.runForceConfirm')}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fs.rm(branchDir, { recursive: true, force: true });
|
||||
await removeBranchIndex(repo.repoPath, summary.branch);
|
||||
console.log(t('clean.deletedBranch', { branch: summary.branch }));
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to delete branch index:');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.lbugSidecars) {
|
||||
const cwd = process.cwd();
|
||||
const repo = await findRepo(cwd);
|
||||
|
||||
@@ -189,7 +189,47 @@ export function formatImpactResult(result: any): string {
|
||||
const byDepth = result.byDepth || {};
|
||||
const total = result.impactedCount || 0;
|
||||
|
||||
// #2129 — an ambiguous bare name must not print the "isolated / safe to
|
||||
// refactor" headline. Surface the per-candidate blast radius + the maximum,
|
||||
// mirroring formatContextResult, so the real impact under whichever symbol the
|
||||
// caller meant is visible on the text surface, not just in the JSON.
|
||||
if (result.status === 'ambiguous') {
|
||||
// #2129 review F11 — report the FULL match count (`totalCandidates`), not the
|
||||
// truncated `candidates[]` length; note when the candidate list is capped.
|
||||
const shown = result.candidates?.length ?? 0;
|
||||
const total = result.totalCandidates ?? shown;
|
||||
const countPhrase = total > shown ? `${total} symbols (showing ${shown})` : `${total} symbols`;
|
||||
const lines = [
|
||||
`${target?.name || '?'}: AMBIGUOUS — ${countPhrase} share this name. ` +
|
||||
`Max blast radius ${result.maxImpactedCount ?? 0} (${result.maxRisk ?? 'UNKNOWN'} risk). ` +
|
||||
`Disambiguate with --uid for one authoritative result:`,
|
||||
];
|
||||
for (const c of result.candidates || []) {
|
||||
lines.push(
|
||||
` ${c.kind} ${c.name} → ${c.filePath}:${c.line || '?'} ` +
|
||||
`[${c.impactedCount ?? 0} ${direction}, risk ${c.risk ?? 'UNKNOWN'}] (uid: ${c.uid})`,
|
||||
);
|
||||
}
|
||||
// #2129 review F1 — a failed per-candidate probe makes the max a lower bound.
|
||||
if (result.partialProbe) {
|
||||
lines.push(
|
||||
' ⚠️ One or more candidate probes failed — max blast radius / risk are lower bounds.',
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
// #1858 — "isolated" is a confident claim. If an interface / indirection
|
||||
// boundary is on the path, the true count is a lower bound, not zero;
|
||||
// callers binding via DI / dynamic dispatch were not traced. Say so instead.
|
||||
if (result.epistemic === 'lower-bound') {
|
||||
const lines = [
|
||||
`${target?.name || '?'}: no direct ${direction} dependencies traced, but this is a LOWER BOUND — unresolved indirection on the path (actual impact may be higher):`,
|
||||
];
|
||||
for (const b of result.boundaries || []) lines.push(` • ${b}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
return `${target?.name || '?'}: No ${direction} dependencies found. This symbol appears isolated.`;
|
||||
}
|
||||
|
||||
@@ -202,6 +242,14 @@ export function formatImpactResult(result: any): string {
|
||||
if (result.partial) {
|
||||
lines.push('⚠️ Partial results — graph traversal was interrupted. Deeper impacts may exist.');
|
||||
}
|
||||
// #1858 — an interface / indirection boundary on the path makes this a lower
|
||||
// bound; surface it so the count is not read as exhaustive.
|
||||
if (result.epistemic === 'lower-bound') {
|
||||
lines.push(
|
||||
'⚠️ Lower bound — unresolved indirection on the path (callers binding via DI / dynamic dispatch are not traced; actual impact may be higher):',
|
||||
);
|
||||
for (const b of result.boundaries || []) lines.push(` • ${b}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
const depthLabels: Record<number, string> = {
|
||||
|
||||
@@ -30,6 +30,7 @@ const COMMAND_DESCRIPTION_KEYS = {
|
||||
impact: 'help.command.impact.description',
|
||||
cypher: 'help.command.cypher.description',
|
||||
'detect-changes': 'help.command.detectChanges.description',
|
||||
check: 'help.command.check.description',
|
||||
'eval-server': 'help.command.evalServer.description',
|
||||
group: 'help.command.group.description',
|
||||
'group create': 'help.command.group.create.description',
|
||||
@@ -73,6 +74,7 @@ const OPTION_DESCRIPTION_KEYS = {
|
||||
'uninstall|-f, --force': 'help.option.uninstall.force',
|
||||
'clean|-f, --force': 'help.option.force.confirmation',
|
||||
'clean|--all': 'help.option.clean.all',
|
||||
'clean|--branch <name>': 'help.option.clean.branch',
|
||||
'clean|--lbug-sidecars': 'help.option.clean.lbugSidecars',
|
||||
'remove|-f, --force': 'help.option.force.confirmation',
|
||||
'wiki|-f, --force': 'help.option.wiki.force',
|
||||
@@ -93,16 +95,19 @@ const OPTION_DESCRIPTION_KEYS = {
|
||||
'publish|--id <owner/repo>': 'help.option.publish.id',
|
||||
'publish|--skip-git': 'help.option.skipGit',
|
||||
'query|-r, --repo <name>': 'help.option.repo.targetOmitOne',
|
||||
'query|--branch <name>': 'help.option.branch',
|
||||
'query|-c, --context <text>': 'help.option.query.context',
|
||||
'query|-g, --goal <text>': 'help.option.query.goal',
|
||||
'query|-l, --limit <n>': 'help.option.query.limit',
|
||||
'query|--content': 'help.option.content',
|
||||
'context|-r, --repo <name>': 'help.option.repo.target',
|
||||
'context|--branch <name>': 'help.option.branch',
|
||||
'context|-u, --uid <uid>': 'help.option.context.uid',
|
||||
'context|-f, --file <path>': 'help.option.context.file',
|
||||
'context|--content': 'help.option.content',
|
||||
'impact|-d, --direction <dir>': 'help.option.impact.direction',
|
||||
'impact|-r, --repo <name>': 'help.option.repo.target',
|
||||
'impact|--branch <name>': 'help.option.branch',
|
||||
'impact|-u, --uid <uid>': 'help.option.context.uid',
|
||||
'impact|-f, --file <path>': 'help.option.context.file',
|
||||
'impact|--kind <kind>': 'help.option.impact.kind',
|
||||
@@ -112,9 +117,15 @@ const OPTION_DESCRIPTION_KEYS = {
|
||||
'impact|--offset <n>': 'help.option.impact.offset',
|
||||
'impact|--summary-only': 'help.option.impact.summaryOnly',
|
||||
'cypher|-r, --repo <name>': 'help.option.repo.target',
|
||||
'cypher|--branch <name>': 'help.option.branch',
|
||||
'detect-changes|-s, --scope <scope>': 'help.option.detectChanges.scope',
|
||||
'detect-changes|-b, --base-ref <ref>': 'help.option.detectChanges.baseRef',
|
||||
'detect-changes|-r, --repo <name>': 'help.option.repo.target',
|
||||
'detect-changes|--branch <name>': 'help.option.branch',
|
||||
'check|--cycles': 'help.option.check.cycles',
|
||||
'check|--json': 'help.option.json',
|
||||
'check|-r, --repo <name>': 'help.option.repo.target',
|
||||
'check|--branch <name>': 'help.option.branch',
|
||||
'eval-server|-p, --port <port>': 'help.option.port',
|
||||
'eval-server|--host <host>': 'help.option.evalServer.host',
|
||||
'eval-server|--idle-timeout <seconds>': 'help.option.evalServer.idleTimeout',
|
||||
|
||||
@@ -10,6 +10,9 @@ export const en = {
|
||||
'list.title': 'Indexed Repositories ({{count}})',
|
||||
'list.indexed': 'Indexed',
|
||||
'list.commit': 'Commit',
|
||||
'list.branch': 'Branch',
|
||||
'list.branchIndexes': 'Branch indexes',
|
||||
'list.branchLine': '{{branch}} ({{commit}}, {{indexed}})',
|
||||
'list.stats': 'Stats',
|
||||
'list.statsValue': '{{files}} files, {{symbols}} symbols, {{edges}} edges',
|
||||
'list.clusters': 'Clusters',
|
||||
@@ -23,6 +26,10 @@ export const en = {
|
||||
'status.indexed': 'Indexed',
|
||||
'status.indexedCommit': 'Indexed commit',
|
||||
'status.currentCommit': 'Current commit',
|
||||
'status.branch': 'Branch',
|
||||
'status.detached': '(detached HEAD)',
|
||||
'status.branchNotIndexed':
|
||||
"⚠️ current branch not indexed (primary index is for '{{primary}}'; run gitnexus analyze)",
|
||||
'status.status': 'Status',
|
||||
'status.upToDate': '✅ up-to-date',
|
||||
'status.stale': '⚠️ stale (re-run gitnexus analyze)',
|
||||
@@ -30,6 +37,9 @@ export const en = {
|
||||
'clean.deletedRepo': 'Deleted: {{name}} ({{storagePath}})',
|
||||
'clean.notFoundHere': 'No indexed repository found in this directory.',
|
||||
'clean.deleteCurrent': 'This will delete the GitNexus index for: {{repoName}}',
|
||||
'clean.branchNotIndexed': 'No indexed branch named "{{branch}}" for this repository.',
|
||||
'clean.deleteBranch': 'This will delete the branch index "{{branch}}" at: {{path}}',
|
||||
'clean.deletedBranch': 'Deleted branch index: {{branch}}',
|
||||
'clean.lbugSidecars.state': 'LadybugDB sidecar state: {{state}}',
|
||||
'clean.lbugSidecars.none': 'No quarantined LadybugDB missing-shadow WAL sidecars found.',
|
||||
'clean.lbugSidecars.preview':
|
||||
@@ -133,6 +143,7 @@ export const en = {
|
||||
'help.command.cypher.description': 'Execute raw Cypher query against the knowledge graph',
|
||||
'help.command.detectChanges.description':
|
||||
'Map git diff hunks to indexed symbols and affected execution flows',
|
||||
'help.command.check.description': 'Run structural checks against the indexed graph',
|
||||
'help.command.evalServer.description':
|
||||
'Start lightweight HTTP server for fast tool calls during evaluation',
|
||||
'help.command.group.description': 'Manage repository groups for cross-index impact analysis',
|
||||
@@ -189,6 +200,7 @@ export const en = {
|
||||
'help.option.force.confirmation': 'Skip confirmation prompt',
|
||||
'help.option.uninstall.force': 'Apply the changes (default is a dry-run preview)',
|
||||
'help.option.clean.all': 'Clean all indexed repos',
|
||||
'help.option.clean.branch': 'Delete only the named branch index (not the primary)',
|
||||
'help.option.clean.lbugSidecars': 'Clean quarantined LadybugDB missing-shadow WAL sidecars',
|
||||
'help.option.wiki.force': 'Force full regeneration even if up to date',
|
||||
'help.option.wiki.provider':
|
||||
@@ -217,6 +229,7 @@ export const en = {
|
||||
'help.option.query.limit': 'Max processes to return (default: 5)',
|
||||
'help.option.content': 'Include full symbol source code',
|
||||
'help.option.repo.target': 'Target repository',
|
||||
'help.option.branch': 'Scope to a specific branch index (multi-branch repos)',
|
||||
'help.option.context.uid': 'Direct symbol UID (zero-ambiguity lookup)',
|
||||
'help.option.context.file': 'File path to disambiguate common names',
|
||||
'help.option.impact.kind':
|
||||
@@ -229,6 +242,7 @@ export const en = {
|
||||
'help.option.impact.summaryOnly': 'Return counts and risk only, omit symbol list',
|
||||
'help.option.detectChanges.scope': 'What to analyze: unstaged, staged, all, or compare',
|
||||
'help.option.detectChanges.baseRef': 'Branch/commit for compare scope (e.g. main)',
|
||||
'help.option.check.cycles': 'Detect circular imports and fail when any are found',
|
||||
'help.option.evalServer.host':
|
||||
'Bind address (default: 127.0.0.1, use 0.0.0.0 to expose to all interfaces)',
|
||||
'help.option.evalServer.idleTimeout': 'Auto-shutdown after N seconds idle (0 = disabled)',
|
||||
|
||||
@@ -14,6 +14,9 @@ export const zhCN = {
|
||||
'list.title': '已索引仓库({{count}})',
|
||||
'list.indexed': '索引时间',
|
||||
'list.commit': '提交',
|
||||
'list.branch': '分支',
|
||||
'list.branchIndexes': '分支索引',
|
||||
'list.branchLine': '{{branch}}({{commit}},{{indexed}})',
|
||||
'list.stats': '统计',
|
||||
'list.statsValue': '{{files}} 个文件,{{symbols}} 个符号,{{edges}} 条边',
|
||||
'list.clusters': '聚类',
|
||||
@@ -27,6 +30,10 @@ export const zhCN = {
|
||||
'status.indexed': '索引时间',
|
||||
'status.indexedCommit': '索引提交',
|
||||
'status.currentCommit': '当前提交',
|
||||
'status.branch': '分支',
|
||||
'status.detached': '(分离 HEAD)',
|
||||
'status.branchNotIndexed':
|
||||
"⚠️ 当前分支未索引(主索引对应 '{{primary}}';请运行 gitnexus analyze)",
|
||||
'status.status': '状态',
|
||||
'status.upToDate': '✅ 已是最新',
|
||||
'status.stale': '⚠️ 已过期(重新运行 gitnexus analyze)',
|
||||
@@ -34,6 +41,9 @@ export const zhCN = {
|
||||
'clean.deletedRepo': '已删除:{{name}}({{storagePath}})',
|
||||
'clean.notFoundHere': '当前目录未找到已索引仓库。',
|
||||
'clean.deleteCurrent': '将删除该仓库的 GitNexus 索引:{{repoName}}',
|
||||
'clean.branchNotIndexed': '该仓库没有名为 “{{branch}}” 的已索引分支。',
|
||||
'clean.deleteBranch': '将删除分支索引 “{{branch}}”,路径:{{path}}',
|
||||
'clean.deletedBranch': '已删除分支索引:{{branch}}',
|
||||
'clean.lbugSidecars.state': 'LadybugDB sidecar 状态:{{state}}',
|
||||
'clean.lbugSidecars.none': '未找到已隔离的 LadybugDB missing-shadow WAL sidecar。',
|
||||
'clean.lbugSidecars.preview':
|
||||
@@ -129,6 +139,7 @@ export const zhCN = {
|
||||
'help.command.impact.description': '影响面分析:修改符号会影响什么',
|
||||
'help.command.cypher.description': '对知识图谱执行原始 Cypher 查询',
|
||||
'help.command.detectChanges.description': '将 git diff hunk 映射到已索引符号和受影响执行流程',
|
||||
'help.command.check.description': '对已索引图谱运行结构检查',
|
||||
'help.command.evalServer.description': '启动轻量 HTTP 服务器,用于评测期间的快速工具调用',
|
||||
'help.command.group.description': '管理仓库组,用于跨索引影响分析',
|
||||
'help.command.group.create.description': '使用模板 group.yaml 创建新仓库组',
|
||||
@@ -178,6 +189,7 @@ export const zhCN = {
|
||||
'help.option.force.confirmation': '跳过确认提示',
|
||||
'help.option.uninstall.force': '应用更改(默认仅为预演预览)',
|
||||
'help.option.clean.all': '清理所有已索引仓库',
|
||||
'help.option.clean.branch': '仅删除指定分支的索引(不影响主索引)',
|
||||
'help.option.clean.lbugSidecars': '清理已隔离的 LadybugDB missing-shadow WAL sidecar',
|
||||
'help.option.wiki.force': '即使已是最新也强制完整重新生成',
|
||||
'help.option.wiki.provider':
|
||||
@@ -203,6 +215,7 @@ export const zhCN = {
|
||||
'help.option.query.limit': '最多返回的流程数(默认:5)',
|
||||
'help.option.content': '包含完整符号源码',
|
||||
'help.option.repo.target': '目标仓库',
|
||||
'help.option.branch': '将查询限定到指定分支的索引(多分支仓库)',
|
||||
'help.option.context.uid': '直接符号 UID(零歧义查找)',
|
||||
'help.option.context.file': '用于消除常见名称歧义的文件路径',
|
||||
'help.option.impact.kind': '用于消除常见名称歧义的类型过滤(如 Function、Class、Method)',
|
||||
@@ -214,6 +227,7 @@ export const zhCN = {
|
||||
'help.option.impact.summaryOnly': '仅返回计数和风险等级,省略符号列表',
|
||||
'help.option.detectChanges.scope': '分析范围:unstaged、staged、all 或 compare',
|
||||
'help.option.detectChanges.baseRef': 'compare 范围的分支/提交(例如 main)',
|
||||
'help.option.check.cycles': '检测循环导入,并在发现循环时失败',
|
||||
'help.option.evalServer.host': '绑定地址(默认:127.0.0.1;用 0.0.0.0 暴露到所有网卡)',
|
||||
'help.option.evalServer.idleTimeout': '空闲 N 秒后自动关闭(0 = 禁用)',
|
||||
'help.option.group.create.force': '覆盖现有仓库组',
|
||||
|
||||
@@ -52,11 +52,22 @@ program
|
||||
'(no-op when --index-only is also set).',
|
||||
)
|
||||
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
|
||||
.option(
|
||||
'--pdg',
|
||||
'Build the control-flow-graph / PDG substrate (BasicBlock nodes + CFG edges) ' +
|
||||
'for supported languages. Opt-in; off by default. (#2081 M1)',
|
||||
)
|
||||
.option(
|
||||
'--default-branch <branch>',
|
||||
'Default branch used in the generated regression-compare example (base_ref). ' +
|
||||
'Falls back to .gitnexusrc, then auto-detected origin/HEAD, then "main".',
|
||||
)
|
||||
.option(
|
||||
'--branch <name>',
|
||||
'Index the working tree under a specific branch slot (multi-branch indexing). ' +
|
||||
'Defaults to the checked-out branch; the primary/first-indexed branch keeps the ' +
|
||||
'flat index and others get their own. Distinct from --default-branch (cosmetic base_ref).',
|
||||
)
|
||||
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
|
||||
.option(
|
||||
'--skip-skills',
|
||||
@@ -145,6 +156,7 @@ program
|
||||
.description('Delete GitNexus index for current repo')
|
||||
.option('-f, --force', 'Skip confirmation prompt')
|
||||
.option('--all', 'Clean all indexed repos')
|
||||
.option('--branch <name>', 'Delete only the named branch index (not the primary)')
|
||||
.option('--lbug-sidecars', 'Clean quarantined LadybugDB missing-shadow WAL sidecars')
|
||||
.action(createLazyAction(() => import('./clean.js'), 'cleanCommand'));
|
||||
|
||||
@@ -216,6 +228,7 @@ program
|
||||
.command('query <search_query>')
|
||||
.description('Search the knowledge graph for execution flows related to a concept')
|
||||
.option('-r, --repo <name>', 'Target repository (omit if only one indexed)')
|
||||
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
|
||||
.option('-c, --context <text>', 'Task context to improve ranking')
|
||||
.option('-g, --goal <text>', 'What you want to find')
|
||||
.option('-l, --limit <n>', 'Max processes to return (default: 5)')
|
||||
@@ -226,6 +239,7 @@ program
|
||||
.command('context [name]')
|
||||
.description('360-degree view of a code symbol: callers, callees, processes')
|
||||
.option('-r, --repo <name>', 'Target repository')
|
||||
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
|
||||
.option('-u, --uid <uid>', 'Direct symbol UID (zero-ambiguity lookup)')
|
||||
.option('-f, --file <path>', 'File path to disambiguate common names')
|
||||
.option('--content', 'Include full symbol source code')
|
||||
@@ -236,6 +250,7 @@ program
|
||||
.description('Blast radius analysis: what breaks if you change a symbol')
|
||||
.option('-d, --direction <dir>', 'upstream (dependants) or downstream (dependencies)', 'upstream')
|
||||
.option('-r, --repo <name>', 'Target repository')
|
||||
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
|
||||
.option('-u, --uid <uid>', 'Direct symbol UID (zero-ambiguity lookup)')
|
||||
.option('-f, --file <path>', 'File path to disambiguate common names')
|
||||
.option(
|
||||
@@ -253,6 +268,7 @@ program
|
||||
.command('cypher <query>')
|
||||
.description('Execute raw Cypher query against the knowledge graph')
|
||||
.option('-r, --repo <name>', 'Target repository')
|
||||
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
|
||||
.action(createLbugLazyAction(() => import('./tool.js'), 'cypherCommand'));
|
||||
|
||||
program
|
||||
@@ -262,8 +278,18 @@ program
|
||||
.option('-s, --scope <scope>', 'What to analyze: unstaged, staged, all, or compare', 'unstaged')
|
||||
.option('-b, --base-ref <ref>', 'Branch/commit for compare scope (e.g. main)')
|
||||
.option('-r, --repo <name>', 'Target repository')
|
||||
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
|
||||
.action(createLbugLazyAction(() => import('./tool.js'), 'detectChangesCommand'));
|
||||
|
||||
program
|
||||
.command('check')
|
||||
.description('Run structural checks against the indexed graph')
|
||||
.option('--cycles', 'Detect circular imports and fail when any are found')
|
||||
.option('--json', 'Emit machine-readable JSON')
|
||||
.option('-r, --repo <name>', 'Target repository')
|
||||
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
|
||||
.action(createLbugLazyAction(() => import('./tool.js'), 'checkCommand'));
|
||||
|
||||
// ─── Eval Server (persistent daemon for SWE-bench) ─────────────────
|
||||
|
||||
program
|
||||
|
||||
@@ -38,6 +38,7 @@ export const listCommand = async () => {
|
||||
console.log(` ${t('common.path')}: ${entry.path}`);
|
||||
console.log(` ${t('list.indexed')}: ${indexedDate}`);
|
||||
console.log(` ${t('list.commit')}: ${commitShort}`);
|
||||
if (entry.branch) console.log(` ${t('list.branch')}: ${entry.branch}`);
|
||||
console.log(
|
||||
` ${t('list.stats')}: ${t('list.statsValue', {
|
||||
files: stats.files ?? 0,
|
||||
@@ -47,6 +48,18 @@ export const listCommand = async () => {
|
||||
);
|
||||
if (stats.communities) console.log(` ${t('list.clusters')}: ${stats.communities}`);
|
||||
if (stats.processes) console.log(` ${t('list.processes')}: ${stats.processes}`);
|
||||
// Per-branch indexes (#2106). Only rendered when extra branches were
|
||||
// indexed for this path, so single-branch output is unchanged.
|
||||
if (entry.branches && entry.branches.length > 0) {
|
||||
console.log(` ${t('list.branchIndexes')}:`);
|
||||
for (const b of entry.branches) {
|
||||
const bCommit = b.lastCommit?.slice(0, 7) || t('list.unknown');
|
||||
const bIndexed = new Date(b.indexedAt).toLocaleString();
|
||||
console.log(
|
||||
` ${t('list.branchLine', { branch: b.branch, commit: bCommit, indexed: bIndexed })}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
/**
|
||||
* Optional grammar availability check.
|
||||
*
|
||||
* tree-sitter-dart, tree-sitter-proto, and tree-sitter-swift are vendored
|
||||
* under vendor/ and materialized into node_modules/ at postinstall. Dart
|
||||
* and Proto are built from source with node-gyp; Swift ships platform
|
||||
* prebuilds activated via node-gyp-build. tree-sitter-kotlin is a declared
|
||||
* optionalDependency (not vendored). All can be skipped via
|
||||
* tree-sitter-dart, -proto, -swift, and -kotlin are vendored under vendor/ and
|
||||
* loaded from there by absolute path (NEVER copied into node_modules — see
|
||||
* core/tree-sitter/vendored-grammars.ts / #2111). Each ships committed platform
|
||||
* prebuilds activated via node-gyp-build. All can be skipped via
|
||||
* GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (postinstall scripts), or can silently
|
||||
* soft-fail when the toolchain is missing (Dart/Proto), when no prebuild
|
||||
* matches the host platform (Swift), or when the optional install was
|
||||
* skipped or its native build failed (Kotlin).
|
||||
* soft-fail when no prebuild matches the host platform (and a source build was
|
||||
* unavailable / not attempted).
|
||||
*
|
||||
* Either path produces the same observable: the .node binding is absent
|
||||
* at runtime. This helper detects that condition and surfaces a single
|
||||
@@ -17,17 +15,15 @@
|
||||
* support is unavailable instead of silently getting a degraded index.
|
||||
*/
|
||||
|
||||
import { createRequire } from 'module';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { isGrammarRuntimeSkipped } from '../core/tree-sitter/parser-loader.js';
|
||||
import { requireVendoredGrammar } from '../core/tree-sitter/vendored-grammars.js';
|
||||
import { cliWarn } from './cli-message.js';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
|
||||
interface OptionalGrammar {
|
||||
/** Display name in warnings */
|
||||
name: string;
|
||||
/** Module name to require.resolve */
|
||||
/** Vendored grammar package name (directory under vendor/) */
|
||||
pkg: string;
|
||||
/** File extensions this grammar parses */
|
||||
extensions: string[];
|
||||
@@ -109,7 +105,7 @@ export function detectMissingOptionalGrammars(): MissingGrammar[] {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
_require(g.pkg);
|
||||
requireVendoredGrammar(g.pkg);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
* Shows the indexing status of the current repository.
|
||||
*/
|
||||
|
||||
import { findRepo, getStoragePaths, hasKuzuIndex } from '../storage/repo-manager.js';
|
||||
import { getCurrentCommit, isGitRepo, getGitRoot } from '../storage/git.js';
|
||||
import path from 'path';
|
||||
import { findRepo, getStoragePaths, loadMeta, hasKuzuIndex } from '../storage/repo-manager.js';
|
||||
import { getCurrentCommit, getCurrentBranch, isGitRepo, getGitRoot } from '../storage/git.js';
|
||||
import { t } from './i18n/index.js';
|
||||
|
||||
export const statusCommand = async () => {
|
||||
@@ -32,11 +33,34 @@ export const statusCommand = async () => {
|
||||
}
|
||||
|
||||
const currentCommit = getCurrentCommit(repo.repoPath);
|
||||
const isUpToDate = currentCommit === repo.meta.lastCommit;
|
||||
const currentBranch = getCurrentBranch(repo.repoPath);
|
||||
|
||||
// Pick the index matching the checked-out branch (#2106). The flat index
|
||||
// belongs to the primary branch (repo.meta.branch); when the current branch
|
||||
// differs and has its own index, report that one. Legacy/no-branch metas and
|
||||
// detached HEAD fall through to the flat index (unchanged behavior).
|
||||
let activeMeta = repo.meta;
|
||||
let currentBranchIndexed = true;
|
||||
if (currentBranch && repo.meta.branch && currentBranch !== repo.meta.branch) {
|
||||
const { metaPath } = getStoragePaths(repo.repoPath, currentBranch);
|
||||
const branchMeta = await loadMeta(path.dirname(metaPath));
|
||||
if (branchMeta) activeMeta = branchMeta;
|
||||
else currentBranchIndexed = false;
|
||||
}
|
||||
|
||||
console.log(`${t('status.repository')}: ${repo.repoPath}`);
|
||||
console.log(`${t('status.indexed')}: ${new Date(repo.meta.indexedAt).toLocaleString()}`);
|
||||
console.log(`${t('status.indexedCommit')}: ${repo.meta.lastCommit?.slice(0, 7)}`);
|
||||
console.log(`${t('status.branch')}: ${currentBranch ?? t('status.detached')}`);
|
||||
|
||||
if (!currentBranchIndexed) {
|
||||
console.log(
|
||||
`${t('status.status')}: ${t('status.branchNotIndexed', { primary: repo.meta.branch ?? '' })}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const isUpToDate = currentCommit === activeMeta.lastCommit;
|
||||
console.log(`${t('status.indexed')}: ${new Date(activeMeta.indexedAt).toLocaleString()}`);
|
||||
console.log(`${t('status.indexedCommit')}: ${activeMeta.lastCommit?.slice(0, 7)}`);
|
||||
console.log(`${t('status.currentCommit')}: ${currentCommit?.slice(0, 7)}`);
|
||||
console.log(`${t('status.status')}: ${isUpToDate ? t('status.upToDate') : t('status.stale')}`);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Direct CLI Tool Commands
|
||||
*
|
||||
* Exposes GitNexus tools (query, context, impact, cypher) as direct CLI commands.
|
||||
* Exposes GitNexus tools (query, context, impact, cypher, check) as direct CLI commands.
|
||||
* Bypasses MCP entirely — invokes LocalBackend directly for minimal overhead.
|
||||
*
|
||||
* Usage:
|
||||
@@ -62,6 +62,7 @@ export async function queryCommand(
|
||||
queryText: string,
|
||||
options?: {
|
||||
repo?: string;
|
||||
branch?: string;
|
||||
context?: string;
|
||||
goal?: string;
|
||||
limit?: string;
|
||||
@@ -81,6 +82,7 @@ export async function queryCommand(
|
||||
limit: options?.limit ? parseInt(options.limit) : undefined,
|
||||
include_content: options?.content ?? false,
|
||||
repo: options?.repo,
|
||||
branch: options?.branch,
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
@@ -89,6 +91,7 @@ export async function contextCommand(
|
||||
name: string,
|
||||
options?: {
|
||||
repo?: string;
|
||||
branch?: string;
|
||||
file?: string;
|
||||
uid?: string;
|
||||
content?: boolean;
|
||||
@@ -111,6 +114,7 @@ export async function contextCommand(
|
||||
file_path: options?.file,
|
||||
include_content: options?.content ?? false,
|
||||
repo: options?.repo,
|
||||
branch: options?.branch,
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
@@ -120,6 +124,7 @@ export async function impactCommand(
|
||||
options?: {
|
||||
direction?: string;
|
||||
repo?: string;
|
||||
branch?: string;
|
||||
uid?: string;
|
||||
file?: string;
|
||||
kind?: string;
|
||||
@@ -165,6 +170,7 @@ export async function impactCommand(
|
||||
maxDepth: options?.depth ? parseInt(options.depth, 10) : undefined,
|
||||
includeTests: options?.includeTests ?? false,
|
||||
repo: options?.repo,
|
||||
branch: options?.branch,
|
||||
limit: parsedLimit,
|
||||
offset: parsedOffset,
|
||||
summaryOnly: options?.summaryOnly ?? undefined,
|
||||
@@ -188,6 +194,7 @@ export async function cypherCommand(
|
||||
query: string,
|
||||
options?: {
|
||||
repo?: string;
|
||||
branch?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!query?.trim()) {
|
||||
@@ -199,6 +206,7 @@ export async function cypherCommand(
|
||||
const result = await backend.callTool('cypher', {
|
||||
query,
|
||||
repo: options?.repo,
|
||||
branch: options?.branch,
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
@@ -207,12 +215,54 @@ export async function detectChangesCommand(options?: {
|
||||
scope?: string;
|
||||
baseRef?: string;
|
||||
repo?: string;
|
||||
branch?: string;
|
||||
}): Promise<void> {
|
||||
const backend = await getBackend();
|
||||
const result = await backend.callTool('detect_changes', {
|
||||
scope: options?.scope || 'unstaged',
|
||||
base_ref: options?.baseRef,
|
||||
repo: options?.repo,
|
||||
branch: options?.branch,
|
||||
});
|
||||
output(formatDetectChangesResult(result));
|
||||
}
|
||||
|
||||
export async function checkCommand(options?: {
|
||||
cycles?: boolean;
|
||||
json?: boolean;
|
||||
repo?: string;
|
||||
branch?: string;
|
||||
}): Promise<void> {
|
||||
if (!options?.cycles) {
|
||||
process.stderr.write('Usage: gitnexus check --cycles [--json]\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const backend = await getBackend();
|
||||
const result = await backend.callTool('check', {
|
||||
cycles: true,
|
||||
repo: options.repo,
|
||||
branch: options.branch,
|
||||
});
|
||||
if (result?.error) {
|
||||
output(result);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (options.json) {
|
||||
output(result);
|
||||
} else if (result.cycleCount === 0) {
|
||||
output('No circular imports found.');
|
||||
} else {
|
||||
output(
|
||||
result.cycles.map((cycle: { files: string[] }) => cycle.files.join(' -> ')).join('\n'),
|
||||
);
|
||||
}
|
||||
if (result.cycleCount > 0) process.exitCode = 1;
|
||||
} catch (error) {
|
||||
output({ error: error instanceof Error ? error.message : String(error) });
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { isHttpMode, getHttpDimensions, httpEmbed } from './http-client.js';
|
||||
import { resolveEmbeddingConfig } from './config.js';
|
||||
import { applyHfEnvOverrides, isHfDownloadFailure, withHfDownloadRetry } from './hf-env.js';
|
||||
import { getLocalEmbeddingRuntimeBlocker } from './runtime-support.js';
|
||||
import { ensureOnnxRuntimeCommonResolvable } from './onnxruntime-common-resolver.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/**
|
||||
@@ -179,6 +180,9 @@ export const initEmbedder = async (
|
||||
try {
|
||||
// Lazy-load transformers.js only after the runtime guard has passed, so
|
||||
// unsupported platforms never reach the native ONNX import (#1515).
|
||||
// Under pnpm-strict / `pnpm dlx`, transformers' phantom `onnxruntime-common`
|
||||
// import is unresolvable; register the fallback resolver first (#307).
|
||||
ensureOnnxRuntimeCommonResolvable();
|
||||
const { pipeline, env } = await import('@huggingface/transformers');
|
||||
|
||||
// Configure transformers.js environment
|
||||
|
||||
@@ -36,13 +36,8 @@ import {
|
||||
} from './types.js';
|
||||
import { resolveEmbeddingConfig } from './config.js';
|
||||
import { rankExactEmbeddingRows, type ExactEmbeddingRow } from './exact-search.js';
|
||||
import {
|
||||
EMBEDDING_TABLE_NAME,
|
||||
EMBEDDING_INDEX_NAME,
|
||||
CREATE_VECTOR_INDEX_QUERY,
|
||||
STALE_HASH_SENTINEL,
|
||||
} from '../lbug/schema.js';
|
||||
import { loadVectorExtension } from '../lbug/lbug-adapter.js';
|
||||
import { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME, STALE_HASH_SENTINEL } from '../lbug/schema.js';
|
||||
import { loadVectorExtension, createVectorIndex } from '../lbug/lbug-adapter.js';
|
||||
import type { ExtensionInstallPolicy } from '../lbug/extension-loader.js';
|
||||
import { getExactScanLimit } from '../platform/capabilities.js';
|
||||
import { logger } from '../logger.js';
|
||||
@@ -215,24 +210,36 @@ export const batchInsertEmbeddings = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the vector index for semantic search
|
||||
|
||||
* Now indexes the separate CodeEmbedding table.
|
||||
* Delegates extension loading to lbug-adapter's loadVectorExtension(),
|
||||
* which owns the VECTOR extension lifecycle and state tracking.
|
||||
|
||||
* Create the vector index for semantic search (indexes the CodeEmbedding table).
|
||||
*
|
||||
* Keeps the embedding-specific extension-install policy gate here
|
||||
* (ensureVectorExtensionAvailable → resolveEmbeddingInstallPolicy, default
|
||||
* `auto` for the analyze write path), then delegates the actual
|
||||
* `CALL CREATE_VECTOR_INDEX(...)` to the adapter, which runs it through the
|
||||
* unprepared `conn.query()` path. It must NOT go through the injected
|
||||
* `executeQuery` (prepared `conn.prepare()`): LadybugDB cannot prepare that
|
||||
* procedure and fails with "We do not support prepare multiple statements" —
|
||||
* the silent degrade in #2114.
|
||||
*/
|
||||
const createVectorIndex = async (
|
||||
executeQuery: (cypher: string) => Promise<any[]>,
|
||||
): Promise<boolean> => {
|
||||
const buildVectorIndex = async (): Promise<boolean> => {
|
||||
// This pre-check applies the embedding-specific install policy
|
||||
// (resolveEmbeddingInstallPolicy, default `auto` for analyze) before reaching
|
||||
// the adapter. The adapter's createVectorIndex() calls loadVectorExtension()
|
||||
// again, but that's a no-op here: once this gate loads VECTOR the module-level
|
||||
// `vectorExtensionLoaded` flag is set, so the adapter's second call
|
||||
// short-circuits without re-resolving the policy — no double install.
|
||||
if (!(await ensureVectorExtensionAvailable())) return false;
|
||||
try {
|
||||
await executeQuery(CREATE_VECTOR_INDEX_QUERY);
|
||||
return true;
|
||||
return await createVectorIndex();
|
||||
} catch (error) {
|
||||
if (isDev) {
|
||||
logger.warn({ error }, 'Vector index creation warning:');
|
||||
}
|
||||
// Surface this even outside dev: it silently downgrades a user-requested
|
||||
// feature (semantic search) to exact scan. Log under `err` so pino's
|
||||
// standard serializer captures the message/stack — logging under `error`
|
||||
// serialized an Error to `{}` (the empty `{"error":{}}` reported in #2114).
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Vector index creation failed; semantic search will use exact-scan fallback',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -383,7 +390,7 @@ export const runEmbeddingPipeline = async (
|
||||
// Ensure the vector index exists even when no new nodes need embedding.
|
||||
// A prior crash or first-time incremental run may have left CodeEmbedding
|
||||
// rows without ever reaching index creation.
|
||||
const vectorIndexReady = await createVectorIndex(executeQuery);
|
||||
const vectorIndexReady = await buildVectorIndex();
|
||||
|
||||
onProgress({
|
||||
phase: 'ready',
|
||||
@@ -544,7 +551,7 @@ export const runEmbeddingPipeline = async (
|
||||
logger.info('📇 Creating vector index...');
|
||||
}
|
||||
|
||||
const vectorIndexReady = await createVectorIndex(executeQuery);
|
||||
const vectorIndexReady = await buildVectorIndex();
|
||||
|
||||
onProgress({
|
||||
phase: 'ready',
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Make `@huggingface/transformers`' phantom `onnxruntime-common` import
|
||||
* resolvable under strict package-manager layouts (#307, #2069).
|
||||
*
|
||||
* ## Why
|
||||
* transformers' shipped `dist/transformers.node.mjs` does a bare
|
||||
* `import 'onnxruntime-common'`, but transformers' `package.json` never declares
|
||||
* onnxruntime-common (it lists onnxruntime-node / onnxruntime-web / sharp). With
|
||||
* npm's flat `node_modules` — or pnpm with hoisting — the package is hoisted to
|
||||
* a directory on transformers' resolution path and the import resolves by
|
||||
* accident. Under pnpm's isolated store (and therefore `pnpm dlx` / `pnpx`), a
|
||||
* package only sees its *declared* deps, so the import dies with
|
||||
* `ERR_MODULE_NOT_FOUND` before `analyze --embeddings` can run.
|
||||
*
|
||||
* Declaring onnxruntime-common in gitnexus' own dependencies (#2074) does NOT
|
||||
* fix this under pnpm: Node resolves the bare specifier from *transformers'*
|
||||
* module scope, not ours, and overrides/resolutions can only re-version an
|
||||
* existing edge, never add the missing one.
|
||||
*
|
||||
* ## What this does
|
||||
* Install a synchronous, in-thread ESM resolution hook (`module.registerHooks`,
|
||||
* Node >= 22.15) that redirects `onnxruntime-common` to a copy gitnexus can
|
||||
* resolve — but only when the default resolver fails. The redirect target is
|
||||
* preferentially the `onnxruntime-common` that `onnxruntime-node` (the native
|
||||
* binding transformers actually loads) itself depends on, so the redirected copy
|
||||
* is version-matched to that binding even under `pnpm dlx` — where gitnexus'
|
||||
* npm-style `overrides` block does NOT apply, because it is honoured only from a
|
||||
* root manifest and gitnexus is a transitive dependency there. It falls back to
|
||||
* gitnexus' own direct `onnxruntime-common` dependency when that chain can't be
|
||||
* walked. onnxruntime-common is a stable, pure-JS package whose `Tensor` surface
|
||||
* is unchanged across 1.24–1.26, so either target is API-compatible. On working
|
||||
* layouts the default resolver succeeds first and the hook never fires, so
|
||||
* behaviour is unchanged.
|
||||
*
|
||||
* `registerHooks` (synchronous, in-thread) is preferred over the older
|
||||
* `module.register` (async, off-thread, now deprecated — DEP0205, removed in
|
||||
* Node 26): the redirect is a one-line conditional that needs no worker thread,
|
||||
* no separate hook module, and no `data` marshalling.
|
||||
*
|
||||
* ## Safety
|
||||
* Best-effort and idempotent. The hook is installed lazily, only on the
|
||||
* local-embedding code path (after parsing), so it is never registered during
|
||||
* analysis, in the parse workers, or in HTTP embedding mode. Once installed it
|
||||
* is process-global: its resolve closure runs for every subsequent module
|
||||
* resolution, but it passes all of them through untouched and only substitutes a
|
||||
* result for the exact `onnxruntime-common` specifier when that specifier is
|
||||
* genuinely absent — so it cannot mask an unrelated resolution error, and the
|
||||
* per-resolution cost is a single string comparison.
|
||||
*
|
||||
* `module.registerHooks` is marked `@experimental` and requires Node >= 22.15
|
||||
* (the gitnexus engines floor is >= 22.0.0). On older runtimes it is absent and
|
||||
* this is a graceful no-op: embeddings then resolve onnxruntime-common exactly
|
||||
* as before — fine on hoisted layouts. Any failure during installation is
|
||||
* swallowed.
|
||||
*/
|
||||
import { registerHooks, createRequire } from 'node:module';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
let attempted = false;
|
||||
|
||||
/**
|
||||
* Compute the file: URL the hook redirects `onnxruntime-common` to.
|
||||
*
|
||||
* Prefer the copy `onnxruntime-node` (the native binding transformers loads)
|
||||
* depends on, so the redirected module is version-matched to the binding even
|
||||
* under `pnpm dlx`, where transformers keeps its own pinned onnxruntime-node.
|
||||
* The walk resolves transformers' MAIN entry — NOT `@huggingface/transformers/
|
||||
* package.json`, which transformers' `exports` map blocks
|
||||
* (`ERR_PACKAGE_PATH_NOT_EXPORTED`) — then onnxruntime-node, then its
|
||||
* onnxruntime-common. Falls back to gitnexus' own direct dependency (always
|
||||
* resolvable from our scope) when any step fails.
|
||||
*/
|
||||
const resolveOnnxRuntimeCommonUrl = (): string => {
|
||||
const require = createRequire(import.meta.url);
|
||||
try {
|
||||
const transformersMain = require.resolve('@huggingface/transformers');
|
||||
const ortNodePkg = createRequire(transformersMain).resolve('onnxruntime-node/package.json');
|
||||
const common = createRequire(ortNodePkg).resolve('onnxruntime-common');
|
||||
return pathToFileURL(common).href;
|
||||
} catch {
|
||||
return pathToFileURL(require.resolve('onnxruntime-common')).href;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Idempotently install the onnxruntime-common resolution fallback. Call once
|
||||
* immediately before the dynamic `import('@huggingface/transformers')` on the
|
||||
* local-embedding path.
|
||||
*/
|
||||
export const ensureOnnxRuntimeCommonResolvable = (): void => {
|
||||
if (attempted) return;
|
||||
// Mark attempted up-front: a failed attempt must not retry on every
|
||||
// initEmbedder() call, and the hook is process-global — once is enough.
|
||||
attempted = true;
|
||||
|
||||
try {
|
||||
// Node < 22.15 (the gitnexus engines floor is >= 22.0.0): no synchronous
|
||||
// hooks API. Degrade gracefully — the import still works on hoisted layouts.
|
||||
if (typeof registerHooks !== 'function') return;
|
||||
|
||||
const redirectUrl = resolveOnnxRuntimeCommonUrl();
|
||||
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier !== 'onnxruntime-common') return nextResolve(specifier, context);
|
||||
// Honour a real, package-manager-provided copy when one is on the path
|
||||
// (npm / hoisted pnpm); only substitute ours when the specifier is
|
||||
// genuinely absent.
|
||||
try {
|
||||
return nextResolve(specifier, context);
|
||||
} catch (err) {
|
||||
// The phantom import surfaces as ERR_MODULE_NOT_FOUND (or, for a
|
||||
// present-but-exports-broken copy, ERR_PACKAGE_PATH_NOT_EXPORTED).
|
||||
// Rethrow anything else so a genuinely broken install is not masked.
|
||||
const code = (err as { code?: string } | null | undefined)?.code;
|
||||
if (code === 'ERR_MODULE_NOT_FOUND' || code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
|
||||
return { url: redirectUrl, shortCircuit: true };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
logger.debug({ redirectUrl }, 'Installed onnxruntime-common resolution fallback (#307)');
|
||||
} catch (err) {
|
||||
// Never block embeddings on the fallback. On layouts where the package
|
||||
// manager already resolves onnxruntime-common this is unnecessary anyway.
|
||||
logger.debug(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
'onnxruntime-common resolution fallback not installed',
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
interface ImportEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
function findCyclePath(component: string[], adjacency: Map<string, string[]>): string[] {
|
||||
const allowed = new Set(component);
|
||||
const start = component[0];
|
||||
const parents = new Map<string, string | null>([[start, null]]);
|
||||
const queue = [start];
|
||||
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const node = queue[index];
|
||||
for (const next of adjacency.get(node) ?? []) {
|
||||
if (!allowed.has(next)) continue;
|
||||
if (next === start) {
|
||||
const path: string[] = [];
|
||||
let cursor: string | null = node;
|
||||
while (cursor !== null) {
|
||||
path.push(cursor);
|
||||
cursor = parents.get(cursor) ?? null;
|
||||
}
|
||||
path.reverse();
|
||||
return [...path, start];
|
||||
}
|
||||
if (parents.has(next)) continue;
|
||||
parents.set(next, node);
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invariant violation: no cycle found through SCC root.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return one deterministic concrete cycle for every cyclic strongly connected
|
||||
* component in the file import graph.
|
||||
*/
|
||||
export function findImportCycles(edges: ImportEdge[]): string[][] {
|
||||
const adjacency = new Map<string, Set<string>>();
|
||||
for (const { source, target } of edges) {
|
||||
if (!source || !target) continue;
|
||||
const targets = adjacency.get(source) ?? new Set<string>();
|
||||
targets.add(target);
|
||||
adjacency.set(source, targets);
|
||||
if (!adjacency.has(target)) adjacency.set(target, new Set());
|
||||
}
|
||||
|
||||
const sortedAdjacency = new Map(
|
||||
[...adjacency].map(([node, targets]) => [node, [...targets].sort()] as const),
|
||||
);
|
||||
const reverseAdjacency = new Map<string, string[]>();
|
||||
for (const node of sortedAdjacency.keys()) reverseAdjacency.set(node, []);
|
||||
for (const [source, targets] of sortedAdjacency) {
|
||||
for (const target of targets) reverseAdjacency.get(target)!.push(source);
|
||||
}
|
||||
for (const sources of reverseAdjacency.values()) sources.sort();
|
||||
|
||||
const visited = new Set<string>();
|
||||
const finishOrder: string[] = [];
|
||||
const components: string[][] = [];
|
||||
|
||||
for (const start of [...sortedAdjacency.keys()].sort()) {
|
||||
if (visited.has(start)) continue;
|
||||
visited.add(start);
|
||||
const stack = [{ node: start, nextIndex: 0 }];
|
||||
while (stack.length > 0) {
|
||||
const frame = stack[stack.length - 1];
|
||||
const neighbors = sortedAdjacency.get(frame.node) ?? [];
|
||||
if (frame.nextIndex < neighbors.length) {
|
||||
const next = neighbors[frame.nextIndex++];
|
||||
if (!visited.has(next)) {
|
||||
visited.add(next);
|
||||
stack.push({ node: next, nextIndex: 0 });
|
||||
}
|
||||
} else {
|
||||
finishOrder.push(frame.node);
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visited.clear();
|
||||
for (let index = finishOrder.length - 1; index >= 0; index -= 1) {
|
||||
const start = finishOrder[index];
|
||||
if (visited.has(start)) continue;
|
||||
const component: string[] = [];
|
||||
const stack = [start];
|
||||
visited.add(start);
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()!;
|
||||
component.push(node);
|
||||
for (const next of reverseAdjacency.get(node) ?? []) {
|
||||
if (visited.has(next)) continue;
|
||||
visited.add(next);
|
||||
stack.push(next);
|
||||
}
|
||||
}
|
||||
component.sort();
|
||||
components.push(component);
|
||||
}
|
||||
|
||||
return components
|
||||
.filter(
|
||||
(component) =>
|
||||
component.length > 1 || (sortedAdjacency.get(component[0]) ?? []).includes(component[0]),
|
||||
)
|
||||
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
||||
.map((component) => findCyclePath(component, sortedAdjacency));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { requireVendoredGrammar } from '../../../tree-sitter/vendored-grammars.js';
|
||||
import {
|
||||
compilePatterns,
|
||||
runCompiledPatterns,
|
||||
@@ -10,11 +11,11 @@ import type { GrpcDetection, GrpcLanguagePlugin } from './types.js';
|
||||
/**
|
||||
* Protobuf (.proto) tree-sitter plugin for gRPC contract extraction.
|
||||
*
|
||||
* Uses `tree-sitter-proto` (coder3101/tree-sitter-proto) as an
|
||||
* optionalDependency — if the grammar is not installed (e.g. native
|
||||
* compilation failed on an unusual platform), the plugin exports
|
||||
* `null` and the orchestrator falls back to the existing manual
|
||||
* string-sanitizing parser.
|
||||
* Uses `tree-sitter-proto` (coder3101/tree-sitter-proto), loaded from
|
||||
* `vendor/` by absolute path (NEVER copied into node_modules — see
|
||||
* vendored-grammars.ts / #2111). If the grammar's binding cannot be loaded
|
||||
* (e.g. no prebuild for an unusual platform), the plugin exports `null` and the
|
||||
* orchestrator falls back to the existing manual string-sanitizing parser.
|
||||
*
|
||||
* The grammar is vendored in `vendor/tree-sitter-proto/` with
|
||||
* parser.c regenerated against tree-sitter-cli 0.24 (ABI version 14)
|
||||
@@ -22,10 +23,13 @@ import type { GrpcDetection, GrpcLanguagePlugin } from './types.js';
|
||||
* (which loads ABI 13–14).
|
||||
*/
|
||||
|
||||
// Only for `tree-sitter` (a real npm dependency) in the smoke-test below;
|
||||
// the vendored grammar goes through requireVendoredGrammar (never a bare
|
||||
// `_require('tree-sitter-proto')`, which would force a node_modules copy — #2111).
|
||||
const _require = createRequire(import.meta.url);
|
||||
let ProtoGrammar: unknown = null;
|
||||
try {
|
||||
ProtoGrammar = _require('tree-sitter-proto');
|
||||
ProtoGrammar = requireVendoredGrammar('tree-sitter-proto');
|
||||
} catch {
|
||||
// Grammar not installed — PROTO_GRPC_PLUGIN will be null.
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ import {
|
||||
unquoteLiteral,
|
||||
type LanguagePatterns,
|
||||
} from '../tree-sitter-scanner.js';
|
||||
import {
|
||||
METHOD_ANNOTATION_TO_HTTP,
|
||||
isRouteMemberKey,
|
||||
findEnclosingClass,
|
||||
} from '../../../ingestion/route-extractors/spring-shared.js';
|
||||
import type {
|
||||
HttpDetection,
|
||||
HttpFileDetections,
|
||||
@@ -33,14 +38,6 @@ import type {
|
||||
* OkHttp, Java/Apache HttpClient) keep their own focused queries.
|
||||
*/
|
||||
|
||||
const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
|
||||
GetMapping: 'GET',
|
||||
PostMapping: 'POST',
|
||||
PutMapping: 'PUT',
|
||||
DeleteMapping: 'DELETE',
|
||||
PatchMapping: 'PATCH',
|
||||
};
|
||||
|
||||
// Each route-defining annotation has two AST shapes — a positional argument
|
||||
// and a named one — that must both be matched:
|
||||
// @RequestMapping("/api") → (annotation_argument_list (string_literal))
|
||||
@@ -361,19 +358,9 @@ const APACHE_HTTP_CLIENT_PATTERNS = compilePatterns({
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
/**
|
||||
* Find the nearest enclosing class/interface declaration ancestor for
|
||||
* a node, or null if the node is top-level. Tree-sitter's
|
||||
* SyntaxNode.parent walks one level at a time.
|
||||
* Find the nearest enclosing interface declaration ancestor for a node, or
|
||||
* null if the node is top-level.
|
||||
*/
|
||||
function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
|
||||
let cur: Parser.SyntaxNode | null = node.parent;
|
||||
while (cur) {
|
||||
if (cur.type === 'class_declaration') return cur;
|
||||
cur = cur.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findEnclosingInterface(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
|
||||
let cur: Parser.SyntaxNode | null = node.parent;
|
||||
while (cur) {
|
||||
@@ -439,18 +426,6 @@ function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A named annotation argument contributes a route only when its member key is
|
||||
* `path` or `value`; a positional argument (no key node) always qualifies.
|
||||
* This is the JS-side replacement for the in-query `^(path|value)$` filter and
|
||||
* drops Spring's non-route string attributes (`produces`, `consumes`,
|
||||
* `headers`, `name`, `params`) that would otherwise be mis-read as routes.
|
||||
*/
|
||||
function isRouteMemberKey(keyNode: Parser.SyntaxNode | undefined): boolean {
|
||||
if (!keyNode) return true;
|
||||
return keyNode.text === 'path' || keyNode.text === 'value';
|
||||
}
|
||||
|
||||
interface MethodRouteAnnotation {
|
||||
methodNode: Parser.SyntaxNode;
|
||||
methodName: string | null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Parser from 'tree-sitter';
|
||||
import { createRequire } from 'node:module';
|
||||
import { requireVendoredGrammar } from '../../../tree-sitter/vendored-grammars.js';
|
||||
import {
|
||||
compilePatterns,
|
||||
runCompiledPatterns,
|
||||
@@ -60,17 +60,16 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js';
|
||||
* value_argument
|
||||
* string_literal ← the path
|
||||
*
|
||||
* tree-sitter-kotlin is an optional npm dependency — when its native
|
||||
* binding is unavailable the plugin gracefully exports `null` and
|
||||
* `http-patterns/index.ts` skips registration for `.kt`/`.kts` files.
|
||||
* tree-sitter-kotlin is a vendored grammar loaded from `vendor/` by absolute
|
||||
* path (NEVER copied into node_modules — see vendored-grammars.ts / #2111) —
|
||||
* when its native binding is unavailable the plugin gracefully exports `null`
|
||||
* and `http-patterns/index.ts` skips registration for `.kt`/`.kts` files.
|
||||
*/
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
|
||||
/** Loaded lazily; null when the grammar binding isn't installed. */
|
||||
/** Loaded lazily; null when the grammar binding isn't available. */
|
||||
let Kotlin: unknown | null = null;
|
||||
try {
|
||||
Kotlin = _require('tree-sitter-kotlin');
|
||||
Kotlin = requireVendoredGrammar('tree-sitter-kotlin');
|
||||
} catch {
|
||||
Kotlin = null;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { glob } from 'glob';
|
||||
import Parser from 'tree-sitter';
|
||||
import Cpp from 'tree-sitter-cpp';
|
||||
import { requireVendoredGrammar } from '../../tree-sitter/vendored-grammars.js';
|
||||
|
||||
// `tree-sitter-c` is vendored prebuild-only (#2116) and may be absent on a
|
||||
// toolchain-less / `--ignore-scripts` install. Load it via a guarded `_require`
|
||||
// rather than a top-level `import C from 'tree-sitter-c'`, which would throw
|
||||
// ERR_MODULE_NOT_FOUND at module-load and crash analyze (#2091/#2093). When the
|
||||
// `tree-sitter-c` is vendored (#2116), loaded from `vendor/` by absolute path
|
||||
// (NEVER copied into node_modules — see vendored-grammars.ts / #2111). Load it
|
||||
// via a guarded call rather than a top-level `import C from 'tree-sitter-c'`,
|
||||
// which would throw ERR_MODULE_NOT_FOUND at module-load and crash analyze
|
||||
// (#2091/#2093). It may be absent on a platform without a prebuild; when the
|
||||
// binding is absent, `getLanguageForFile` returns null for `.c`/`.h` so C
|
||||
// include-extraction is skipped (C++ is unaffected — its binding always ships).
|
||||
const _require = createRequire(import.meta.url);
|
||||
let C: unknown = null;
|
||||
try {
|
||||
C = _require('tree-sitter-c');
|
||||
C = requireVendoredGrammar('tree-sitter-c');
|
||||
} catch {
|
||||
/* C grammar unavailable — C include extraction degrades to a no-op. */
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* CfgBuilder (issue #2081, M1) — the language-agnostic accumulator.
|
||||
*
|
||||
* A per-language `CfgVisitor` drives this: it creates blocks as it walks
|
||||
* statements, wires edges (including back-edges and break/continue/return/throw
|
||||
* targets resolved via {@link ControlFlowContext}), and calls {@link finish} to
|
||||
* produce the serializable {@link FunctionCfg}. The builder owns the synthetic
|
||||
* ENTRY (index 0) and EXIT blocks and de-duplicates identical edges so repeated
|
||||
* `connect` calls (common when wiring a set of dangling exits) stay idempotent.
|
||||
*
|
||||
* It has no knowledge of any AST — it is exercised directly in unit tests with
|
||||
* hand-built block sequences, which is how the classic CFG hazards are pinned
|
||||
* before the tree-sitter visitor (U2) drives it.
|
||||
*/
|
||||
import type {
|
||||
BasicBlockData,
|
||||
BindingEntry,
|
||||
CfgEdgeData,
|
||||
CfgEdgeKind,
|
||||
FunctionCfg,
|
||||
StatementFacts,
|
||||
} from './types.js';
|
||||
|
||||
interface MutableBlock {
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
/**
|
||||
* Block source accumulated as fragments, joined once in {@link finish}. A
|
||||
* coalescing straight-line run appends one fragment per statement; storing
|
||||
* them as an array and joining at the end keeps that O(n) instead of the
|
||||
* O(n²) of repeatedly concatenating onto a growing string (a long generated
|
||||
* init function is the worst case — see bench/cfg).
|
||||
*/
|
||||
textParts: string[];
|
||||
kind: BasicBlockData['kind'];
|
||||
/**
|
||||
* Per-statement def/use facts in execution order (#2082 M2 U1). Parallel to
|
||||
* the statements that accrued to this block — but self-describing (each
|
||||
* record carries its line): facts-only attaches (ENTRY params, catch params)
|
||||
* mean fact index ≠ text-fragment index.
|
||||
*/
|
||||
statements: StatementFacts[];
|
||||
}
|
||||
|
||||
export class CfgBuilder {
|
||||
private readonly blocks: MutableBlock[] = [];
|
||||
private readonly edges: CfgEdgeData[] = [];
|
||||
private readonly edgeKeys = new Set<string>();
|
||||
readonly entryIndex: number;
|
||||
readonly exitIndex: number;
|
||||
|
||||
constructor(
|
||||
private readonly filePath: string,
|
||||
private readonly functionStartLine: number,
|
||||
private readonly functionEndLine: number,
|
||||
/** Start column of the owning function — disambiguates same-line functions
|
||||
* in the BasicBlock ids (see {@link FunctionCfg.functionStartColumn}).
|
||||
* Defaults to 0 for hand-built test CFGs that don't model columns. */
|
||||
private readonly functionStartColumn: number = 0,
|
||||
) {
|
||||
this.entryIndex = this.newBlock(functionStartLine, functionStartLine, '', 'entry');
|
||||
this.exitIndex = this.newBlock(functionEndLine, functionEndLine, '', 'exit');
|
||||
}
|
||||
|
||||
/** Create a block and return its index. */
|
||||
newBlock(
|
||||
startLine: number,
|
||||
endLine: number,
|
||||
text: string,
|
||||
kind: BasicBlockData['kind'] = 'normal',
|
||||
facts?: StatementFacts,
|
||||
): number {
|
||||
this.blocks.push({
|
||||
startLine,
|
||||
endLine,
|
||||
textParts: text ? [text] : [],
|
||||
kind,
|
||||
statements: facts ? [facts] : [],
|
||||
});
|
||||
return this.blocks.length - 1;
|
||||
}
|
||||
|
||||
/** Add a single edge (idempotent on from+to+kind). */
|
||||
edge(from: number, to: number, kind: CfgEdgeKind): void {
|
||||
const key = `${from}->${to}:${kind}`;
|
||||
if (this.edgeKeys.has(key)) return;
|
||||
this.edgeKeys.add(key);
|
||||
this.edges.push({ from, to, kind });
|
||||
}
|
||||
|
||||
/** Wire a set of dangling exits to a single target block with one kind. */
|
||||
connect(exits: readonly number[], to: number, kind: CfgEdgeKind = 'seq'): void {
|
||||
for (const from of exits) this.edge(from, to, kind);
|
||||
}
|
||||
|
||||
/** Extend a block's end line as more statements accrue to it. */
|
||||
extendBlock(index: number, endLine: number, appendText?: string, facts?: StatementFacts): void {
|
||||
const b = this.blocks[index];
|
||||
if (!b) return;
|
||||
if (endLine > b.endLine) b.endLine = endLine;
|
||||
if (appendText) b.textParts.push(appendText);
|
||||
if (facts) b.statements.push(facts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a facts-only statement record to a block WITHOUT touching its text
|
||||
* or line span (#2082 M2 U1) — bench fingerprints and CFG snapshots include
|
||||
* block text, so harvesting must never perturb it (ENTRY-block param defs
|
||||
* are the canonical use; records that must precede a walked body get their
|
||||
* own facts-only block instead, see the catch-param handling in visitTry).
|
||||
*/
|
||||
attachFacts(index: number, facts: StatementFacts): void {
|
||||
const b = this.blocks[index];
|
||||
if (!b) return;
|
||||
b.statements.push(facts);
|
||||
}
|
||||
|
||||
get blockCount(): number {
|
||||
return this.blocks.length;
|
||||
}
|
||||
|
||||
/** Produce the serializable CFG. Caller is responsible for having wired the
|
||||
* function's dangling exits to {@link exitIndex} before calling.
|
||||
*
|
||||
* Pass `bindings` (the function's binding table, possibly empty) to emit
|
||||
* statement facts (#2082 M2 U1) — every block then carries a `statements`
|
||||
* array. Omit it (hand-built test CFGs, pre-M2 producers) and both fields
|
||||
* are absent, which the reaching-defs solver reports as `no-facts`. */
|
||||
finish(bindings?: readonly BindingEntry[]): FunctionCfg {
|
||||
const withFacts = bindings !== undefined;
|
||||
return {
|
||||
filePath: this.filePath,
|
||||
functionStartLine: this.functionStartLine,
|
||||
functionEndLine: this.functionEndLine,
|
||||
functionStartColumn: this.functionStartColumn,
|
||||
entryIndex: this.entryIndex,
|
||||
exitIndex: this.exitIndex,
|
||||
blocks: this.blocks.map((b, index) => ({
|
||||
index,
|
||||
startLine: b.startLine,
|
||||
endLine: b.endLine,
|
||||
text: b.textParts.join('\n'),
|
||||
kind: b.kind,
|
||||
...(withFacts ? { statements: b.statements } : {}),
|
||||
})),
|
||||
edges: [...this.edges],
|
||||
...(withFacts ? { bindings } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Block indices reachable from `entryIndex` by following edges. Backs the
|
||||
* reachability property tests (R9) over hand-built and visitor-produced CFGs.
|
||||
*/
|
||||
export const reachableBlocks = (cfg: FunctionCfg): Set<number> => {
|
||||
const adj = new Map<number, number[]>();
|
||||
for (const e of cfg.edges) {
|
||||
const list = adj.get(e.from);
|
||||
if (list) list.push(e.to);
|
||||
else adj.set(e.from, [e.to]);
|
||||
}
|
||||
const seen = new Set<number>([cfg.entryIndex]);
|
||||
const stack = [cfg.entryIndex];
|
||||
while (stack.length) {
|
||||
const n = stack.pop() as number;
|
||||
for (const next of adj.get(n) ?? []) {
|
||||
if (!seen.has(next)) {
|
||||
seen.add(next);
|
||||
stack.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* collectFunctionCfgs (issue #2081, M1).
|
||||
*
|
||||
* Walks a parsed file's tree-sitter tree and builds one {@link FunctionCfg} per
|
||||
* CFG-bearing function via the language's {@link CfgVisitor}. Runs IN THE PARSE
|
||||
* WORKER (where the AST lives — KTD1/KTD7); the result rides on
|
||||
* `ParsedFile.cfgSideChannel` across the worker→main boundary.
|
||||
*
|
||||
* Nested functions are enumerated independently — each gets its own CFG, and
|
||||
* appears as an opaque straight-line block in its enclosing function's CFG (the
|
||||
* visitor does not descend into nested function bodies). `maxFunctionLines`
|
||||
* bounds per-function cost: a function whose source span exceeds the cap is
|
||||
* skipped (and counted) rather than walked, so a pathological mega-function
|
||||
* cannot blow up worker time/memory. A cap of `0` means no limit.
|
||||
*/
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import type { CfgVisitor, FunctionCfg } from './types.js';
|
||||
|
||||
/**
|
||||
* Default per-function source-line cap used by the worker when the `--pdg` run
|
||||
* does not specify `pdgMaxFunctionLines`. A function longer than this (almost
|
||||
* always minified/generated code) is skipped rather than walked — its CFG is
|
||||
* both expensive and low-value. Overridable via `PipelineOptions.pdgMaxFunctionLines`.
|
||||
*/
|
||||
export const DEFAULT_PDG_MAX_FUNCTION_LINES = 2000;
|
||||
|
||||
export interface CollectedCfgs {
|
||||
readonly cfgs: readonly FunctionCfg[];
|
||||
/** Functions skipped for exceeding `maxFunctionLines` (0 ⇒ none skipped). */
|
||||
readonly skipped: number;
|
||||
}
|
||||
|
||||
export function collectFunctionCfgs(
|
||||
root: SyntaxNode,
|
||||
visitor: CfgVisitor<SyntaxNode>,
|
||||
filePath: string,
|
||||
maxFunctionLines = 0,
|
||||
): CollectedCfgs {
|
||||
const cfgs: FunctionCfg[] = [];
|
||||
let skipped = 0;
|
||||
const stack: SyntaxNode[] = [root];
|
||||
|
||||
while (stack.length) {
|
||||
const node = stack.pop() as SyntaxNode;
|
||||
if (visitor.isFunction(node)) {
|
||||
const lines = node.endPosition.row - node.startPosition.row + 1;
|
||||
if (maxFunctionLines > 0 && lines > maxFunctionLines) {
|
||||
skipped++;
|
||||
} else {
|
||||
const cfg = visitor.buildFunctionCfg(node, filePath);
|
||||
if (cfg) cfgs.push(cfg);
|
||||
}
|
||||
}
|
||||
// Descend regardless (a skipped mega-function may still contain small
|
||||
// nested functions that are worth a CFG of their own).
|
||||
for (let i = node.namedChildCount - 1; i >= 0; i--) {
|
||||
const child = node.namedChild(i);
|
||||
if (child) stack.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
return { cfgs, skipped };
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* ControlFlowContext (issue #2081 M1; finalizer frames added by #2082 M2 U2).
|
||||
*
|
||||
* Resolves the targets of `break`/`continue` (plain and labeled) as the visitor
|
||||
* descends through loops and switches. Loops and switches push a target frame
|
||||
* on entry and pop it on exit; a labeled statement attaches its label to the
|
||||
* frame of the construct it labels, so `break outer` / `continue outer` resolve
|
||||
* against the right enclosing loop/switch rather than the nearest one.
|
||||
*
|
||||
* M2 adds FINALIZER frames, interleaved on the SAME stack as loop/switch frames
|
||||
* — interleaving is load-bearing: a jump must route through exactly the
|
||||
* `finally` bodies lexically BETWEEN it and its target (target-relative
|
||||
* threading). A `break` whose loop lives entirely inside the `try` crosses no
|
||||
* finally and must keep its direct edge; re-routing it anyway would force the
|
||||
* only path to the in-try continuation through the finally, letting a finally
|
||||
* redefinition falsely KILL in-loop definitions for the downstream
|
||||
* reaching-defs pass (a taint false negative). A parallel stack cannot express
|
||||
* that between-ness, which is why the frames live here.
|
||||
*/
|
||||
import type { CfgBuilder } from './cfg-builder.js';
|
||||
import type { CfgEdgeKind } from './types.js';
|
||||
|
||||
interface LoopFrame {
|
||||
readonly kind: 'loop';
|
||||
/** Block a `continue` jumps to (the loop header / update). */
|
||||
readonly continueTo: number;
|
||||
/** Block a `break` jumps to (the loop exit / join). */
|
||||
readonly breakTo: number;
|
||||
/** All labels naming this construct (`outer: inner: for` carries both). */
|
||||
readonly labels: readonly string[];
|
||||
}
|
||||
|
||||
interface SwitchFrame {
|
||||
readonly kind: 'switch';
|
||||
/** Block a `break` jumps to (after the switch). `continue` is invalid here. */
|
||||
readonly breakTo: number;
|
||||
readonly labels: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A labeled NON-loop statement (`blk: { … break blk; … }`) — break-to-label
|
||||
* targets the synthesized join after the body (tri-review P1: routing such a
|
||||
* break to EXIT removed the real continuation and falsely killed every def
|
||||
* live at the jump for post-construct uses). Matched ONLY by a labeled break
|
||||
* naming it; unlabeled breaks and continues skip it.
|
||||
*/
|
||||
interface BlockFrame {
|
||||
readonly kind: 'block';
|
||||
readonly breakTo: number;
|
||||
readonly labels: readonly string[];
|
||||
}
|
||||
|
||||
/** A `finally` whose body any crossing jump must route through. */
|
||||
export interface FinalizerFrame {
|
||||
readonly kind: 'finalizer';
|
||||
/** Entry block of the finally body. */
|
||||
readonly entry: number;
|
||||
/**
|
||||
* Completion legs registered by jumps that crossed this finally: once the
|
||||
* owning try pops the frame, it wires `finally-exits → to` with `kind` for
|
||||
* each entry. Mutated by the jump handlers via {@link ControlFlowContext}.
|
||||
*/
|
||||
readonly pending: { to: number; kind: CfgEdgeKind }[];
|
||||
}
|
||||
|
||||
type Frame = LoopFrame | SwitchFrame | BlockFrame | FinalizerFrame;
|
||||
type TargetFrame = LoopFrame | SwitchFrame | BlockFrame;
|
||||
|
||||
/** A resolved jump: its ultimate target + the finallys it crosses (inner→outer). */
|
||||
export interface JumpResolution {
|
||||
readonly target: number;
|
||||
readonly finalizers: readonly FinalizerFrame[];
|
||||
}
|
||||
|
||||
export class ControlFlowContext {
|
||||
private readonly stack: Frame[] = [];
|
||||
|
||||
pushLoop(continueTo: number, breakTo: number, labels: readonly string[] = []): void {
|
||||
this.stack.push({ kind: 'loop', continueTo, breakTo, labels });
|
||||
}
|
||||
|
||||
pushSwitch(breakTo: number, labels: readonly string[] = []): void {
|
||||
this.stack.push({ kind: 'switch', breakTo, labels });
|
||||
}
|
||||
|
||||
/** Push a labeled non-loop statement's break-target frame. */
|
||||
pushLabeledBlock(breakTo: number, labels: readonly string[]): void {
|
||||
this.stack.push({ kind: 'block', breakTo, labels });
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a finalizer frame and return it — the owning `visitTry` keeps the
|
||||
* reference to wire {@link FinalizerFrame.pending} after popping it.
|
||||
*/
|
||||
pushFinalizer(entry: number): FinalizerFrame {
|
||||
const frame: FinalizerFrame = { kind: 'finalizer', entry, pending: [] };
|
||||
this.stack.push(frame);
|
||||
return frame;
|
||||
}
|
||||
|
||||
pop(): void {
|
||||
this.stack.pop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `break`: the nearest enclosing loop/switch frame (or, with a
|
||||
* label, the nearest frame carrying that label) plus every finalizer frame
|
||||
* stacked ABOVE it — i.e. exactly the finallys the jump crosses, innermost
|
||||
* first. Returns `undefined` if there is no valid target (malformed input or
|
||||
* an unmodeled label) — the caller falls back to its conservative routing and
|
||||
* threads nothing.
|
||||
*/
|
||||
resolveBreak(label?: string): JumpResolution | undefined {
|
||||
return this.resolve((f) =>
|
||||
label === undefined
|
||||
? f.kind !== 'block' // an unlabeled break never targets a labeled block
|
||||
: f.labels.includes(label),
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve a `continue`: like {@link resolveBreak} but only loop frames match. */
|
||||
resolveContinue(label?: string): JumpResolution | undefined {
|
||||
return this.resolve(
|
||||
(f) => f.kind === 'loop' && (label === undefined || f.labels.includes(label)),
|
||||
(f) => (f as LoopFrame).continueTo,
|
||||
);
|
||||
}
|
||||
|
||||
/** Every active finalizer, innermost first — what a `return` must cross. */
|
||||
finalizersForReturn(): readonly FinalizerFrame[] {
|
||||
const fins: FinalizerFrame[] = [];
|
||||
for (let i = this.stack.length - 1; i >= 0; i--) {
|
||||
const f = this.stack[i];
|
||||
if (f.kind === 'finalizer') fins.push(f);
|
||||
}
|
||||
return fins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Target block for a `break` (no finalizer info) — see {@link resolveBreak}.
|
||||
* Prefer `resolveBreak` + {@link wireJumpThroughFinalizers} in visitors: a
|
||||
* target-only lookup silently loses finalizer threading (the M2 soundness
|
||||
* fix). Kept for target-shape assertions in tests.
|
||||
*/
|
||||
breakTarget(label?: string): number | undefined {
|
||||
return this.resolveBreak(label)?.target;
|
||||
}
|
||||
|
||||
/** Target block for a `continue` — same caveat as {@link breakTarget}. */
|
||||
continueTarget(label?: string): number | undefined {
|
||||
return this.resolveContinue(label)?.target;
|
||||
}
|
||||
|
||||
private resolve(
|
||||
matches: (f: TargetFrame) => boolean,
|
||||
targetOf: (f: TargetFrame) => number = (f) => f.breakTo,
|
||||
): JumpResolution | undefined {
|
||||
const crossed: FinalizerFrame[] = [];
|
||||
for (let i = this.stack.length - 1; i >= 0; i--) {
|
||||
const f = this.stack[i];
|
||||
if (f.kind === 'finalizer') {
|
||||
crossed.push(f);
|
||||
continue;
|
||||
}
|
||||
if (matches(f)) return { target: targetOf(f), finalizers: crossed };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire a jump from `from` to `target`, routing through the finallys it
|
||||
* crosses (innermost first). The first leg keeps the bare jump `kind`
|
||||
* (preserving the "kind ⟹ source-block terminator" invariant in types.ts);
|
||||
* each finally's completion leg is registered as pending on its frame with the
|
||||
* matching `finally-*` kind and wired by the owning try via
|
||||
* {@link drainFinalizerPending} once the finally's exits are known.
|
||||
*
|
||||
* Language-agnostic on purpose (#2082 M2): the threading protocol encodes
|
||||
* three subtle invariants every future language visitor needs identically —
|
||||
* keeping it here means a new visitor cannot drift on any of them.
|
||||
*/
|
||||
export function wireJumpThroughFinalizers(
|
||||
builder: CfgBuilder,
|
||||
from: number,
|
||||
finalizers: readonly FinalizerFrame[],
|
||||
target: number,
|
||||
kind: 'return' | 'break' | 'continue',
|
||||
): void {
|
||||
if (finalizers.length === 0) {
|
||||
builder.edge(from, target, kind);
|
||||
return;
|
||||
}
|
||||
const completionKind = `finally-${kind}` as CfgEdgeKind;
|
||||
builder.edge(from, finalizers[0].entry, kind);
|
||||
for (let i = 0; i < finalizers.length; i++) {
|
||||
const to = i + 1 < finalizers.length ? finalizers[i + 1].entry : target;
|
||||
finalizers[i].pending.push({ to, kind: completionKind });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire a popped finalizer frame's pending completion legs from the finally's
|
||||
* exit blocks. A finally that itself always jumps (`finally { return 2; }`)
|
||||
* has no exits — its pending legs wire nowhere, matching JS's
|
||||
* finally-override semantics.
|
||||
*/
|
||||
export function drainFinalizerPending(
|
||||
builder: CfgBuilder,
|
||||
frame: FinalizerFrame,
|
||||
finallyExits: readonly number[],
|
||||
): void {
|
||||
for (const p of frame.pending) {
|
||||
builder.connect(finallyExits, p.to, p.kind);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* cfg/emit.ts (issue #2081, M1) — serialized side-channel → graph.
|
||||
*
|
||||
* Pure helper: given a file's per-function CFGs (off `ParsedFile.cfgSideChannel`,
|
||||
* produced by the worker in U3), emit one persisted `BasicBlock` node per block
|
||||
* and one `CFG` edge per edge into the {@link KnowledgeGraph}. Invoked from
|
||||
* scope-resolution (run.ts Phase 4) while the disk-backed ParsedFile store is
|
||||
* still live — the only window where the worker-built CFGs are loaded (KTD1/
|
||||
* KTD5). Default (`--pdg` off) runs never call this, so the emitted graph stays
|
||||
* byte-identical to a pre-#2081 run.
|
||||
*
|
||||
* BasicBlock id: `BasicBlock:<filePath>:<functionStartLine>:<functionStartColumn>:<blockIndex>`
|
||||
* (KTD3). The function start line+column segments disambiguate blocks across
|
||||
* multiple functions in one file — including same-line functions — since each
|
||||
* function's block indices restart at 0; blocks carry no `name` (the
|
||||
* BasicBlock table has no such column). The edge KIND
|
||||
* (`seq`/`cond-true`/…) rides in the relationship `reason` — CFG edges are
|
||||
* values of the single `CodeRelation` table's `type` column (`'CFG'`), so the
|
||||
* kind cannot be its own edge type and is queried via `reason`.
|
||||
*/
|
||||
import type { KnowledgeGraph } from '../../graph/types.js';
|
||||
import { generateId } from '../../../lib/utils.js';
|
||||
import { computeReachingDefs } from './reaching-defs.js';
|
||||
import type { BindingEntry, FunctionCfg } from './types.js';
|
||||
|
||||
/**
|
||||
* Default per-function CFG edge cap. A pathological generated function could
|
||||
* otherwise emit an unbounded edge set; the cap bounds graph growth and is
|
||||
* overridable via `--pdg` options. `0` (in options) means no cap (unlimited
|
||||
* — see the `cap` mapping in {@link emitFileCfgs}); `undefined` means this
|
||||
* default.
|
||||
*/
|
||||
export const DEFAULT_MAX_CFG_EDGES_PER_FUNCTION = 5000;
|
||||
|
||||
/**
|
||||
* Default per-function REACHING_DEF edge cap (#2082 M2 KTD9). 4000 mirrors
|
||||
* Joern's per-method `maxNumberOfDefinitions` — the closest production prior
|
||||
* art — but truncates-and-warns instead of silently skipping the function.
|
||||
* Counts (defBlock, useBlock, binding) DEDUPED edges, not statement-level
|
||||
* facts. `0` ⇒ unlimited; `undefined` ⇒ this default.
|
||||
*/
|
||||
export const DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION = 4000;
|
||||
|
||||
/**
|
||||
* Fact-materialization headroom over the edge cap (#2082 M2 U3/F3): facts are
|
||||
* O(defs×uses) BY SPEC in merge-heavy code, and the edge cap alone bounds the
|
||||
* GRAPH, not the per-function memory spike of materializing facts before
|
||||
* dedup. {@link emitFileReachingDefs} hands `edgeCap × this` to
|
||||
* `computeReachingDefs` as `maxFacts` (unlimited when the edge cap is 0) —
|
||||
* single source of truth; the DEFAULT constant below is derived, never the
|
||||
* mechanism.
|
||||
*/
|
||||
export const REACHING_DEF_FACTS_PER_EDGE_CAP = 4;
|
||||
|
||||
/** Derived emit-path fact limit at the default edge cap (bench/doc anchor). */
|
||||
export const DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION =
|
||||
REACHING_DEF_FACTS_PER_EDGE_CAP * DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION;
|
||||
|
||||
export interface CfgEmitResult {
|
||||
blocks: number;
|
||||
edges: number;
|
||||
/** Edges dropped because a function's edge count exceeded the cap. */
|
||||
droppedEdges: number;
|
||||
/** Number of functions that hit the cap. */
|
||||
cappedFunctions: number;
|
||||
}
|
||||
|
||||
const basicBlockId = (
|
||||
filePath: string,
|
||||
functionStartLine: number,
|
||||
functionStartColumn: number,
|
||||
blockIndex: number,
|
||||
): string => `BasicBlock:${filePath}:${functionStartLine}:${functionStartColumn}:${blockIndex}`;
|
||||
|
||||
/**
|
||||
* Whether an untrusted `cfgSideChannel` element is safe to feed to
|
||||
* {@link emitFileCfgs}. Deliberately NOT full FunctionCfg validation — it
|
||||
* checks exactly the fields whose corruption is SILENT given emit's
|
||||
* mechanics: {@link basicBlockId} string-templates every id-anchor value
|
||||
* (filePath, function start line/column, block index, edge endpoints) and
|
||||
* the graph's addNode/addRelationship are no-throw Map inserts. Unchecked,
|
||||
* a missing anchor field cross-wires same-`undefined`-id blocks across
|
||||
* functions (addNode is first-writer-wins), and an edge endpoint that
|
||||
* matches no block index becomes a dangling `BasicBlock:…:<n>` edge that
|
||||
* detonates much later at DB bulk-load instead of throwing here — so
|
||||
* endpoints are checked for MEMBERSHIP in the block-index set, not just
|
||||
* integer-ness. Lives in this module so the guard evolves with the id
|
||||
* templating it defends (#2099 F4; M2 fields that join the id path must
|
||||
* join this check).
|
||||
*/
|
||||
export const isEmitSafeCfg = (cfg: FunctionCfg | undefined | null): cfg is FunctionCfg => {
|
||||
if (
|
||||
typeof cfg?.filePath !== 'string' ||
|
||||
!Number.isInteger(cfg.functionStartLine) ||
|
||||
!Number.isInteger(cfg.functionStartColumn) ||
|
||||
!Array.isArray(cfg.blocks) ||
|
||||
!Array.isArray(cfg.edges)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Contiguity (index === position), not just integer-ness: every consumer —
|
||||
// this module's id templating AND the reaching-defs solver's
|
||||
// position-indexed adjacency arrays — assumes blocks[i].index === i. A
|
||||
// membership-only check would admit a compacted channel ({index:0},{index:5})
|
||||
// whose edge 0→5 passes membership but indexes past the arrays downstream.
|
||||
for (let i = 0; i < cfg.blocks.length; i++) {
|
||||
if (cfg.blocks[i]?.index !== i) return false;
|
||||
}
|
||||
const n = cfg.blocks.length;
|
||||
// entry/exit must land on real blocks — the solver feeds entryIndex straight
|
||||
// into its RPO walk, where an out-of-range index throws and (worse than this
|
||||
// one element) costs the whole FILE's REACHING_DEF pass (tri-review P3).
|
||||
if (
|
||||
!Number.isInteger(cfg.entryIndex) ||
|
||||
cfg.entryIndex < 0 ||
|
||||
cfg.entryIndex >= n ||
|
||||
!Number.isInteger(cfg.exitIndex) ||
|
||||
cfg.exitIndex < 0 ||
|
||||
cfg.exitIndex >= n
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return cfg.edges.every(
|
||||
(e) =>
|
||||
Number.isInteger(e?.from) &&
|
||||
Number.isInteger(e?.to) &&
|
||||
e.from >= 0 &&
|
||||
e.from < n &&
|
||||
e.to >= 0 &&
|
||||
e.to < n,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a structurally-valid CFG's M2 statement facts are safe to feed to
|
||||
* the reaching-defs solver + REACHING_DEF id templating (#2082 U1/U4): the
|
||||
* binding table's name/declLine/declColumn template into edge ids, and
|
||||
* statement def/use indices must stay IN RANGE of the table (an escaping
|
||||
* index would fabricate `undefined`-keyed ids). Deliberately SEPARATE from
|
||||
* {@link isEmitSafeCfg}: malformed facts must cost only the function's
|
||||
* REACHING_DEF projection — degrading to M1 behavior (CFG emitted, no facts)
|
||||
* — never the BasicBlock/CFG layer itself.
|
||||
*/
|
||||
export const hasEmitSafeFacts = (cfg: FunctionCfg): boolean => {
|
||||
const bindings = cfg.bindings;
|
||||
if (bindings === undefined) {
|
||||
// Pre-M2 channel — statements must be absent too.
|
||||
return cfg.blocks.every((b) => b.statements === undefined);
|
||||
}
|
||||
if (!Array.isArray(bindings)) return false;
|
||||
for (const b of bindings) {
|
||||
if (
|
||||
typeof b?.name !== 'string' ||
|
||||
!Number.isInteger(b.declLine) ||
|
||||
!Number.isInteger(b.declColumn)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const bindingCount = bindings.length;
|
||||
const inRange = (i: number): boolean => Number.isInteger(i) && i >= 0 && i < bindingCount;
|
||||
for (const b of cfg.blocks) {
|
||||
const stmts = b.statements;
|
||||
if (stmts === undefined) continue;
|
||||
if (!Array.isArray(stmts)) return false;
|
||||
for (const s of stmts) {
|
||||
if (!Number.isInteger(s?.line) || !Array.isArray(s.defs) || !Array.isArray(s.uses)) {
|
||||
return false;
|
||||
}
|
||||
if (!s.defs.every(inRange) || !s.uses.every(inRange)) return false;
|
||||
if (s.mayDefs !== undefined) {
|
||||
if (!Array.isArray(s.mayDefs) || !s.mayDefs.every(inRange)) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Emit BasicBlock nodes + CFG edges for every function CFG in `cfgs`.
|
||||
*
|
||||
* `maxEdgesPerFunction` caps edges per function. On overflow we stop emitting
|
||||
* that function's remaining edges and call `onWarn` naming the dropped count —
|
||||
* no silent truncation (KTD6/R6). Block nodes are always fully emitted (their
|
||||
* count is bounded by the function's statement count); only edges are capped.
|
||||
*/
|
||||
export function emitFileCfgs(
|
||||
graph: KnowledgeGraph,
|
||||
cfgs: readonly FunctionCfg[],
|
||||
maxEdgesPerFunction: number = DEFAULT_MAX_CFG_EDGES_PER_FUNCTION,
|
||||
onWarn?: (message: string) => void,
|
||||
): CfgEmitResult {
|
||||
const result: CfgEmitResult = { blocks: 0, edges: 0, droppedEdges: 0, cappedFunctions: 0 };
|
||||
const cap = maxEdgesPerFunction > 0 ? maxEdgesPerFunction : Infinity;
|
||||
|
||||
for (const cfg of cfgs) {
|
||||
const { filePath, functionStartLine, functionStartColumn } = cfg;
|
||||
|
||||
for (const b of cfg.blocks) {
|
||||
graph.addNode({
|
||||
id: basicBlockId(filePath, functionStartLine, functionStartColumn, b.index),
|
||||
label: 'BasicBlock',
|
||||
properties: {
|
||||
name: '', // BasicBlock has no name column; identified by id + span
|
||||
filePath,
|
||||
startLine: b.startLine,
|
||||
endLine: b.endLine,
|
||||
text: b.text,
|
||||
},
|
||||
});
|
||||
result.blocks++;
|
||||
}
|
||||
|
||||
let emittedForFn = 0;
|
||||
for (const e of cfg.edges) {
|
||||
if (emittedForFn >= cap) {
|
||||
const dropped = cfg.edges.length - emittedForFn;
|
||||
result.droppedEdges += dropped;
|
||||
result.cappedFunctions++;
|
||||
onWarn?.(
|
||||
`[cfg] ${filePath}:${functionStartLine}: per-function CFG edge cap ` +
|
||||
`(${maxEdgesPerFunction}) reached — dropped ${dropped} of ${cfg.edges.length} edges`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
const sourceId = basicBlockId(filePath, functionStartLine, functionStartColumn, e.from);
|
||||
const targetId = basicBlockId(filePath, functionStartLine, functionStartColumn, e.to);
|
||||
graph.addRelationship({
|
||||
id: generateId('CFG', `${sourceId}->${targetId}:${e.kind}`),
|
||||
type: 'CFG',
|
||||
sourceId,
|
||||
targetId,
|
||||
confidence: 1.0,
|
||||
reason: e.kind, // CfgEdgeKind (seq/cond-true/loop-back/…) — queryable
|
||||
});
|
||||
result.edges++;
|
||||
emittedForFn++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface ReachingDefEmitResult {
|
||||
/** Deduped (defBlock, useBlock, binding) edges persisted. */
|
||||
edges: number;
|
||||
/** Deduped edges dropped by the per-function edge cap. */
|
||||
droppedEdges: number;
|
||||
cappedFunctions: number;
|
||||
/** Functions whose FACT materialization hit the solver's maxFacts limit. */
|
||||
truncatedFunctions: number;
|
||||
/** Functions whose facts failed {@link hasEmitSafeFacts} (CFG kept, facts skipped). */
|
||||
malformedFactFunctions: number;
|
||||
/** Total statement-level facts the solver produced (pre-dedup telemetry). */
|
||||
facts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable identity for a binding inside edge ids (#2082 M2 KTD3/KTD9):
|
||||
* `name:declLine:declCol` for declared bindings, `name@module` for synthetic
|
||||
* ones. Distinct same-name bindings never share a key; identifier characters
|
||||
* cannot contain the id separators.
|
||||
*/
|
||||
const bindingKey = (b: BindingEntry): string =>
|
||||
b.synthetic ? `${b.name}@module` : `${b.name}:${b.declLine}:${b.declColumn}`;
|
||||
|
||||
/**
|
||||
* Compute reaching definitions per function and persist the bounded
|
||||
* REACHING_DEF projection (#2082 M2 U4).
|
||||
*
|
||||
* Facts are DEDUPED to (defBlock, useBlock, binding) before budgeting — the
|
||||
* persisted columns (`from,to,type,confidence,reason,step`; relationship ids
|
||||
* are in-memory-only, the CodeRelation table has no id column) cannot
|
||||
* distinguish finer rows, so statement-indexed ids would only manufacture
|
||||
* byte-identical duplicate rows that burn budget. Statement granularity lives
|
||||
* in the in-memory {@link computeReachingDefs} result, which the M3 taint
|
||||
* engine recomputes on demand — the budget here governs only this projection
|
||||
* and can never drop a taint fact.
|
||||
*
|
||||
* R7 (no silent truncation) covers BOTH layers: the per-function edge cap AND
|
||||
* the solver's fact-materialization limit (which can fire without the edge
|
||||
* cap ever being reached, since dedup is many-to-one) each produce one
|
||||
* unconditional `onWarn`. The edge-cap warn names the top bindings by fact
|
||||
* count — overflow is almost always one variable, which is exactly the datum
|
||||
* M3 tuning wants.
|
||||
*/
|
||||
export function emitFileReachingDefs(
|
||||
graph: KnowledgeGraph,
|
||||
cfgs: readonly FunctionCfg[],
|
||||
maxEdgesPerFunction: number = DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION,
|
||||
onWarn?: (message: string) => void,
|
||||
): ReachingDefEmitResult {
|
||||
const result: ReachingDefEmitResult = {
|
||||
edges: 0,
|
||||
droppedEdges: 0,
|
||||
cappedFunctions: 0,
|
||||
truncatedFunctions: 0,
|
||||
malformedFactFunctions: 0,
|
||||
facts: 0,
|
||||
};
|
||||
const cap = maxEdgesPerFunction > 0 ? maxEdgesPerFunction : Infinity;
|
||||
const maxFacts = Number.isFinite(cap) ? (cap as number) * REACHING_DEF_FACTS_PER_EDGE_CAP : 0; // 0 ⇒ unlimited
|
||||
|
||||
for (const cfg of cfgs) {
|
||||
// Graceful degradation: malformed M2 facts cost only this function's
|
||||
// REACHING_DEF projection — its BasicBlock/CFG layer was already emitted.
|
||||
if (!hasEmitSafeFacts(cfg)) {
|
||||
result.malformedFactFunctions++;
|
||||
onWarn?.(
|
||||
`[reaching-defs] ${cfg.filePath}:${cfg.functionStartLine}: malformed ` +
|
||||
`statement facts (bad binding table or out-of-range fact indices) — ` +
|
||||
`REACHING_DEF skipped for this function; its CFG is unaffected`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const r = computeReachingDefs(cfg, { maxFacts });
|
||||
if (r.status === 'no-facts') continue;
|
||||
result.facts += r.facts.length;
|
||||
|
||||
const { filePath, functionStartLine, functionStartColumn } = cfg;
|
||||
if (r.status === 'truncated') {
|
||||
result.truncatedFunctions++;
|
||||
onWarn?.(
|
||||
`[reaching-defs] ${filePath}:${functionStartLine}: fact materialization ` +
|
||||
`limit (${maxFacts}) reached — facts beyond it were not computed; ` +
|
||||
`the persisted REACHING_DEF projection for this function is sparse`,
|
||||
);
|
||||
} else if (r.status === 'overflow') {
|
||||
result.truncatedFunctions++;
|
||||
onWarn?.(
|
||||
`[reaching-defs] ${filePath}:${functionStartLine}: a basic block exceeds ` +
|
||||
`the def-key stride (≥2^21 coalesced statements — minified/generated ` +
|
||||
`code) — REACHING_DEF skipped for this function (computing any facts ` +
|
||||
`would risk wrong-block aliasing); its CFG is unaffected`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Dedup to (defBlock, useBlock, binding) — facts arrive sorted, so the
|
||||
// deduped order (and therefore cap truncation) is deterministic.
|
||||
const seen = new Set<string>();
|
||||
const deduped: { defBlock: number; useBlock: number; bindingIdx: number }[] = [];
|
||||
for (const f of r.facts) {
|
||||
const key = `${f.def.blockIndex}:${f.use.blockIndex}:${f.bindingIdx}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push({
|
||||
defBlock: f.def.blockIndex,
|
||||
useBlock: f.use.blockIndex,
|
||||
bindingIdx: f.bindingIdx,
|
||||
});
|
||||
}
|
||||
|
||||
let emittedForFn = 0;
|
||||
for (const edge of deduped) {
|
||||
if (emittedForFn >= cap) {
|
||||
const dropped = deduped.length - emittedForFn;
|
||||
result.droppedEdges += dropped;
|
||||
result.cappedFunctions++;
|
||||
// Tallied lazily — cap overflow is the rare path; the common uncapped
|
||||
// case must not pay a per-fact counting pass.
|
||||
const factsPerBinding = new Map<number, number>();
|
||||
for (const f of r.facts) {
|
||||
factsPerBinding.set(f.bindingIdx, (factsPerBinding.get(f.bindingIdx) ?? 0) + 1);
|
||||
}
|
||||
const top = [...factsPerBinding.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0] - b[0])
|
||||
.slice(0, 2)
|
||||
.map(([idx, count]) => `${r.bindings[idx]?.name ?? `#${idx}`}(${count} facts)`)
|
||||
.join(', ');
|
||||
onWarn?.(
|
||||
`[reaching-defs] ${filePath}:${functionStartLine}: per-function ` +
|
||||
`REACHING_DEF edge cap (${maxEdgesPerFunction}) reached — dropped ` +
|
||||
`${dropped} of ${deduped.length} edges; top bindings: ${top}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
const binding = r.bindings[edge.bindingIdx];
|
||||
const sourceId = basicBlockId(
|
||||
filePath,
|
||||
functionStartLine,
|
||||
functionStartColumn,
|
||||
edge.defBlock,
|
||||
);
|
||||
const targetId = basicBlockId(
|
||||
filePath,
|
||||
functionStartLine,
|
||||
functionStartColumn,
|
||||
edge.useBlock,
|
||||
);
|
||||
graph.addRelationship({
|
||||
// Single function anchor — the two block ids share it, so templating
|
||||
// it once halves the id size (ids are in-memory-only but ~4000 of
|
||||
// them per capped function is real transient heap).
|
||||
id: generateId(
|
||||
'REACHING_DEF',
|
||||
`${filePath}:${functionStartLine}:${functionStartColumn}:` +
|
||||
`${edge.defBlock}->${edge.useBlock}:${bindingKey(binding)}`,
|
||||
),
|
||||
type: 'REACHING_DEF',
|
||||
sourceId,
|
||||
targetId,
|
||||
confidence: 1.0,
|
||||
reason: binding.name, // plain source-level name (M0/S1 verdict) — queryable
|
||||
});
|
||||
result.edges++;
|
||||
emittedForFn++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* Reaching definitions (#2082 M2 U3) — classic GEN/KILL monotone fixpoint over
|
||||
* one function's CFG, plus the canonical intra-block statement sweep that
|
||||
* recovers statement-granular def→use facts from M1's coalesced blocks
|
||||
* WITHOUT re-splitting the CFG.
|
||||
*
|
||||
* PURE AND DETERMINISTIC (load-bearing contract):
|
||||
* - Pure function of its inputs — no graph, no logger (warnings are the
|
||||
* caller's job), importable outside the worker. The M3 taint engine calls
|
||||
* this same function in-phase (facts are recomputed on demand, never
|
||||
* retained run-wide — the persisted REACHING_DEF edges are a bounded
|
||||
* projection, never the taint substrate).
|
||||
* - Deterministic — predecessors merge in sorted block-index order,
|
||||
* insertion-ordered Maps/Sets throughout, and the output fact array is
|
||||
* explicitly sorted. Snapshot tests and content-derived edge ids rely on it.
|
||||
*
|
||||
* COMPLEXITY DISCIPLINE (the four-times-repeated repo bug shape is per-item
|
||||
* re-derivation inside the loop): def-sets are SHARED BY REFERENCE, never
|
||||
* deep-copied — a MUST def's kill is total per binding, so a transfer either
|
||||
* aliases the incoming set or replaces it; a MAY def (conditional context —
|
||||
* see StatementFacts.mayDefs) unions WITHOUT killing via a copy-on-extend.
|
||||
* Single-predecessor blocks alias the predecessor's OUT map outright;
|
||||
* multi-pred merges union only bindings whose incoming sets differ by
|
||||
* reference. Iteration is reverse post-order, seeded with every block
|
||||
* (unreachable blocks keep ⊥ IN — correct, their defs reach nothing).
|
||||
* Convergence: sets grow monotonically within the finite def-site universe ⇒
|
||||
* ≤ loop-depth+1 passes in practice.
|
||||
*
|
||||
* `limits.maxFacts` bounds materialization: facts are O(defs×uses) BY SPEC in
|
||||
* merge-heavy code (N branch-arm defs × N later uses = N² facts), and a
|
||||
* 2000-line function can spike 100k+ fact objects on the main thread. The
|
||||
* emit path passes DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION (emit.ts);
|
||||
* M3 passes its own large-but-finite limit and treats `status: 'truncated'`
|
||||
* as a per-function taint-coverage gap.
|
||||
*/
|
||||
import type { BindingEntry, FunctionCfg } from './types.js';
|
||||
|
||||
/** A statement-granular program point within one function's CFG. */
|
||||
export interface ProgramPoint {
|
||||
readonly blockIndex: number;
|
||||
/** Statement index within the block's `statements` array. */
|
||||
readonly stmtIndex: number;
|
||||
readonly line: number;
|
||||
}
|
||||
|
||||
/** One def→use fact: the definition at `def` reaches the use at `use`. */
|
||||
export interface DefUseFact {
|
||||
/** Index into {@link FunctionDefUse.bindings}. */
|
||||
readonly bindingIdx: number;
|
||||
readonly def: ProgramPoint;
|
||||
readonly use: ProgramPoint;
|
||||
}
|
||||
|
||||
export interface ReachingDefsLimits {
|
||||
/**
|
||||
* Maximum number of facts to materialize; the sweep stops early and reports
|
||||
* `status: 'truncated'`. `undefined`/0 ⇒ unlimited.
|
||||
*/
|
||||
readonly maxFacts?: number;
|
||||
}
|
||||
|
||||
export interface FunctionDefUse {
|
||||
/**
|
||||
* `computed` — full facts.
|
||||
* `no-facts` — the CFG carries no statement facts (hand-built or pre-M2
|
||||
* side channel); empty facts, NOT an error.
|
||||
* `truncated` — `limits.maxFacts` hit; `facts` is a deterministic prefix.
|
||||
* `overflow` — a block's statement count breaches the def-key stride; no
|
||||
* facts at all (computing any would risk key aliasing —
|
||||
* wrong-block facts are strictly worse than none). Distinct
|
||||
* from `truncated` so the caller's diagnostic doesn't
|
||||
* misname it as the fact-materialization limit.
|
||||
*/
|
||||
readonly status: 'computed' | 'no-facts' | 'truncated' | 'overflow';
|
||||
/** Pass-through of the CFG's binding table (empty for `no-facts`). */
|
||||
readonly bindings: readonly BindingEntry[];
|
||||
/** Sorted by (def block, def stmt, use block, use stmt, binding). */
|
||||
readonly facts: readonly DefUseFact[];
|
||||
/** Total def / use sites seen (telemetry; independent of truncation). */
|
||||
readonly defCount: number;
|
||||
readonly useCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* def-site key: packs (blockIndex, stmtIndex) into one number. The stride is
|
||||
* a per-BLOCK statement bound, and `maxFunctionLines` caps LINES, not
|
||||
* statements — a minified one-line function coalesces arbitrarily many
|
||||
* statements into one block, so an overflow would silently alias
|
||||
* (block b, stmt STRIDE+k) with (block b+1, stmt k) and fabricate wrong-block
|
||||
* facts. computeReachingDefs therefore range-checks up front and bails to a
|
||||
* sound empty `truncated` result instead of ever letting a key alias.
|
||||
* 2^21 statements per block × blocks ≤ 2^32 stays inside Number's 2^53.
|
||||
*/
|
||||
const STMT_STRIDE = 1 << 21;
|
||||
const defKey = (blockIndex: number, stmtIndex: number): number =>
|
||||
blockIndex * STMT_STRIDE + stmtIndex;
|
||||
|
||||
type DefSet = Set<number>;
|
||||
/** bindingIdx → def-site keys reaching this program point. */
|
||||
type Lattice = Map<number, DefSet>;
|
||||
|
||||
const EMPTY_LATTICE: Lattice = new Map();
|
||||
|
||||
/**
|
||||
* Compute reaching definitions for one function. See the module doc for the
|
||||
* purity/determinism/sharing contract.
|
||||
*/
|
||||
export function computeReachingDefs(cfg: FunctionCfg, limits?: ReachingDefsLimits): FunctionDefUse {
|
||||
if (!cfg.bindings) {
|
||||
return { status: 'no-facts', bindings: [], facts: [], defCount: 0, useCount: 0 };
|
||||
}
|
||||
|
||||
const blocks = cfg.blocks;
|
||||
const n = blocks.length;
|
||||
|
||||
// Key-aliasing guard (see STMT_STRIDE): a block with ≥ STRIDE statements
|
||||
// cannot be keyed without aliasing into the next block's def sites, which
|
||||
// would fabricate wrong-block facts — strictly worse than producing none.
|
||||
// Bail to a sound empty `overflow` result (the emit path warns distinctly).
|
||||
for (const b of blocks) {
|
||||
if ((b.statements?.length ?? 0) >= STMT_STRIDE) {
|
||||
return { status: 'overflow', bindings: cfg.bindings, facts: [], defCount: 0, useCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// ── adjacency (sorted for deterministic merges) ─────────────────────────
|
||||
// A `throw` edge contributes IN(from) ∪ allDefs(from) to its handler, not
|
||||
// OUT: an exception can fire BEFORE the block's defs complete (the seed def
|
||||
// in `let x = seed(); try { x = risky(); } catch { sink(x) }` must reach the
|
||||
// sink) AND between any two defs of a multi-def coalesced block (the parse
|
||||
// def in `x = parse(a); x = normalize(x);` is live exactly when normalize
|
||||
// throws — OUT's last-def-wins misses it). Sound over-approximation;
|
||||
// monotone, so the fixpoint absorbs it. See mergePreds.
|
||||
const preds: { from: number; viaThrow: boolean }[][] = Array.from({ length: n }, () => []);
|
||||
const succs: number[][] = Array.from({ length: n }, () => []);
|
||||
// Handlers whose IN depends on this block's IN (throw edges) — requeued on
|
||||
// IN change, since a genned binding can absorb IN growth without changing
|
||||
// OUT, which would otherwise leave the handler stale.
|
||||
const throwSuccs: number[][] = Array.from({ length: n }, () => []);
|
||||
for (const e of cfg.edges) {
|
||||
// Optional-chained pushes drop out-of-range endpoints defensively — the
|
||||
// emit path validates via isEmitSafeCfg, but this pure function also runs
|
||||
// on hand-built CFGs.
|
||||
succs[e.from]?.push(e.to);
|
||||
preds[e.to]?.push({ from: e.from, viaThrow: e.kind === 'throw' });
|
||||
if (e.kind === 'throw') throwSuccs[e.from]?.push(e.to);
|
||||
}
|
||||
for (const list of preds) {
|
||||
list.sort((a, b) => a.from - b.from || Number(a.viaThrow) - Number(b.viaThrow));
|
||||
// duplicate (from, throw+non-throw) pairs both survive — the throw leg
|
||||
// adds IN(from); the merge dedups set-wise.
|
||||
}
|
||||
for (const list of succs) list.sort((a, b) => a - b);
|
||||
|
||||
// ── per-block GEN + def/use telemetry ────────────────────────────────────
|
||||
// gen[b]: bindingIdx → { set, kills }. A MUST def resets the accumulated
|
||||
// set (kill is total); a MAY def (conditionally-evaluated context — see
|
||||
// StatementFacts.mayDefs) only ADDS: the binding's incoming defs survive,
|
||||
// so the transfer is out[x] = kills ? set : in[x] ∪ set.
|
||||
interface GenEntry {
|
||||
set: DefSet;
|
||||
kills: boolean;
|
||||
}
|
||||
const gen: (Map<number, GenEntry> | null)[] = new Array(n).fill(null);
|
||||
// allDefsGen[b]: bindingIdx → EVERY def-site key in the block (must + may).
|
||||
// This is what a throw edge delivers to its handler: an exception can fire
|
||||
// between any two statements, so every intermediate def may be the live one
|
||||
// at the handler — IN∪OUT alone misses defs overwritten later in the same
|
||||
// coalesced block (`try { x = parse(a); x = normalize(x); } catch { sink(x) }`
|
||||
// — parse's value is exactly what sink sees when normalize throws).
|
||||
const allDefsGen: (Lattice | null)[] = new Array(n).fill(null);
|
||||
const defLine = new Map<number, number>(); // defKey → source line
|
||||
let defCount = 0;
|
||||
let useCount = 0;
|
||||
for (const b of blocks) {
|
||||
const stmts = b.statements;
|
||||
if (!stmts || stmts.length === 0) continue;
|
||||
let g: Map<number, GenEntry> | null = null;
|
||||
let all: Lattice | null = null;
|
||||
for (let i = 0; i < stmts.length; i++) {
|
||||
const s = stmts[i];
|
||||
useCount += s.uses.length;
|
||||
const key = defKey(b.index, i);
|
||||
const record = (d: number, kills: boolean): void => {
|
||||
defCount += 1;
|
||||
defLine.set(key, s.line);
|
||||
if (!g) g = new Map();
|
||||
const entry = g.get(d);
|
||||
if (kills || !entry) {
|
||||
g.set(d, { set: new Set([key]), kills: kills || (entry?.kills ?? false) });
|
||||
} else {
|
||||
entry.set.add(key); // may-def accumulates; never clears
|
||||
}
|
||||
if (!all) all = new Map();
|
||||
const allSet = all.get(d);
|
||||
if (allSet) allSet.add(key);
|
||||
else all.set(d, new Set([key]));
|
||||
};
|
||||
if (s.mayDefs) for (const d of s.mayDefs) record(d, false);
|
||||
for (const d of s.defs) record(d, true);
|
||||
}
|
||||
gen[b.index] = g;
|
||||
allDefsGen[b.index] = all;
|
||||
}
|
||||
|
||||
// ── iteration order: RPO over reachable blocks, then the rest by index ──
|
||||
const order = reversePostOrder(cfg.entryIndex, succs, n);
|
||||
|
||||
// ── fixpoint ────────────────────────────────────────────────────────────
|
||||
const inSets: Lattice[] = new Array(n).fill(EMPTY_LATTICE);
|
||||
const outSets: Lattice[] = new Array(n).fill(EMPTY_LATTICE);
|
||||
|
||||
const inWorklist = new Array(n).fill(true);
|
||||
let pending = n;
|
||||
while (pending > 0) {
|
||||
for (const b of order) {
|
||||
if (!inWorklist[b]) continue;
|
||||
inWorklist[b] = false;
|
||||
pending -= 1;
|
||||
|
||||
const p = preds[b];
|
||||
const inB: Lattice =
|
||||
p.length === 0
|
||||
? EMPTY_LATTICE
|
||||
: p.length === 1 && !p[0].viaThrow
|
||||
? outSets[p[0].from] // alias — zero allocation on straight-line chains
|
||||
: mergePreds(p, inSets, outSets, allDefsGen);
|
||||
const inChanged = !latticeEquals(inSets[b], inB);
|
||||
inSets[b] = inB;
|
||||
|
||||
const g = gen[b];
|
||||
// OUT = overlay(IN): a KILLING gen entry replaces the binding's set; a
|
||||
// may-def-only entry unions with the incoming set (never kills). When
|
||||
// nothing is genned, OUT aliases IN outright.
|
||||
let outB: Lattice;
|
||||
if (!g) {
|
||||
outB = inB;
|
||||
} else {
|
||||
outB = new Map(inB); // copies REFERENCES, never set contents
|
||||
for (const [bindingIdx, entry] of g) {
|
||||
if (entry.kills) {
|
||||
outB.set(bindingIdx, entry.set);
|
||||
} else {
|
||||
const incoming = inB.get(bindingIdx);
|
||||
outB.set(bindingIdx, incoming ? unionSets(incoming, entry.set) : entry.set);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requeue = (s: number): void => {
|
||||
if (!inWorklist[s]) {
|
||||
inWorklist[s] = true;
|
||||
pending += 1;
|
||||
}
|
||||
};
|
||||
if (!latticeEquals(outSets[b], outB)) {
|
||||
outSets[b] = outB;
|
||||
for (const s of succs[b]) requeue(s);
|
||||
}
|
||||
if (inChanged) for (const s of throwSuccs[b]) requeue(s);
|
||||
}
|
||||
}
|
||||
|
||||
// ── statement sweep: recover statement-granular def→use facts ───────────
|
||||
const maxFacts = limits?.maxFacts && limits.maxFacts > 0 ? limits.maxFacts : Infinity;
|
||||
const facts: DefUseFact[] = [];
|
||||
let truncated = false;
|
||||
|
||||
outer: for (const b of blocks) {
|
||||
const stmts = b.statements;
|
||||
if (!stmts || stmts.length === 0) continue;
|
||||
// Lazy overlay of IN — entries are replaced (never mutated) on def, so the
|
||||
// shared sets stay intact.
|
||||
let reach: Lattice | null = null;
|
||||
for (let i = 0; i < stmts.length; i++) {
|
||||
const s = stmts[i];
|
||||
// A use's binding that the SAME statement also defines could be a
|
||||
// read-then-write (`x += 1` — sees prior defs) OR a write-then-read
|
||||
// (`if ((m = re.exec(s)) && m[1])` — sees the same-statement def).
|
||||
// StatementFacts carries no intra-statement order, so emit BOTH: prior
|
||||
// defs ∪ the same-statement def. Sound over-approximation — the extra
|
||||
// self-fact on compound assignments is harmless; missing the
|
||||
// assign-and-test def→use (the most common JS idiom) would be a taint
|
||||
// false negative. May-defs join the self-key set the same way.
|
||||
const sameStmtDefs =
|
||||
s.defs.length > 0 || s.mayDefs?.length ? new Set([...s.defs, ...(s.mayDefs ?? [])]) : null;
|
||||
for (const u of s.uses) {
|
||||
const reaching = (reach ?? inSets[b.index]).get(u);
|
||||
const selfKey = sameStmtDefs?.has(u) ? defKey(b.index, i) : undefined;
|
||||
if (!reaching && selfKey === undefined) continue;
|
||||
const keys =
|
||||
selfKey !== undefined && !reaching?.has(selfKey)
|
||||
? [...(reaching ?? []), selfKey]
|
||||
: [...(reaching ?? [])];
|
||||
for (const key of keys) {
|
||||
if (facts.length >= maxFacts) {
|
||||
truncated = true;
|
||||
break outer;
|
||||
}
|
||||
const defBlock = Math.floor(key / STMT_STRIDE);
|
||||
const defStmt = key % STMT_STRIDE;
|
||||
facts.push({
|
||||
bindingIdx: u,
|
||||
def: { blockIndex: defBlock, stmtIndex: defStmt, line: defLine.get(key) ?? s.line },
|
||||
use: { blockIndex: b.index, stmtIndex: i, line: s.line },
|
||||
});
|
||||
}
|
||||
}
|
||||
if (s.mayDefs?.length) {
|
||||
// Gen WITHOUT kill: the conditional def joins the binding's set.
|
||||
if (!reach) reach = new Map(inSets[b.index]);
|
||||
const key = defKey(b.index, i);
|
||||
for (const d of s.mayDefs) {
|
||||
const prior = reach.get(d);
|
||||
reach.set(d, prior ? unionSets(prior, new Set([key])) : new Set([key]));
|
||||
}
|
||||
}
|
||||
if (s.defs.length > 0) {
|
||||
if (!reach) reach = new Map(inSets[b.index]);
|
||||
for (const d of s.defs) reach.set(d, new Set([defKey(b.index, i)])); // kill + gen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
facts.sort(
|
||||
(a, b) =>
|
||||
a.def.blockIndex - b.def.blockIndex ||
|
||||
a.def.stmtIndex - b.def.stmtIndex ||
|
||||
a.use.blockIndex - b.use.blockIndex ||
|
||||
a.use.stmtIndex - b.use.stmtIndex ||
|
||||
a.bindingIdx - b.bindingIdx,
|
||||
);
|
||||
|
||||
return {
|
||||
status: truncated ? 'truncated' : 'computed',
|
||||
bindings: cfg.bindings,
|
||||
facts,
|
||||
defCount,
|
||||
useCount,
|
||||
};
|
||||
}
|
||||
|
||||
/** RPO over blocks reachable from `entry`; unreachable blocks appended by index. */
|
||||
function reversePostOrder(entry: number, succs: readonly number[][], n: number): number[] {
|
||||
const visited = new Array<boolean>(n).fill(false);
|
||||
const post: number[] = [];
|
||||
// Iterative DFS with an explicit phase stack (children pushed in reverse so
|
||||
// they pop in sorted order — determinism).
|
||||
const stack: { node: number; childIdx: number }[] = [{ node: entry, childIdx: 0 }];
|
||||
visited[entry] = true;
|
||||
while (stack.length) {
|
||||
const top = stack[stack.length - 1];
|
||||
const children = succs[top.node];
|
||||
if (top.childIdx < children.length) {
|
||||
const next = children[top.childIdx];
|
||||
top.childIdx += 1;
|
||||
if (!visited[next]) {
|
||||
visited[next] = true;
|
||||
stack.push({ node: next, childIdx: 0 });
|
||||
}
|
||||
} else {
|
||||
post.push(top.node);
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
const order = post.reverse();
|
||||
for (let b = 0; b < n; b++) if (!visited[b]) order.push(b);
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union predecessor lattices, sharing sets where possible. A normal edge
|
||||
* contributes OUT(from). A THROW edge contributes IN(from) ∪ allDefs(from):
|
||||
* an exception may fire before, between, or after any of the block's defs, so
|
||||
* the handler can observe the incoming state OR any intermediate def — OUT
|
||||
* alone (last-def-wins) misses defs overwritten later in the same block.
|
||||
* IN ∪ allDefs ⊇ OUT, so the throw contribution subsumes it.
|
||||
*/
|
||||
function mergePreds(
|
||||
preds: readonly { from: number; viaThrow: boolean }[],
|
||||
inSets: readonly Lattice[],
|
||||
outSets: readonly Lattice[],
|
||||
allDefsGen: readonly (Lattice | null)[],
|
||||
): Lattice {
|
||||
const merged: Lattice = new Map();
|
||||
const mergeOne = (source: Lattice): void => {
|
||||
for (const [bindingIdx, set] of source) {
|
||||
const existing = merged.get(bindingIdx);
|
||||
if (!existing) {
|
||||
merged.set(bindingIdx, set); // share the first contributor's set
|
||||
} else if (existing !== set) {
|
||||
// Union only when the references differ. Copy-on-extend: `existing`
|
||||
// may be a shared set from another block — never mutate it.
|
||||
let target = existing;
|
||||
let copied = false;
|
||||
for (const key of set) {
|
||||
if (!target.has(key)) {
|
||||
if (!copied) {
|
||||
target = new Set(existing);
|
||||
copied = true;
|
||||
}
|
||||
target.add(key);
|
||||
}
|
||||
}
|
||||
if (copied) merged.set(bindingIdx, target);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const p of preds) {
|
||||
if (p.viaThrow) {
|
||||
mergeOne(inSets[p.from]); // exception may fire pre-defs…
|
||||
const all = allDefsGen[p.from];
|
||||
if (all) mergeOne(all); // …or after ANY of the block's defs
|
||||
} else {
|
||||
mergeOne(outSets[p.from]);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Order-stable union of two def-sets (shares `a` when `b` adds nothing). */
|
||||
function unionSets(a: DefSet, b: DefSet): DefSet {
|
||||
let target = a;
|
||||
let copied = false;
|
||||
for (const key of b) {
|
||||
if (!target.has(key)) {
|
||||
if (!copied) {
|
||||
target = new Set(a);
|
||||
copied = true;
|
||||
}
|
||||
target.add(key);
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/** Per-binding equality with a reference fast path (sets only ever grow). */
|
||||
function latticeEquals(a: Lattice, b: Lattice): boolean {
|
||||
if (a === b) return true;
|
||||
if (a.size !== b.size) return false;
|
||||
for (const [k, bSet] of b) {
|
||||
const aSet = a.get(k);
|
||||
if (aSet === bSet) continue;
|
||||
if (!aSet || aSet.size !== bSet.size) return false;
|
||||
for (const v of bSet) if (!aSet.has(v)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* TraversalResult (issue #2081, M1).
|
||||
*
|
||||
* Visiting a statement (or a statement sequence) returns the block its control
|
||||
* flow ENTERS through, plus the set of blocks whose **normal** control flows
|
||||
* out the bottom (the "dangling exits") — to be wired to the entry of whatever
|
||||
* comes next. Abnormal exits (return/break/continue/throw) are wired directly
|
||||
* to their targets during the walk and are NOT part of `exits`.
|
||||
*
|
||||
* A statement that cannot fall through (e.g. ends in `return`/`throw`, or both
|
||||
* branches of an `if` return) yields an empty `exits` array.
|
||||
*/
|
||||
export interface TraversalResult {
|
||||
/** Block index control enters this statement/sequence through. */
|
||||
readonly entry: number;
|
||||
/** Block indices whose normal control falls out the bottom (may be empty). */
|
||||
readonly exits: readonly number[];
|
||||
}
|
||||
|
||||
/** A sequence of statements that produced no blocks (e.g. an empty body). */
|
||||
export const emptyTraversal = (entry: number): TraversalResult => ({ entry, exits: [entry] });
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* CFG data model — plain, JSON-serializable types (issue #2081, M1).
|
||||
*
|
||||
* These cross the worker→main boundary and the disk-backed/durable ParsedFile
|
||||
* store, so they must contain NO tree-sitter AST references, class instances,
|
||||
* or anything that does not survive `JSON.stringify` → `JSON.parse`. Block and
|
||||
* edge endpoints are referenced by integer index within a function's CFG.
|
||||
*
|
||||
* The per-language `CfgVisitor` (built in the parse worker, where the AST
|
||||
* lives — see the M1 plan KTD1/KTD7) produces a `FunctionCfg` per function; the
|
||||
* array of them is what rides on `ParsedFile.cfgSideChannel`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One distinct declared variable (binding) within a function (#2082 M2 U1).
|
||||
*
|
||||
* Statement facts reference bindings by integer index into
|
||||
* {@link FunctionCfg.bindings} — names appear once per binding instead of once
|
||||
* per occurrence (measured ~4× smaller serialized payload than named records).
|
||||
* Distinct bindings of the same name (shadowing) get distinct entries, which is
|
||||
* what keeps an inner `let x` from falsely killing the outer `x`'s definitions
|
||||
* in the reaching-defs solver. NOTE: no field here may be named `nodeId` — the
|
||||
* durable parsedfile-store reviver dedups objects keyed on that field name.
|
||||
*/
|
||||
export interface BindingEntry {
|
||||
/** Source-level variable name (what the persisted edge's `reason` carries). */
|
||||
readonly name: string;
|
||||
/**
|
||||
* 1-based line/0-based column of the canonical declaration site — `var`
|
||||
* multi-declarations canonicalize to the FIRST declaration in source order.
|
||||
* Both 0 for synthetic bindings.
|
||||
*/
|
||||
readonly declLine: number;
|
||||
readonly declColumn: number;
|
||||
/** How the binding was introduced (param/catch matter to the M3 taint pass). */
|
||||
readonly kind: 'var' | 'let' | 'const' | 'param' | 'catch' | 'function' | 'class' | 'module';
|
||||
/**
|
||||
* True when the name has no in-function declaration site (implicit global,
|
||||
* import, or a variable captured from an enclosing function) — keyed
|
||||
* `name@module` in edge ids instead of `name:line:col`.
|
||||
*/
|
||||
readonly synthetic?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Def/use facts for one harvested statement (or construct header), in
|
||||
* execution order within its block (#2082 M2 U1). `defs`/`uses` are indices
|
||||
* into {@link FunctionCfg.bindings}. A compound assignment / update expression
|
||||
* lists its binding in BOTH. Self-describing — `line` is carried here, never
|
||||
* inferred from the block's text fragments (facts-only records exist, e.g.
|
||||
* params on ENTRY and catch params).
|
||||
*
|
||||
* `mayDefs` (tri-review P1): defs harvested inside CONDITIONALLY-EVALUATED
|
||||
* subexpressions — short-circuit right operands (`a && (x = v)`,
|
||||
* `c ?? (c = load())`), ternary arms, logical-assignment operators, and
|
||||
* switch case-test expressions. The solver treats them as GEN WITHOUT KILL:
|
||||
* treating them as must-defs would falsely kill the prior def on the
|
||||
* not-taken path (a taint false negative on core JS idioms). Optional —
|
||||
* absent means none.
|
||||
*/
|
||||
export interface StatementFacts {
|
||||
readonly line: number;
|
||||
readonly defs: readonly number[];
|
||||
readonly uses: readonly number[];
|
||||
readonly mayDefs?: readonly number[];
|
||||
}
|
||||
|
||||
/** A basic block: a maximal straight-line run of statements between leaders. */
|
||||
export interface BasicBlockData {
|
||||
/** Block index within its function. The synthetic ENTRY is always 0. */
|
||||
readonly index: number;
|
||||
readonly startLine: number;
|
||||
readonly endLine: number;
|
||||
/** Source snippet for the block (empty for synthetic ENTRY/EXIT). */
|
||||
readonly text: string;
|
||||
readonly kind: 'entry' | 'exit' | 'normal';
|
||||
/**
|
||||
* Per-statement def/use facts in execution order (#2082 M2 U1). Present only
|
||||
* when the producing visitor harvests (TS/JS under `--pdg`); absent on
|
||||
* hand-built or pre-M2 CFGs — the reaching-defs solver reports `no-facts`.
|
||||
*/
|
||||
readonly statements?: readonly StatementFacts[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Why one block flows to another — drives the `reason` on the emitted CFG edge.
|
||||
*
|
||||
* Kind invariant (M2): a bare jump kind (`return`/`break`/`continue`) means the
|
||||
* SOURCE block's terminator is that jump statement. A `finally-*` kind marks a
|
||||
* COMPLETION edge out of a `finally` body's exit — the leg that resumes a jump
|
||||
* which was re-routed through the finally (issue #2082 U2). Reusing the bare
|
||||
* kinds on completion edges would silently break consumers that infer the
|
||||
* source block's terminator from the kind, and a single generic kind would lose
|
||||
* WHICH jump each completion edge completes when a shared finally has several
|
||||
* pending targets.
|
||||
*/
|
||||
export type CfgEdgeKind =
|
||||
| 'seq' // straight-line fallthrough
|
||||
| 'cond-true' // branch taken (if/while/for condition true)
|
||||
| 'cond-false' // branch not taken / loop exit
|
||||
| 'loop-back' // back-edge to a loop header
|
||||
| 'break' // break → loop/switch exit (or the finally it must cross)
|
||||
| 'continue' // continue → loop header (or the finally it must cross)
|
||||
| 'return' // return → function EXIT (or the finally it must cross)
|
||||
| 'throw' // throw → nearest handler / finally / EXIT
|
||||
| 'switch-case' // dispatch to a case
|
||||
| 'fallthrough' // switch case → next case (no break)
|
||||
| 'finally-return' // finally exit → resumed return target (EXIT / outer finally)
|
||||
| 'finally-break' // finally exit → resumed break target
|
||||
| 'finally-continue'; // finally exit → resumed continue target
|
||||
|
||||
export interface CfgEdgeData {
|
||||
readonly from: number;
|
||||
readonly to: number;
|
||||
readonly kind: CfgEdgeKind;
|
||||
}
|
||||
|
||||
/** One function's control-flow graph. `cfgSideChannel` is `readonly FunctionCfg[]`. */
|
||||
export interface FunctionCfg {
|
||||
readonly filePath: string;
|
||||
/** Source span of the owning function — anchors the BasicBlock node ids. */
|
||||
readonly functionStartLine: number;
|
||||
readonly functionEndLine: number;
|
||||
/**
|
||||
* Start COLUMN of the owning function. Combined with `functionStartLine` it
|
||||
* disambiguates the BasicBlock node ids when two functions share a start line
|
||||
* — e.g. `{ a: () => x(), b: () => y() }`, where both arrows begin on the same
|
||||
* line and each restarts its block indices at 0. Without the column the ids
|
||||
* collide and the graph's first-writer-wins `addNode` silently drops the
|
||||
* second function's blocks and cross-wires its edges.
|
||||
*/
|
||||
readonly functionStartColumn: number;
|
||||
readonly entryIndex: number;
|
||||
readonly exitIndex: number;
|
||||
readonly blocks: readonly BasicBlockData[];
|
||||
readonly edges: readonly CfgEdgeData[];
|
||||
/**
|
||||
* The function's binding table (#2082 M2 U1) — referenced by index from
|
||||
* {@link BasicBlockData.statements}. Present iff statement facts are.
|
||||
*/
|
||||
readonly bindings?: readonly BindingEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-language CFG strategy. Invoked **in the parse worker** for each function
|
||||
* node. `TNode` is the language's AST node type (tree-sitter `SyntaxNode` for
|
||||
* TS/JS) — kept generic so this module stays AST-library-agnostic. Returns
|
||||
* `undefined` when the node is not a CFG-bearing function (the caller skips it).
|
||||
*/
|
||||
export interface CfgVisitor<TNode = unknown> {
|
||||
buildFunctionCfg(fnNode: TNode, filePath: string): FunctionCfg | undefined;
|
||||
|
||||
/**
|
||||
* Whether `node` is a CFG-bearing function this visitor handles. Lets the
|
||||
* worker enumerate functions (and apply the per-function line budget) by a
|
||||
* cheap node-type test, instead of attempting to build a CFG for every AST
|
||||
* node. `buildFunctionCfg` still re-checks, so this is purely an optimization
|
||||
* + the seam the line-budget hooks into.
|
||||
*/
|
||||
isFunction(node: TNode): boolean;
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
/**
|
||||
* TS/JS def/use harvester (#2082 M2 U1).
|
||||
*
|
||||
* Runs in the parse worker next to the CFG visitor, extracting per-statement
|
||||
* variable definition/use facts that ride the side channel for the
|
||||
* reaching-defs solver (`cfg/reaching-defs.ts`). Output is the per-function
|
||||
* binding table ({@link BindingEntry}[]) plus {@link StatementFacts} records
|
||||
* the visitor attaches to blocks as it walks.
|
||||
*
|
||||
* TWO-PHASE, ORDER-INDEPENDENT (load-bearing): the CFG walk is NOT source-order
|
||||
* — `visitTry` builds the finally body before the protected body, `visitFor`
|
||||
* creates the init block after walking the body, `visitDoWhile` the condition
|
||||
* before the body. Resolving names against a scope stack populated *during*
|
||||
* that walk would mis-resolve common code (`try { var v = 1; } finally
|
||||
* { use(v); }` keys the use synthetically while the def gets the real binding —
|
||||
* the def→use fact silently never forms, a taint false negative). So phase 1
|
||||
* pre-scans the whole function subtree once, collecting every declaration into
|
||||
* a completed lexical scope tree (also resolving `var` hoisting and multi-decl
|
||||
* canonicalization order-independently, eslint-scope style); phase 2 resolves
|
||||
* defs/uses against that finished tree from any walk order.
|
||||
*
|
||||
* v1 def-semantics scope (plan KTD4): var/let/const declarations, assignments
|
||||
* (plain/compound/destructuring), update expressions, function/class
|
||||
* declarations, parameters (incl. defaults/rest/destructured), catch params,
|
||||
* for-in/of heads. EXCLUDED, deliberately: property/member writes (`this.x=`,
|
||||
* `obj.p=` — TypeScript-CFA precedent), and BOTH directions of nested-function
|
||||
* capture — writes to outer variables from nested bodies AND reads of captured
|
||||
* variables inside nested bodies are invisible (nested functions are opaque
|
||||
* blocks in the enclosing CFG; callback flows like `arr.forEach(() => sink(y))`
|
||||
* register no use of `y` — closure/callback dataflow is M4 territory and the
|
||||
* M3 consumer contract must name it).
|
||||
*
|
||||
* Identifiers with no in-function declaration (implicit globals, imports,
|
||||
* variables captured from an enclosing function) resolve to a SYNTHETIC
|
||||
* module-level binding (`name@module`), applied identically by def and use
|
||||
* harvesting so `notDeclared = 1; use(notDeclared)` still forms a fact.
|
||||
*
|
||||
* NOTE: nothing serialized here may carry a field named `nodeId` — the durable
|
||||
* parsedfile-store reviver dedups objects keyed on that field name.
|
||||
*/
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import type { BindingEntry, StatementFacts } from '../types.js';
|
||||
|
||||
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
|
||||
const NESTED_FUNCTION_TYPES = new Set([
|
||||
'function_declaration',
|
||||
'function_expression',
|
||||
'arrow_function',
|
||||
'method_definition',
|
||||
'generator_function_declaration',
|
||||
'generator_function',
|
||||
'async_function_declaration',
|
||||
'async_arrow_function',
|
||||
]);
|
||||
|
||||
/** Function-ish declaration statements whose NAME still binds in the enclosing scope. */
|
||||
const FUNCTION_DECL_TYPES = new Set([
|
||||
'function_declaration',
|
||||
'generator_function_declaration',
|
||||
'async_function_declaration',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Nodes that open a lexical scope for `let`/`const`/`class`/catch bindings.
|
||||
* A `switch` BODY is deliberately ONE scope shared by all case arms (JS
|
||||
* semantics: `case 1: let x = 1; case 2: use(x)` is the same binding).
|
||||
*/
|
||||
const SCOPE_TYPES = new Set([
|
||||
'statement_block',
|
||||
'for_statement',
|
||||
'for_in_statement',
|
||||
'for_of_statement',
|
||||
'catch_clause',
|
||||
'switch_body',
|
||||
]);
|
||||
|
||||
/** Type-position subtrees — identifiers inside them are not value uses. */
|
||||
const TYPE_CONTEXT_TYPES = new Set([
|
||||
'type_annotation',
|
||||
'type_arguments',
|
||||
'type_parameters',
|
||||
'type_predicate_annotation',
|
||||
'asserts_annotation',
|
||||
]);
|
||||
|
||||
interface Scope {
|
||||
readonly parent: Scope | null;
|
||||
/** name → binding index */
|
||||
readonly table: Map<string, number>;
|
||||
}
|
||||
|
||||
export class TsHarvester {
|
||||
private readonly bindings: BindingEntry[] = [];
|
||||
/** Scope-opening node id → its scope. */
|
||||
private readonly scopeByNode = new Map<number, Scope>();
|
||||
private readonly root: Scope = { parent: null, table: new Map() };
|
||||
/** name → synthetic binding index (implicit global / import / captured). */
|
||||
private readonly synthetic = new Map<string, number>();
|
||||
private readonly fnId: number;
|
||||
/**
|
||||
* Innermost enclosing scope per visited node id, filled during the prescan
|
||||
* (which already touches every named node once). Makes phase-2 resolution
|
||||
* O(scope-chain) instead of O(AST-depth) per identifier — a deeply-chained
|
||||
* single-statement expression (generated code) otherwise turns the
|
||||
* parent-chain walk quadratic (tri-review perf finding).
|
||||
*/
|
||||
private readonly nearestScopeCache = new Map<number, Scope>();
|
||||
/**
|
||||
* >0 while walking a conditionally-evaluated subexpression (short-circuit
|
||||
* right operand, ternary arm, logical-assignment target, case test). Defs
|
||||
* found there are MAY-defs — gen without kill (tri-review P1: a must-def
|
||||
* here falsely kills the prior def on the not-taken path).
|
||||
*/
|
||||
private conditionalDepth = 0;
|
||||
|
||||
constructor(private readonly fnNode: SyntaxNode) {
|
||||
this.fnId = fnNode.id;
|
||||
this.scopeByNode.set(fnNode.id, this.root);
|
||||
this.declareParams(fnNode);
|
||||
const body = fnNode.childForFieldName('body');
|
||||
if (body)
|
||||
this.prescan(body, body.type === 'statement_block' ? this.openScope(body) : this.root);
|
||||
}
|
||||
|
||||
/** The completed binding table — pass to `CfgBuilder.finish`. */
|
||||
table(): readonly BindingEntry[] {
|
||||
return this.bindings;
|
||||
}
|
||||
|
||||
// ── phase 1: declaration pre-scan ────────────────────────────────────────
|
||||
|
||||
private openScope(node: SyntaxNode): Scope {
|
||||
const existing = this.scopeByNode.get(node.id);
|
||||
if (existing) return existing;
|
||||
const scope: Scope = { parent: this.nearestScopeOf(node), table: new Map() };
|
||||
this.scopeByNode.set(node.id, scope);
|
||||
return scope;
|
||||
}
|
||||
|
||||
private nearestScopeOf(node: SyntaxNode): Scope {
|
||||
for (let p = node.parent; p; p = p.parent) {
|
||||
const s = this.scopeByNode.get(p.id);
|
||||
if (s) return s;
|
||||
if (p.id === this.fnId) break;
|
||||
}
|
||||
return this.root;
|
||||
}
|
||||
|
||||
private declare(
|
||||
nameNode: SyntaxNode,
|
||||
kind: BindingEntry['kind'],
|
||||
scope: Scope,
|
||||
hoistToRoot: boolean,
|
||||
): void {
|
||||
const target = hoistToRoot ? this.root : scope;
|
||||
const name = nameNode.text;
|
||||
// `var` multi-declaration (and a param + `var` of the same name) is ONE
|
||||
// binding — first declaration in source order is canonical. The dedup is
|
||||
// scoped to the single target table, so an inner `let x` shadowing a root
|
||||
// `var x` still gets its own entry in its own scope.
|
||||
if (target.table.has(name)) return;
|
||||
target.table.set(name, this.bindings.length);
|
||||
this.bindings.push({
|
||||
name,
|
||||
declLine: nameNode.startPosition.row + 1,
|
||||
declColumn: nameNode.startPosition.column,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
|
||||
private declareParams(fnNode: SyntaxNode): void {
|
||||
const params = fnNode.childForFieldName('parameters') ?? fnNode.childForFieldName('parameter');
|
||||
if (!params) return;
|
||||
if (params.type === 'identifier') {
|
||||
this.declare(params, 'param', this.root, true); // `x => …` single-param arrow
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < params.namedChildCount; i++) {
|
||||
const p = params.namedChild(i);
|
||||
if (!p) continue;
|
||||
// TS wraps each param (required_parameter/optional_parameter, field
|
||||
// `pattern`); plain JS puts the pattern directly in formal_parameters.
|
||||
const pattern = p.childForFieldName('pattern') ?? p;
|
||||
this.declarePattern(pattern, 'param', this.root, true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Declare every name bound by a (possibly destructuring) pattern. */
|
||||
private declarePattern(
|
||||
node: SyntaxNode,
|
||||
kind: BindingEntry['kind'],
|
||||
scope: Scope,
|
||||
hoistToRoot: boolean,
|
||||
): void {
|
||||
switch (node.type) {
|
||||
case 'identifier':
|
||||
case 'shorthand_property_identifier_pattern':
|
||||
this.declare(node, kind, scope, hoistToRoot);
|
||||
return;
|
||||
case 'rest_pattern':
|
||||
case 'object_pattern':
|
||||
case 'array_pattern':
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (c) this.declarePattern(c, kind, scope, hoistToRoot);
|
||||
}
|
||||
return;
|
||||
case 'pair_pattern': {
|
||||
const value = node.childForFieldName('value');
|
||||
if (value) this.declarePattern(value, kind, scope, hoistToRoot);
|
||||
return;
|
||||
}
|
||||
case 'assignment_pattern':
|
||||
case 'object_assignment_pattern': {
|
||||
const left = node.childForFieldName('left');
|
||||
if (left) this.declarePattern(left, kind, scope, hoistToRoot);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
// Type annotations / unknown wrappers — descend defensively.
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (c && !TYPE_CONTEXT_TYPES.has(c.type)) {
|
||||
this.declarePattern(c, kind, scope, hoistToRoot);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private prescan(node: SyntaxNode, scope: Scope): void {
|
||||
this.nearestScopeCache.set(node.id, scope);
|
||||
const t = node.type;
|
||||
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) {
|
||||
// A nested function's NAME binds in the enclosing scope; its body is opaque.
|
||||
if (FUNCTION_DECL_TYPES.has(t)) {
|
||||
const name = node.childForFieldName('name');
|
||||
if (name) this.declare(name, 'function', scope, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let childScope = scope;
|
||||
if (SCOPE_TYPES.has(t)) childScope = this.openScope(node);
|
||||
|
||||
switch (t) {
|
||||
case 'lexical_declaration': {
|
||||
const kind = node.child(0)?.type === 'const' ? 'const' : 'let';
|
||||
this.declareDeclarators(node, kind, childScope, false);
|
||||
break;
|
||||
}
|
||||
case 'variable_declaration':
|
||||
this.declareDeclarators(node, 'var', childScope, true);
|
||||
break;
|
||||
case 'class_declaration': {
|
||||
const name = node.childForFieldName('name');
|
||||
if (name) this.declare(name, 'class', childScope, false);
|
||||
break;
|
||||
}
|
||||
case 'catch_clause': {
|
||||
const param = node.childForFieldName('parameter');
|
||||
if (param) this.declarePattern(param, 'catch', childScope, false);
|
||||
break;
|
||||
}
|
||||
case 'for_in_statement':
|
||||
case 'for_of_statement': {
|
||||
// `for (const x of xs)` — the `kind` keyword marks a declaration; a bare
|
||||
// `for (x of xs)` left is an assignment, resolved at use time instead.
|
||||
const kindNode = node.childForFieldName('kind');
|
||||
const left = node.childForFieldName('left');
|
||||
if (kindNode && left) {
|
||||
const k = kindNode.type === 'var' ? 'var' : kindNode.type === 'const' ? 'const' : 'let';
|
||||
this.declarePattern(left, k, childScope, k === 'var');
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (c) this.prescan(c, childScope);
|
||||
}
|
||||
}
|
||||
|
||||
private declareDeclarators(
|
||||
declNode: SyntaxNode,
|
||||
kind: 'var' | 'let' | 'const',
|
||||
scope: Scope,
|
||||
hoistToRoot: boolean,
|
||||
): void {
|
||||
for (let i = 0; i < declNode.namedChildCount; i++) {
|
||||
const d = declNode.namedChild(i);
|
||||
if (d?.type !== 'variable_declarator') continue;
|
||||
const name = d.childForFieldName('name');
|
||||
if (name) this.declarePattern(name, kind, scope, hoistToRoot);
|
||||
}
|
||||
}
|
||||
|
||||
// ── phase 2: per-statement fact extraction ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Def/use facts for one statement (or construct-header expression) node.
|
||||
* Safe from any walk order — resolution consults the completed scope tree.
|
||||
*/
|
||||
facts(node: SyntaxNode): StatementFacts {
|
||||
const acc = new FactAccumulator(node.startPosition.row + 1);
|
||||
this.walkValue(node, acc);
|
||||
return acc.finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* Facts for an expression whose WHOLE evaluation is conditional (switch
|
||||
* case tests, which only run when earlier cases didn't match) — every def
|
||||
* inside becomes a may-def.
|
||||
*/
|
||||
factsConditional(node: SyntaxNode): StatementFacts {
|
||||
const acc = new FactAccumulator(node.startPosition.row + 1);
|
||||
this.conditional(() => this.walkValue(node, acc));
|
||||
return acc.finish();
|
||||
}
|
||||
|
||||
/** Facts for a `for (left in/of right)` head: left binds/assigns, right is used. */
|
||||
forInHeadFacts(stmt: SyntaxNode): StatementFacts {
|
||||
const acc = new FactAccumulator(stmt.startPosition.row + 1);
|
||||
const left = stmt.childForFieldName('left');
|
||||
const right = stmt.childForFieldName('right');
|
||||
if (left) this.walkDefPattern(left, acc);
|
||||
if (right) this.walkValue(right, acc);
|
||||
return acc.finish();
|
||||
}
|
||||
|
||||
/** ENTRY-block facts for the function's parameters (defs + default-value uses). */
|
||||
paramFacts(): StatementFacts | undefined {
|
||||
const fnNode = this.fnNode;
|
||||
const params = fnNode.childForFieldName('parameters') ?? fnNode.childForFieldName('parameter');
|
||||
if (!params) return undefined;
|
||||
const acc = new FactAccumulator(fnNode.startPosition.row + 1);
|
||||
if (params.type === 'identifier') {
|
||||
this.def(params, acc);
|
||||
} else {
|
||||
for (let i = 0; i < params.namedChildCount; i++) {
|
||||
const p = params.namedChild(i);
|
||||
if (!p) continue;
|
||||
const pattern = p.childForFieldName('pattern') ?? p;
|
||||
this.walkDefPattern(pattern, acc);
|
||||
const dflt = p.childForFieldName('value');
|
||||
if (dflt) this.walkValue(dflt, acc);
|
||||
}
|
||||
}
|
||||
return acc.defCount() || acc.useCount() ? acc.finish() : undefined;
|
||||
}
|
||||
|
||||
/** Def fact for a `catch (e)` parameter — prepend to the handler entry block. */
|
||||
catchParamFacts(catchClause: SyntaxNode): StatementFacts | undefined {
|
||||
const param = catchClause.childForFieldName('parameter');
|
||||
if (!param) return undefined;
|
||||
const acc = new FactAccumulator(catchClause.startPosition.row + 1);
|
||||
this.walkDefPattern(param, acc);
|
||||
return acc.defCount() ? acc.finish() : undefined;
|
||||
}
|
||||
|
||||
private resolve(nameNode: SyntaxNode): number {
|
||||
const name = nameNode.text;
|
||||
// Fast path: the prescan cached every visited node's innermost scope, so
|
||||
// resolution walks the SCOPE chain (shallow), not the AST parent chain
|
||||
// (arbitrarily deep in chained expressions). The parent-chain walk remains
|
||||
// as fallback for the few nodes the prescan never visits (e.g. a nested
|
||||
// function declaration's own name node).
|
||||
const cached = this.nearestScopeCache.get(nameNode.id);
|
||||
let startScope: Scope | null = cached ?? null;
|
||||
if (!startScope) {
|
||||
for (let p: SyntaxNode | null = nameNode; p; p = p.parent) {
|
||||
const scope = this.scopeByNode.get(p.id) ?? this.nearestScopeCache.get(p.id);
|
||||
if (scope) {
|
||||
startScope = scope;
|
||||
break;
|
||||
}
|
||||
if (p.id === this.fnId) {
|
||||
startScope = this.root;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let s: Scope | null = startScope; s; s = s.parent) {
|
||||
const idx = s.table.get(name);
|
||||
if (idx !== undefined) return idx;
|
||||
}
|
||||
// No in-function declaration — synthetic module-level binding, shared by
|
||||
// defs and uses so `notDeclared = 1; use(notDeclared)` still forms a fact.
|
||||
let idx = this.synthetic.get(name);
|
||||
if (idx === undefined) {
|
||||
idx = this.bindings.length;
|
||||
this.synthetic.set(name, idx);
|
||||
this.bindings.push({ name, declLine: 0, declColumn: 0, kind: 'module', synthetic: true });
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
private def(nameNode: SyntaxNode, acc: FactAccumulator): void {
|
||||
if (this.conditionalDepth > 0) acc.addMayDef(this.resolve(nameNode));
|
||||
else acc.addDef(this.resolve(nameNode));
|
||||
}
|
||||
|
||||
/** Run `fn` with defs demoted to may-defs (conditionally-evaluated context). */
|
||||
private conditional(fn: () => void): void {
|
||||
this.conditionalDepth++;
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
this.conditionalDepth--;
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip wrappers that don't change the lvalue (`(x) += 1`, `x! ++`). */
|
||||
private unwrapLvalue(node: SyntaxNode): SyntaxNode {
|
||||
let n = node;
|
||||
while (n.type === 'parenthesized_expression' || n.type === 'non_null_expression') {
|
||||
const inner = n.namedChild(0);
|
||||
if (!inner) break;
|
||||
n = inner;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
private use(nameNode: SyntaxNode, acc: FactAccumulator): void {
|
||||
acc.addUse(this.resolve(nameNode));
|
||||
}
|
||||
|
||||
/** Value-position walk: collect uses; route def positions to the pattern walk. */
|
||||
private walkValue(node: SyntaxNode, acc: FactAccumulator): void {
|
||||
const t = node.type;
|
||||
if (TYPE_CONTEXT_TYPES.has(t)) return;
|
||||
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) {
|
||||
// Opaque nested function: its NAME (function declaration) is a def in
|
||||
// the enclosing scope; captured reads/writes inside are invisible (KTD4).
|
||||
if (FUNCTION_DECL_TYPES.has(t)) {
|
||||
const name = node.childForFieldName('name');
|
||||
if (name) this.def(name, acc);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (t) {
|
||||
case 'identifier':
|
||||
case 'shorthand_property_identifier':
|
||||
this.use(node, acc);
|
||||
return;
|
||||
case 'lexical_declaration':
|
||||
case 'variable_declaration':
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const d = node.namedChild(i);
|
||||
if (d?.type !== 'variable_declarator') continue;
|
||||
const name = d.childForFieldName('name');
|
||||
const value = d.childForFieldName('value');
|
||||
// A bare `var x;` mid-function is hoisted and writes NOTHING at
|
||||
// runtime — harvesting it as a def would fabricate a kill of the
|
||||
// live def (`x = source(); var x; sink(x)` must keep source→sink;
|
||||
// tri-review P2). `let`/`const` declarators genuinely initialize.
|
||||
if (name && (value || t === 'lexical_declaration')) {
|
||||
this.walkDefPattern(name, acc);
|
||||
}
|
||||
if (value) this.walkValue(value, acc);
|
||||
}
|
||||
return;
|
||||
case 'assignment_expression': {
|
||||
const left = node.childForFieldName('left');
|
||||
const right = node.childForFieldName('right');
|
||||
if (left) this.walkDefPattern(this.unwrapLvalue(left), acc);
|
||||
if (right) this.walkValue(right, acc);
|
||||
return;
|
||||
}
|
||||
case 'augmented_assignment_expression': {
|
||||
// `x += y` both defines and uses x. The logical-assignment operators
|
||||
// (`||=`, `&&=`, `??=`) only WRITE conditionally — their def is a
|
||||
// may-def (the read always happens).
|
||||
const left = node.childForFieldName('left')
|
||||
? this.unwrapLvalue(node.childForFieldName('left') as SyntaxNode)
|
||||
: null;
|
||||
const right = node.childForFieldName('right');
|
||||
const op = node.childForFieldName('operator')?.type ?? '';
|
||||
const logical = op === '||=' || op === '&&=' || op === '??=';
|
||||
if (left?.type === 'identifier') {
|
||||
if (logical) this.conditional(() => this.def(left, acc));
|
||||
else this.def(left, acc);
|
||||
this.use(left, acc);
|
||||
} else if (left) {
|
||||
this.walkValue(left, acc); // member/subscript target — uses only
|
||||
}
|
||||
// The RHS of a logical assignment is itself conditionally evaluated.
|
||||
if (right) {
|
||||
if (logical) this.conditional(() => this.walkValue(right, acc));
|
||||
else this.walkValue(right, acc);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'update_expression': {
|
||||
const rawArg = node.childForFieldName('argument');
|
||||
const arg = rawArg ? this.unwrapLvalue(rawArg) : null;
|
||||
if (arg?.type === 'identifier') {
|
||||
this.def(arg, acc);
|
||||
this.use(arg, acc);
|
||||
} else if (arg) {
|
||||
this.walkValue(arg, acc);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'binary_expression': {
|
||||
// Short-circuit operators evaluate their RIGHT operand conditionally:
|
||||
// a def inside it (`a && (x = clean())`, `c ?? (c = load())`) must be
|
||||
// a may-def or the not-taken path's prior def is falsely killed
|
||||
// (tri-review P1). Other binary operators evaluate both sides.
|
||||
const left = node.childForFieldName('left');
|
||||
const right = node.childForFieldName('right');
|
||||
const op = node.childForFieldName('operator')?.type ?? '';
|
||||
if (left) this.walkValue(left, acc);
|
||||
if (right) {
|
||||
if (op === '&&' || op === '||' || op === '??') {
|
||||
this.conditional(() => this.walkValue(right, acc));
|
||||
} else {
|
||||
this.walkValue(right, acc);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'ternary_expression': {
|
||||
// Each arm is conditionally evaluated — defs inside are may-defs.
|
||||
const cond = node.childForFieldName('condition');
|
||||
const consequence = node.childForFieldName('consequence');
|
||||
const alternative = node.childForFieldName('alternative');
|
||||
if (cond) this.walkValue(cond, acc);
|
||||
if (consequence) this.conditional(() => this.walkValue(consequence, acc));
|
||||
if (alternative) this.conditional(() => this.walkValue(alternative, acc));
|
||||
return;
|
||||
}
|
||||
case 'class_declaration': {
|
||||
// The class NAME is a def (prescan declared the binding) — without
|
||||
// this case the default walk would record it as a bogus USE in plain
|
||||
// JS (the name is an `identifier` there; in TS it's a type_identifier
|
||||
// and would be silently skipped, losing the def either way). The body
|
||||
// walk picks up field-initializer uses; methods are opaque nested fns.
|
||||
const name = node.childForFieldName('name');
|
||||
if (name) this.def(name, acc);
|
||||
const body = node.childForFieldName('body');
|
||||
if (body) this.walkValue(body, acc);
|
||||
return;
|
||||
}
|
||||
case 'class': {
|
||||
// Class EXPRESSION: its name (if any) binds only inside the class —
|
||||
// not a def in the enclosing function. Walk only the body.
|
||||
const body = node.childForFieldName('body');
|
||||
if (body) this.walkValue(body, acc);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (c) this.walkValue(c, acc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Assignment-target walk: identifiers bind; member/subscript targets are uses. */
|
||||
private walkDefPattern(node: SyntaxNode, acc: FactAccumulator): void {
|
||||
switch (node.type) {
|
||||
case 'identifier':
|
||||
case 'shorthand_property_identifier_pattern':
|
||||
this.def(node, acc);
|
||||
return;
|
||||
case 'rest_pattern':
|
||||
case 'object_pattern':
|
||||
case 'array_pattern':
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (c) this.walkDefPattern(c, acc);
|
||||
}
|
||||
return;
|
||||
case 'pair_pattern': {
|
||||
const key = node.childForFieldName('key');
|
||||
const value = node.childForFieldName('value');
|
||||
if (key?.type === 'computed_property_name') this.walkValue(key, acc);
|
||||
if (value) this.walkDefPattern(value, acc);
|
||||
return;
|
||||
}
|
||||
case 'assignment_pattern':
|
||||
case 'object_assignment_pattern': {
|
||||
const left = node.childForFieldName('left');
|
||||
const right = node.childForFieldName('right');
|
||||
if (left) this.walkDefPattern(left, acc);
|
||||
if (right) this.walkValue(right, acc);
|
||||
return;
|
||||
}
|
||||
case 'member_expression':
|
||||
case 'subscript_expression':
|
||||
// Property/element write — NOT a scalar def (KTD4); its identifiers
|
||||
// (object, computed key) are uses.
|
||||
this.walkValue(node, acc);
|
||||
return;
|
||||
default:
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (c && !TYPE_CONTEXT_TYPES.has(c.type)) this.walkDefPattern(c, acc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ordered, deduplicating def/use collector for one statement record. */
|
||||
class FactAccumulator {
|
||||
private readonly defs: number[] = [];
|
||||
private readonly uses: number[] = [];
|
||||
private readonly mayDefs: number[] = [];
|
||||
private readonly defSeen = new Set<number>();
|
||||
private readonly useSeen = new Set<number>();
|
||||
private readonly mayDefSeen = new Set<number>();
|
||||
|
||||
constructor(private readonly line: number) {}
|
||||
|
||||
addDef(idx: number): void {
|
||||
if (this.defSeen.has(idx)) return;
|
||||
this.defSeen.add(idx);
|
||||
this.defs.push(idx);
|
||||
}
|
||||
|
||||
/** A def that may not execute (conditional context) — gen without kill. */
|
||||
addMayDef(idx: number): void {
|
||||
if (this.mayDefSeen.has(idx)) return;
|
||||
this.mayDefSeen.add(idx);
|
||||
this.mayDefs.push(idx);
|
||||
}
|
||||
|
||||
addUse(idx: number): void {
|
||||
if (this.useSeen.has(idx)) return;
|
||||
this.useSeen.add(idx);
|
||||
this.uses.push(idx);
|
||||
}
|
||||
|
||||
defCount(): number {
|
||||
return this.defs.length + this.mayDefs.length;
|
||||
}
|
||||
|
||||
useCount(): number {
|
||||
return this.uses.length;
|
||||
}
|
||||
|
||||
finish(): StatementFacts {
|
||||
return {
|
||||
line: this.line,
|
||||
defs: this.defs,
|
||||
uses: this.uses,
|
||||
// Optional field stays absent when empty — keeps the serialized
|
||||
// side-channel payload lean (most statements have no may-defs).
|
||||
...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,782 @@
|
||||
/**
|
||||
* TS/JS CfgVisitor (issue #2081, M1).
|
||||
*
|
||||
* Walks a TypeScript/JavaScript function's tree-sitter AST and drives the
|
||||
* language-agnostic {@link CfgBuilder} to produce a serializable
|
||||
* {@link FunctionCfg}. TS and JS share a grammar family (tree-sitter-typescript
|
||||
* reuses tree-sitter-javascript's statement nodes), so one visitor covers both.
|
||||
*
|
||||
* Design — a `visit_<node_type>` dispatch over the statement taxonomy. The
|
||||
* classic CFG hazards (R10) are handled explicitly:
|
||||
* - loops allocate a dedicated **loop-exit** block so `break` has a concrete
|
||||
* target before the loop's successor is known; `continue` targets the
|
||||
* header/increment; the back-edge closes the loop.
|
||||
* - `switch` cases fall through naturally: a case body that does not `break`
|
||||
* yields non-empty `exits`, which we wire to the next case as `fallthrough`;
|
||||
* a case that `break`s wires to the switch exit (via {@link ControlFlowContext})
|
||||
* and yields no fall-out.
|
||||
* - `try/catch/finally` routes both normal completion AND a `throw` in the try
|
||||
* through `finally` (the finally block post-dominates the try/catch); a
|
||||
* `throw` with no catch propagates through finally to the enclosing handler.
|
||||
* - EARLY EXITS THROUGH FINALLY (#2082 M2 U2, closes the M1 soundness gap): a
|
||||
* `break`/`continue`/`return` whose jump CROSSES a `finally` is re-routed to
|
||||
* the finally entry (keeping its bare jump kind), and the finally's exits
|
||||
* gain a `finally-return`/`finally-break`/`finally-continue` completion edge
|
||||
* to the resumed target. Threading is TARGET-RELATIVE via finalizer frames
|
||||
* interleaved on the {@link ControlFlowContext} stack: only the finallys
|
||||
* lexically between the jump and its target thread (a `break` whose loop is
|
||||
* wholly inside the try keeps its direct edge — re-routing it would let a
|
||||
* finally redefinition falsely kill in-loop defs for reaching-defs). Nested
|
||||
* finallys chain inner→outer; finally-as-shared-join conflates exit paths
|
||||
* (sound over-approximation; duplication-per-exit-path was rejected). An
|
||||
* empty/comment-only finally pushes no frame — jumps keep direct edges.
|
||||
* - labeled `break`/`continue` resolve against the labeled construct's frame:
|
||||
* loops/switches carry their full label LIST (`outer: inner: for` resolves
|
||||
* both), and a labeled NON-loop statement (`blk: { … break blk; … }`) gets
|
||||
* a break-target frame whose target is a synthesized join after the body —
|
||||
* the M1 route-to-EXIT fallback removed the real continuation and falsely
|
||||
* killed defs for reaching-defs (tri-review P1).
|
||||
*
|
||||
* Known limitations:
|
||||
* - A jump whose label STILL fails to resolve (malformed source) keeps the
|
||||
* conservative route-to-EXIT + thread-all-finallys fallback in
|
||||
* visitBreak/visitContinue — single-exit preserved, no finally bypassed,
|
||||
* but the continuation path is approximate.
|
||||
* - Exceptional flow stays the sound over-approximation: EVERY protected-region
|
||||
* block edges to the handler (an exception may fire mid-block), which
|
||||
* over-supplies reaching-defs facts into `catch` — extra facts, never false
|
||||
* kills. Per-leader throw precision is deliberately deferred (M3 decides).
|
||||
* - Def/use harvest scope (#2082 M2, see typescript-harvest.ts for the full
|
||||
* v1 semantics table): member/property writes are not scalar defs; nested
|
||||
* function bodies are opaque in BOTH directions (writes to and reads of
|
||||
* captured outer variables are invisible — callback flows are M4 territory);
|
||||
* `case x:` test uses attach to the switch dispatch block (sound
|
||||
* over-approximation of in-order case evaluation).
|
||||
*
|
||||
* Block/edge accounting and reachability are pinned in
|
||||
* `test/unit/cfg/cfg-builder.test.ts` (core) and
|
||||
* `test/unit/cfg/typescript-visitor.test.ts` (this visitor, per hazard).
|
||||
*/
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { CfgBuilder } from '../cfg-builder.js';
|
||||
import {
|
||||
ControlFlowContext,
|
||||
drainFinalizerPending,
|
||||
wireJumpThroughFinalizers,
|
||||
} from '../control-flow-context.js';
|
||||
import type { TraversalResult } from '../traversal-result.js';
|
||||
import type { CfgVisitor, FunctionCfg } from '../types.js';
|
||||
import { TsHarvester } from './typescript-harvest.js';
|
||||
|
||||
/** TS/JS node types that own a CFG-bearing function body. */
|
||||
const TS_FUNCTION_TYPES = new Set([
|
||||
'function_declaration',
|
||||
'function_expression',
|
||||
'arrow_function',
|
||||
'method_definition',
|
||||
'generator_function_declaration',
|
||||
'generator_function',
|
||||
'async_function_declaration',
|
||||
'async_arrow_function',
|
||||
]);
|
||||
|
||||
/** Statement node types that break a basic block (everything else coalesces). */
|
||||
const CONTROL_FLOW_TYPES = new Set([
|
||||
'if_statement',
|
||||
'while_statement',
|
||||
'do_statement',
|
||||
'for_statement',
|
||||
'for_in_statement',
|
||||
'for_of_statement',
|
||||
'switch_statement',
|
||||
'try_statement',
|
||||
'return_statement',
|
||||
'break_statement',
|
||||
'continue_statement',
|
||||
'throw_statement',
|
||||
'labeled_statement',
|
||||
'statement_block',
|
||||
]);
|
||||
|
||||
const LOOP_OR_SWITCH_TYPES = new Set([
|
||||
'while_statement',
|
||||
'do_statement',
|
||||
'for_statement',
|
||||
'for_in_statement',
|
||||
'for_of_statement',
|
||||
'switch_statement',
|
||||
]);
|
||||
|
||||
const startLineOf = (n: SyntaxNode): number => n.startPosition.row + 1;
|
||||
const endLineOf = (n: SyntaxNode): number => n.endPosition.row + 1;
|
||||
|
||||
/** A statement sequence that produced no blocks (empty body) is "transparent". */
|
||||
type SeqResult = TraversalResult | null;
|
||||
|
||||
/**
|
||||
* Per-function walk state. One instance is created per function so the
|
||||
* {@link ControlFlowContext}, exception-handler stack, and pending label are
|
||||
* scoped to that function and never leak across functions.
|
||||
*/
|
||||
class TsCfgWalk {
|
||||
private readonly cfc = new ControlFlowContext();
|
||||
/** Stack of exception-handler entry blocks (catch/finally) a `throw` jumps to. */
|
||||
private readonly handlers: number[] = [];
|
||||
/** Labels awaiting the construct they precede (`outer: inner: for` = both). */
|
||||
private pendingLabels: string[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly builder: CfgBuilder,
|
||||
/** Def/use fact extractor (#2082 M2 U1) — phase-2 only; its scope tree is
|
||||
* already complete, so any walk order resolves names correctly. */
|
||||
private readonly harvest: TsHarvester,
|
||||
) {}
|
||||
|
||||
/** Statements of a block node, ignoring comments. */
|
||||
private statementsOf(block: SyntaxNode): SyntaxNode[] {
|
||||
return block.namedChildren.filter((c) => c.type !== 'comment');
|
||||
}
|
||||
|
||||
/** The `body` block of a node (field, or the first statement_block child). */
|
||||
private bodyBlockOf(node: SyntaxNode): SyntaxNode | undefined {
|
||||
return (
|
||||
node.childForFieldName('body') ?? node.namedChildren.find((c) => c.type === 'statement_block')
|
||||
);
|
||||
}
|
||||
|
||||
/** Visit a body that may be a `statement_block` or a single statement. */
|
||||
private visitBody(node: SyntaxNode | undefined | null): SeqResult {
|
||||
if (!node) return null;
|
||||
if (node.type === 'statement_block') return this.visitSeq(this.statementsOf(node));
|
||||
return this.visitStmt(node);
|
||||
}
|
||||
|
||||
/** Wire a sequence of statements, coalescing straight-line runs into blocks. */
|
||||
visitSeq(stmts: SyntaxNode[]): SeqResult {
|
||||
let entry: number | undefined;
|
||||
let dangling: number[] = [];
|
||||
let openSimple: number | undefined;
|
||||
|
||||
for (const stmt of stmts) {
|
||||
if (CONTROL_FLOW_TYPES.has(stmt.type)) {
|
||||
openSimple = undefined; // close any open straight-line block
|
||||
const res = this.visitStmt(stmt);
|
||||
if (res === null) continue; // transparent (empty nested block)
|
||||
if (entry === undefined) entry = res.entry;
|
||||
else this.builder.connect(dangling, res.entry, 'seq');
|
||||
dangling = [...res.exits];
|
||||
} else {
|
||||
// Simple statement — coalesce into the current straight-line block.
|
||||
if (openSimple === undefined) {
|
||||
const idx = this.builder.newBlock(
|
||||
startLineOf(stmt),
|
||||
endLineOf(stmt),
|
||||
stmt.text,
|
||||
'normal',
|
||||
this.harvest.facts(stmt),
|
||||
);
|
||||
if (entry === undefined) entry = idx;
|
||||
else this.builder.connect(dangling, idx, 'seq');
|
||||
openSimple = idx;
|
||||
dangling = [idx];
|
||||
} else {
|
||||
this.builder.extendBlock(
|
||||
openSimple,
|
||||
endLineOf(stmt),
|
||||
stmt.text,
|
||||
this.harvest.facts(stmt),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (entry === undefined) return null;
|
||||
return { entry, exits: dangling };
|
||||
}
|
||||
|
||||
/** Dispatch one statement to its handler. Non-null except for empty blocks. */
|
||||
visitStmt(stmt: SyntaxNode): SeqResult {
|
||||
switch (stmt.type) {
|
||||
case 'if_statement':
|
||||
return this.visitIf(stmt);
|
||||
case 'while_statement':
|
||||
return this.visitWhile(stmt);
|
||||
case 'do_statement':
|
||||
return this.visitDoWhile(stmt);
|
||||
case 'for_statement':
|
||||
return this.visitFor(stmt);
|
||||
case 'for_in_statement':
|
||||
case 'for_of_statement':
|
||||
return this.visitForIn(stmt);
|
||||
case 'switch_statement':
|
||||
return this.visitSwitch(stmt);
|
||||
case 'try_statement':
|
||||
return this.visitTry(stmt);
|
||||
case 'return_statement':
|
||||
return this.visitReturn(stmt);
|
||||
case 'throw_statement':
|
||||
return this.visitThrow(stmt);
|
||||
case 'break_statement':
|
||||
return this.visitBreak(stmt);
|
||||
case 'continue_statement':
|
||||
return this.visitContinue(stmt);
|
||||
case 'labeled_statement':
|
||||
return this.visitLabeled(stmt);
|
||||
case 'statement_block':
|
||||
return this.visitSeq(this.statementsOf(stmt));
|
||||
default:
|
||||
return this.visitSimple(stmt);
|
||||
}
|
||||
}
|
||||
|
||||
private visitSimple(stmt: SyntaxNode): TraversalResult {
|
||||
const idx = this.builder.newBlock(
|
||||
startLineOf(stmt),
|
||||
endLineOf(stmt),
|
||||
stmt.text,
|
||||
'normal',
|
||||
this.harvest.facts(stmt),
|
||||
);
|
||||
return { entry: idx, exits: [idx] };
|
||||
}
|
||||
|
||||
private visitReturn(stmt: SyntaxNode): TraversalResult {
|
||||
// Harvest the argument expression's uses — `return x` blocks live in this
|
||||
// dedicated handler, not visitSeq, and were a silently-missed site once.
|
||||
const idx = this.builder.newBlock(
|
||||
startLineOf(stmt),
|
||||
endLineOf(stmt),
|
||||
stmt.text,
|
||||
'normal',
|
||||
this.harvest.facts(stmt),
|
||||
);
|
||||
// A return crosses EVERY active finally before reaching EXIT.
|
||||
wireJumpThroughFinalizers(
|
||||
this.builder,
|
||||
idx,
|
||||
this.cfc.finalizersForReturn(),
|
||||
this.builder.exitIndex,
|
||||
'return',
|
||||
);
|
||||
return { entry: idx, exits: [] };
|
||||
}
|
||||
|
||||
private visitThrow(stmt: SyntaxNode): TraversalResult {
|
||||
const idx = this.builder.newBlock(
|
||||
startLineOf(stmt),
|
||||
endLineOf(stmt),
|
||||
stmt.text,
|
||||
'normal',
|
||||
this.harvest.facts(stmt),
|
||||
);
|
||||
this.builder.edge(idx, this.currentHandler(), 'throw');
|
||||
return { entry: idx, exits: [] };
|
||||
}
|
||||
|
||||
private visitBreak(stmt: SyntaxNode): TraversalResult {
|
||||
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
|
||||
const res = this.cfc.resolveBreak(this.labelOf(stmt));
|
||||
// An unresolved target — a label this visitor doesn't model (a stacked
|
||||
// outer label like `outer: inner: for`, or a labeled non-loop block) —
|
||||
// would otherwise leave this block with NO out-edge, stranding it and
|
||||
// breaking the single-exit invariant a downstream post-dominator / PDG pass
|
||||
// relies on. Conservatively route an unresolved jump to the function EXIT
|
||||
// ("escapes the function") and thread ALL active finallys — a superset of
|
||||
// the truly-crossed set (the real target is somewhere in the function, so
|
||||
// execution provably runs every finally between the jump and wherever it
|
||||
// lands... up to the ones the conservative EXIT routing over-includes).
|
||||
// Sound for dataflow either way: extra paths, never a bypassed finally.
|
||||
const { target, finalizers } = res ?? {
|
||||
target: this.builder.exitIndex,
|
||||
finalizers: this.cfc.finalizersForReturn(),
|
||||
};
|
||||
wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'break');
|
||||
return { entry: idx, exits: [] };
|
||||
}
|
||||
|
||||
private visitContinue(stmt: SyntaxNode): TraversalResult {
|
||||
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
|
||||
const res = this.cfc.resolveContinue(this.labelOf(stmt));
|
||||
// See visitBreak: an unresolved label routes to EXIT (threading all
|
||||
// active finallys) to preserve single-exit without bypassing a finally.
|
||||
const { target, finalizers } = res ?? {
|
||||
target: this.builder.exitIndex,
|
||||
finalizers: this.cfc.finalizersForReturn(),
|
||||
};
|
||||
wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'continue');
|
||||
return { entry: idx, exits: [] };
|
||||
}
|
||||
|
||||
private visitLabeled(stmt: SyntaxNode): SeqResult {
|
||||
const body =
|
||||
stmt.childForFieldName('body') ?? stmt.namedChildren[stmt.namedChildren.length - 1];
|
||||
const label = this.labelOf(stmt);
|
||||
if (body && (LOOP_OR_SWITCH_TYPES.has(body.type) || body.type === 'labeled_statement')) {
|
||||
// Loop/switch consumes the accumulated labels via takeLabels(); a nested
|
||||
// labeled_statement keeps accumulating (`outer: inner: for` → both
|
||||
// labels land on the loop frame).
|
||||
if (label) this.pendingLabels.push(label);
|
||||
const res = this.visitStmt(body);
|
||||
this.pendingLabels = []; // clear leftovers if the construct didn't consume
|
||||
return res;
|
||||
}
|
||||
// Labeled NON-loop statement (`blk: { … break blk; … }`): break-to-label
|
||||
// targets a synthesized join after the body. Routing it to EXIT instead
|
||||
// (the M1 behavior) removed the real continuation and falsely killed
|
||||
// every def live at the jump for post-construct uses (tri-review P1).
|
||||
const labels = [...this.pendingLabels, ...(label ? [label] : [])];
|
||||
this.pendingLabels = [];
|
||||
const join = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
|
||||
this.cfc.pushLabeledBlock(join, labels);
|
||||
const res = this.visitBody(body);
|
||||
this.cfc.pop();
|
||||
if (res) this.builder.connect(res.exits, join, 'seq');
|
||||
return { entry: res?.entry ?? join, exits: [join] };
|
||||
}
|
||||
|
||||
private visitIf(stmt: SyntaxNode): TraversalResult {
|
||||
const cond = stmt.childForFieldName('condition') ?? stmt;
|
||||
const condBlock = this.builder.newBlock(
|
||||
startLineOf(stmt),
|
||||
endLineOf(cond),
|
||||
cond.text,
|
||||
'normal',
|
||||
this.harvest.facts(cond),
|
||||
);
|
||||
|
||||
const exits: number[] = [];
|
||||
|
||||
const thenRes = this.visitBody(stmt.childForFieldName('consequence'));
|
||||
if (thenRes) {
|
||||
this.builder.edge(condBlock, thenRes.entry, 'cond-true');
|
||||
exits.push(...thenRes.exits);
|
||||
} else {
|
||||
exits.push(condBlock); // empty then — true path falls through
|
||||
}
|
||||
|
||||
const elseNode = this.elseBodyOf(stmt);
|
||||
if (elseNode) {
|
||||
const elseRes = this.visitBody(elseNode);
|
||||
if (elseRes) {
|
||||
this.builder.edge(condBlock, elseRes.entry, 'cond-false');
|
||||
exits.push(...elseRes.exits);
|
||||
} else {
|
||||
exits.push(condBlock); // empty else block
|
||||
}
|
||||
} else {
|
||||
exits.push(condBlock); // no else — false path falls through to the join
|
||||
}
|
||||
|
||||
return { entry: condBlock, exits: [...new Set(exits)] };
|
||||
}
|
||||
|
||||
/** The else body node (unwraps an `else_clause` wrapper if present). */
|
||||
private elseBodyOf(ifStmt: SyntaxNode): SyntaxNode | undefined {
|
||||
const alt = ifStmt.childForFieldName('alternative');
|
||||
if (!alt) return undefined;
|
||||
if (alt.type === 'else_clause') {
|
||||
return alt.childForFieldName('body') ?? alt.namedChildren[0];
|
||||
}
|
||||
return alt;
|
||||
}
|
||||
|
||||
private visitWhile(stmt: SyntaxNode): TraversalResult {
|
||||
const labels = this.takeLabels();
|
||||
const cond = stmt.childForFieldName('condition') ?? stmt;
|
||||
const header = this.builder.newBlock(
|
||||
startLineOf(stmt),
|
||||
endLineOf(cond),
|
||||
cond.text,
|
||||
'normal',
|
||||
this.harvest.facts(cond),
|
||||
);
|
||||
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
|
||||
|
||||
this.cfc.pushLoop(header, loopExit, labels);
|
||||
const body = this.visitBody(this.bodyBlockOf(stmt));
|
||||
this.cfc.pop();
|
||||
|
||||
if (body) {
|
||||
this.builder.edge(header, body.entry, 'cond-true');
|
||||
this.builder.connect(body.exits, header, 'loop-back');
|
||||
} else {
|
||||
this.builder.edge(header, header, 'loop-back'); // empty body re-tests
|
||||
}
|
||||
this.builder.edge(header, loopExit, 'cond-false');
|
||||
return { entry: header, exits: [loopExit] };
|
||||
}
|
||||
|
||||
private visitDoWhile(stmt: SyntaxNode): TraversalResult {
|
||||
const labels = this.takeLabels();
|
||||
const cond = stmt.childForFieldName('condition') ?? stmt;
|
||||
const condBlock = this.builder.newBlock(
|
||||
startLineOf(cond),
|
||||
endLineOf(cond),
|
||||
cond.text,
|
||||
'normal',
|
||||
this.harvest.facts(cond),
|
||||
);
|
||||
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
|
||||
|
||||
this.cfc.pushLoop(condBlock, loopExit, labels);
|
||||
const body = this.visitBody(this.bodyBlockOf(stmt));
|
||||
this.cfc.pop();
|
||||
|
||||
const backTarget = body ? body.entry : condBlock;
|
||||
if (body) this.builder.connect(body.exits, condBlock, 'seq');
|
||||
this.builder.edge(condBlock, backTarget, 'loop-back'); // cond true → run body again
|
||||
this.builder.edge(condBlock, loopExit, 'cond-false');
|
||||
return { entry: backTarget, exits: [loopExit] };
|
||||
}
|
||||
|
||||
private visitFor(stmt: SyntaxNode): TraversalResult {
|
||||
const labels = this.takeLabels();
|
||||
const init = stmt.childForFieldName('initializer');
|
||||
const cond = stmt.childForFieldName('condition');
|
||||
const incr = stmt.childForFieldName('increment');
|
||||
|
||||
const header = this.builder.newBlock(
|
||||
startLineOf(stmt),
|
||||
cond ? endLineOf(cond) : startLineOf(stmt),
|
||||
cond ? cond.text : 'for(;;)',
|
||||
'normal',
|
||||
cond ? this.harvest.facts(cond) : undefined,
|
||||
);
|
||||
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
|
||||
|
||||
let incrBlock = header;
|
||||
if (incr) {
|
||||
incrBlock = this.builder.newBlock(
|
||||
startLineOf(incr),
|
||||
endLineOf(incr),
|
||||
incr.text,
|
||||
'normal',
|
||||
this.harvest.facts(incr),
|
||||
);
|
||||
this.builder.edge(incrBlock, header, 'loop-back');
|
||||
}
|
||||
|
||||
this.cfc.pushLoop(incrBlock, loopExit, labels);
|
||||
const body = this.visitBody(this.bodyBlockOf(stmt));
|
||||
this.cfc.pop();
|
||||
|
||||
if (body) {
|
||||
this.builder.edge(header, body.entry, 'cond-true');
|
||||
// With no increment clause the body's exits ARE the back-edge — carry
|
||||
// the loop-back kind on them (mirroring visitWhile/visitForIn) instead
|
||||
// of a phantom header→header self-loop that models a path which never
|
||||
// executes the body. With an increment, the body falls through to the
|
||||
// increment (`seq`) and the increment carries the loop-back (:338).
|
||||
this.builder.connect(body.exits, incrBlock, incr ? 'seq' : 'loop-back');
|
||||
} else {
|
||||
this.builder.edge(header, incrBlock, 'cond-true');
|
||||
// Empty body with no increment: the header genuinely re-tests itself.
|
||||
if (!incr) this.builder.edge(header, header, 'loop-back');
|
||||
}
|
||||
this.builder.edge(header, loopExit, 'cond-false');
|
||||
|
||||
let entry = header;
|
||||
if (init) {
|
||||
const initBlock = this.builder.newBlock(
|
||||
startLineOf(init),
|
||||
endLineOf(init),
|
||||
init.text,
|
||||
'normal',
|
||||
this.harvest.facts(init),
|
||||
);
|
||||
this.builder.edge(initBlock, header, 'seq');
|
||||
entry = initBlock;
|
||||
}
|
||||
return { entry, exits: [loopExit] };
|
||||
}
|
||||
|
||||
private visitForIn(stmt: SyntaxNode): TraversalResult {
|
||||
const labels = this.takeLabels();
|
||||
// Header text is SYNTHESIZED, so facts come from the left/right AST nodes
|
||||
// directly (the loop variable is a def, the iterated expression a use).
|
||||
const header = this.builder.newBlock(
|
||||
startLineOf(stmt),
|
||||
startLineOf(stmt),
|
||||
this.forInHeaderText(stmt),
|
||||
'normal',
|
||||
this.harvest.forInHeadFacts(stmt),
|
||||
);
|
||||
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
|
||||
|
||||
this.cfc.pushLoop(header, loopExit, labels);
|
||||
const body = this.visitBody(this.bodyBlockOf(stmt));
|
||||
this.cfc.pop();
|
||||
|
||||
if (body) {
|
||||
this.builder.edge(header, body.entry, 'cond-true');
|
||||
this.builder.connect(body.exits, header, 'loop-back');
|
||||
} else {
|
||||
this.builder.edge(header, header, 'loop-back');
|
||||
}
|
||||
this.builder.edge(header, loopExit, 'cond-false');
|
||||
return { entry: header, exits: [loopExit] };
|
||||
}
|
||||
|
||||
private forInHeaderText(stmt: SyntaxNode): string {
|
||||
const left = stmt.childForFieldName('left')?.text ?? '';
|
||||
const right = stmt.childForFieldName('right')?.text ?? '';
|
||||
return left || right ? `for(${left} … ${right})` : 'for(… in/of …)';
|
||||
}
|
||||
|
||||
private visitSwitch(stmt: SyntaxNode): TraversalResult {
|
||||
const labels = this.takeLabels();
|
||||
const value = stmt.childForFieldName('value') ?? stmt;
|
||||
const dispatch = this.builder.newBlock(
|
||||
startLineOf(stmt),
|
||||
endLineOf(value),
|
||||
value.text,
|
||||
'normal',
|
||||
this.harvest.facts(value),
|
||||
);
|
||||
const switchExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
|
||||
|
||||
this.cfc.pushSwitch(switchExit, labels);
|
||||
const body = stmt.childForFieldName('body');
|
||||
const cases = body
|
||||
? body.namedChildren.filter((c) => c.type === 'switch_case' || c.type === 'switch_default')
|
||||
: [];
|
||||
|
||||
// `case x:` test expressions live in no block (caseStatements filters the
|
||||
// value node out) — harvest their uses onto the dispatch block, one record
|
||||
// per case in source order (a sound over-approximation of JS's in-order
|
||||
// case evaluation). Conditionally: a later case test only evaluates when
|
||||
// earlier cases didn't match, so any def inside one is a may-def — as a
|
||||
// must-def on the always-executed dispatch block it would falsely kill
|
||||
// prior defs for earlier-matching arms (tri-review).
|
||||
for (const c of cases) {
|
||||
const caseValue = c.childForFieldName('value');
|
||||
if (caseValue) this.builder.attachFacts(dispatch, this.harvest.factsConditional(caseValue));
|
||||
}
|
||||
|
||||
const caseResults = cases.map((c) => this.visitSeq(this.caseStatements(c)));
|
||||
const hasDefault = cases.some((c) => c.type === 'switch_default');
|
||||
|
||||
// entryOf[i] = block a dispatch/fallthrough INTO case i lands on (empty
|
||||
// cases are transparent — they resolve to the next case, or the exit).
|
||||
const entryOf: number[] = new Array(cases.length);
|
||||
let after = switchExit;
|
||||
for (let i = cases.length - 1; i >= 0; i--) {
|
||||
entryOf[i] = caseResults[i]?.entry ?? after;
|
||||
after = entryOf[i];
|
||||
}
|
||||
|
||||
for (let i = 0; i < cases.length; i++) {
|
||||
this.builder.edge(dispatch, entryOf[i], 'switch-case');
|
||||
}
|
||||
if (!hasDefault) this.builder.edge(dispatch, switchExit, 'switch-case'); // no-match path
|
||||
|
||||
for (let i = 0; i < cases.length; i++) {
|
||||
const res = caseResults[i];
|
||||
if (!res) continue;
|
||||
const fallTarget = i + 1 < cases.length ? entryOf[i + 1] : switchExit;
|
||||
this.builder.connect(res.exits, fallTarget, 'fallthrough');
|
||||
}
|
||||
|
||||
this.cfc.pop();
|
||||
return { entry: dispatch, exits: [switchExit] };
|
||||
}
|
||||
|
||||
private caseStatements(caseNode: SyntaxNode): SyntaxNode[] {
|
||||
const value = caseNode.childForFieldName('value');
|
||||
return caseNode.namedChildren.filter((c) => c.id !== value?.id && c.type !== 'comment');
|
||||
}
|
||||
|
||||
private visitTry(stmt: SyntaxNode): SeqResult {
|
||||
const bodyNode = stmt.childForFieldName('body');
|
||||
// Single pass over named children — tree-sitter's `namedChildren` getter
|
||||
// allocates a fresh array on every access, so avoid the double `.find`.
|
||||
let catchClause: SyntaxNode | undefined;
|
||||
let finallyClause: SyntaxNode | undefined;
|
||||
for (let i = 0; i < stmt.namedChildCount; i++) {
|
||||
const c = stmt.namedChild(i);
|
||||
if (c?.type === 'catch_clause') catchClause = c;
|
||||
else if (c?.type === 'finally_clause') finallyClause = c;
|
||||
}
|
||||
|
||||
// Build finally first so its entry is known as both a normal join and a
|
||||
// handler target. The finally body runs in the OUTER handler context — and
|
||||
// OUTSIDE this try's finalizer frame: a return inside the finally must not
|
||||
// thread itself (it threads only outer finallys, matching JS semantics).
|
||||
const finallyRes = finallyClause
|
||||
? this.visitSeq(this.statementsOf(this.bodyBlockOf(finallyClause) as SyntaxNode))
|
||||
: null;
|
||||
|
||||
// Finalizer frame for early-exit threading (#2082 M2 U2): active while the
|
||||
// catch and protected bodies are walked, so a crossing `return`/`break`/
|
||||
// `continue` inside either routes through the finally. An empty/comment-only
|
||||
// finally (`finallyRes` null — the #2099-F2 empty-catch bug shape) pushes
|
||||
// NO frame: it can define nothing, so jumps soundly keep direct edges.
|
||||
const finFrame = finallyRes ? this.cfc.pushFinalizer(finallyRes.entry) : null;
|
||||
|
||||
// A throw inside catch propagates to finally (if any), else the outer handler.
|
||||
let catchRes: SeqResult = null;
|
||||
if (catchClause) {
|
||||
if (finallyRes) this.handlers.push(finallyRes.entry);
|
||||
catchRes = this.visitSeq(this.statementsOf(this.bodyBlockOf(catchClause) as SyntaxNode));
|
||||
if (finallyRes) this.handlers.pop();
|
||||
if (catchRes === null) {
|
||||
// Empty (or comment-only) catch body — `catch {}`. The clause still
|
||||
// CATCHES: handler semantics key off the syntactic clause, not the
|
||||
// traversal result. Treating it as "no catch" sent the swallowed
|
||||
// exception to the outer handler/EXIT and left post-try code
|
||||
// unreachable when the body always throws — a hard false-negative
|
||||
// for downstream taint. Synthesize one empty block spanning the
|
||||
// clause (entry == sole exit) so exception flow lands in it and
|
||||
// rejoins the normal continuation. Created BEFORE the protected
|
||||
// region is walked, so it never receives a spurious throw edge.
|
||||
const idx = this.builder.newBlock(startLineOf(catchClause), endLineOf(catchClause), '');
|
||||
catchRes = { entry: idx, exits: [idx] };
|
||||
}
|
||||
// `catch (e)` has no header block — the param def gets its OWN
|
||||
// facts-only block in front of the body entry. It must NOT be prepended
|
||||
// into the body's entry block: when the catch body STARTS with a loop,
|
||||
// that entry is the loop HEADER, re-entered on every iteration — the
|
||||
// param def would re-gen there and falsely KILL loop-carried
|
||||
// redefinitions of the param (`catch (e) { while (c) { e = fix(e); }
|
||||
// sink(e); }` would lose the fix→sink fact, a taint false negative).
|
||||
// The param block becomes the handler entry, which is also semantically
|
||||
// right: the binding happens exactly once, on handler entry.
|
||||
const paramFacts = this.harvest.catchParamFacts(catchClause);
|
||||
if (paramFacts) {
|
||||
const paramBlock = this.builder.newBlock(
|
||||
startLineOf(catchClause),
|
||||
startLineOf(catchClause),
|
||||
'',
|
||||
'normal',
|
||||
paramFacts,
|
||||
);
|
||||
this.builder.edge(paramBlock, catchRes.entry, 'seq');
|
||||
catchRes = { entry: paramBlock, exits: catchRes.exits };
|
||||
}
|
||||
}
|
||||
|
||||
// Handler for the try body: catch if present, else finally, else outer.
|
||||
const tryHandler = catchRes?.entry ?? finallyRes?.entry ?? this.currentHandler();
|
||||
const protectedStart = this.builder.blockCount;
|
||||
this.handlers.push(tryHandler);
|
||||
const bodyRes = bodyNode ? this.visitSeq(this.statementsOf(bodyNode)) : null;
|
||||
this.handlers.pop();
|
||||
|
||||
// Conservative exceptional edges: ANY block in the protected region may raise
|
||||
// to the handler — not just an explicit `throw`, and not just the body ENTRY.
|
||||
// Edging every block created during the try-body walk keeps exception flow
|
||||
// sound when the body BRANCHES: an `if` / nested-try / post-branch block whose
|
||||
// interior blocks would otherwise have no path to the handler — i.e. a taint
|
||||
// false-negative into `catch` for the downstream PDG analysis. The
|
||||
// per-function edge cap bounds the count; explicit `throw`s add their own
|
||||
// (idempotent) edge to the same handler.
|
||||
if (catchClause || finallyClause) {
|
||||
for (let b = protectedStart; b < this.builder.blockCount; b++) {
|
||||
this.builder.edge(b, tryHandler, 'throw');
|
||||
}
|
||||
}
|
||||
|
||||
// The finalizer frame closes once the protected/catch walks are done; any
|
||||
// jumps that crossed it left their completion legs on `pending`, wired
|
||||
// here from the finally's exits (see drainFinalizerPending for the
|
||||
// finally-override semantics of an always-jumping finally).
|
||||
if (finFrame && finallyRes) {
|
||||
this.cfc.pop();
|
||||
drainFinalizerPending(this.builder, finFrame, finallyRes.exits);
|
||||
}
|
||||
|
||||
const exits: number[] = [];
|
||||
if (finallyRes) {
|
||||
// Normal completion of try AND catch both flow through finally.
|
||||
if (bodyRes) this.builder.connect(bodyRes.exits, finallyRes.entry, 'seq');
|
||||
if (catchRes) this.builder.connect(catchRes.exits, finallyRes.entry, 'seq');
|
||||
exits.push(...finallyRes.exits);
|
||||
// No catch → an exception re-propagates out after finally runs.
|
||||
if (!catchRes) this.builder.connect(finallyRes.exits, this.currentHandler(), 'throw');
|
||||
} else {
|
||||
if (bodyRes) exits.push(...bodyRes.exits);
|
||||
if (catchRes) exits.push(...catchRes.exits);
|
||||
}
|
||||
|
||||
const entry = bodyRes?.entry ?? finallyRes?.entry ?? catchRes?.entry;
|
||||
if (entry === undefined) return null;
|
||||
return { entry, exits: [...new Set(exits)] };
|
||||
}
|
||||
|
||||
/** Nearest enclosing exception handler, or the function EXIT. */
|
||||
private currentHandler(): number {
|
||||
return this.handlers.length ? this.handlers[this.handlers.length - 1] : this.builder.exitIndex;
|
||||
}
|
||||
|
||||
/** Consume the labels awaiting the loop/switch this call is building. */
|
||||
private takeLabels(): string[] {
|
||||
const labels = this.pendingLabels;
|
||||
this.pendingLabels = [];
|
||||
return labels;
|
||||
}
|
||||
|
||||
private labelOf(stmt: SyntaxNode): string | undefined {
|
||||
const id =
|
||||
stmt.childForFieldName('label') ??
|
||||
stmt.namedChildren.find((c) => c.type === 'statement_identifier');
|
||||
return id?.text;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the CFG for one TS/JS function node (or `undefined` if not a function). */
|
||||
function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | undefined {
|
||||
if (!TS_FUNCTION_TYPES.has(fnNode.type)) return undefined;
|
||||
const startLine = startLineOf(fnNode);
|
||||
const endLine = endLineOf(fnNode);
|
||||
const startColumn = fnNode.startPosition.column;
|
||||
const builder = new CfgBuilder(filePath, startLine, endLine, startColumn);
|
||||
|
||||
const body = fnNode.childForFieldName('body');
|
||||
if (!body) return undefined; // overload signature / abstract method — no body
|
||||
|
||||
// Phase-1 declaration pre-scan (#2082 M2 U1) — must complete before any
|
||||
// facts are extracted; the CFG walk below is not source-order.
|
||||
const harvest = new TsHarvester(fnNode);
|
||||
|
||||
// Parameters define at ENTRY (facts only — never touch the entry block's
|
||||
// text or span: bench fingerprints and CFG snapshots include block text).
|
||||
const paramFacts = harvest.paramFacts();
|
||||
if (paramFacts) builder.attachFacts(builder.entryIndex, paramFacts);
|
||||
|
||||
if (body.type !== 'statement_block') {
|
||||
// Expression-bodied arrow: `() => expr` — one block whose value is returned.
|
||||
// Lives outside the walk class, so it harvests explicitly.
|
||||
const blk = builder.newBlock(
|
||||
startLineOf(body),
|
||||
endLineOf(body),
|
||||
body.text,
|
||||
'normal',
|
||||
harvest.facts(body),
|
||||
);
|
||||
builder.edge(builder.entryIndex, blk, 'seq');
|
||||
builder.edge(blk, builder.exitIndex, 'return');
|
||||
return builder.finish(harvest.table());
|
||||
}
|
||||
|
||||
const walk = new TsCfgWalk(builder, harvest);
|
||||
const res = walk.visitSeq(body.namedChildren.filter((c) => c.type !== 'comment'));
|
||||
if (!res) {
|
||||
builder.edge(builder.entryIndex, builder.exitIndex, 'seq'); // empty body
|
||||
return builder.finish(harvest.table());
|
||||
}
|
||||
builder.edge(builder.entryIndex, res.entry, 'seq');
|
||||
builder.connect(res.exits, builder.exitIndex, 'seq'); // normal fall-off → EXIT
|
||||
return builder.finish(harvest.table());
|
||||
}
|
||||
|
||||
/** Whether a node is a TS/JS function this visitor builds a CFG for. */
|
||||
function isFunction(node: SyntaxNode): boolean {
|
||||
return TS_FUNCTION_TYPES.has(node.type);
|
||||
}
|
||||
|
||||
/** The TS/JS CFG visitor (shared by TypeScript and JavaScript). */
|
||||
export function createTypeScriptCfgVisitor(): CfgVisitor<SyntaxNode> {
|
||||
return { buildFunctionCfg, isFunction };
|
||||
}
|
||||
|
||||
export { TS_FUNCTION_TYPES };
|
||||
@@ -35,7 +35,10 @@ import type { MethodExtractor } from './method-types.js';
|
||||
import type { VariableExtractor } from './variable-types.js';
|
||||
import type { ImportResolverFn } from './import-resolvers/types.js';
|
||||
import type { SyntaxNode } from './utils/ast-helpers.js';
|
||||
import type { CfgVisitor } from './cfg/types.js';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import type Parser from 'tree-sitter';
|
||||
import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
|
||||
|
||||
// ── Shared type aliases ────────────────────────────────────────────────────
|
||||
/** Tree-sitter query captures: capture name → AST node (or undefined if not captured). */
|
||||
@@ -185,6 +188,12 @@ interface LanguageProviderConfig {
|
||||
* `undefined` when no constraints exist / the node isn't a templated
|
||||
* function. Languages without SFINAE / concept semantics leave this
|
||||
* undefined and the disambiguation is a pass-through.
|
||||
*
|
||||
* Cloneability contract: the returned payload crosses the worker boundary
|
||||
* via structured clone, so it MUST be structured-clone-safe (no functions,
|
||||
* symbols, or tree-sitter `SyntaxNode`s — only plain data). Wrap the return
|
||||
* with `assertCloneable` from `workers/clone-safety.ts` so a future leak is a
|
||||
* compile error at the source instead of a runtime DataCloneError (#2143).
|
||||
*/
|
||||
readonly extractTemplateConstraints?: (definitionNode: SyntaxNode) => unknown;
|
||||
|
||||
@@ -236,6 +245,22 @@ interface LanguageProviderConfig {
|
||||
* Default: undefined (no route files). */
|
||||
readonly isRouteFile?: (filePath: string) => boolean;
|
||||
|
||||
/**
|
||||
* Extract decorator-style route annotations from a parsed file.
|
||||
*
|
||||
* When defined, the parse worker calls this after per-file capture processing
|
||||
* to extract framework route definitions that require AST-level analysis beyond
|
||||
* generic `@decorator` captures (e.g., Java Spring class-level prefix joining,
|
||||
* multi-class handling). The returned routes are appended to `decoratorRoutes`.
|
||||
*
|
||||
* Default: undefined (no language-specific decorator route extraction).
|
||||
*/
|
||||
readonly extractDecoratorRoutes?: (
|
||||
tree: Parser.Tree,
|
||||
filePath: string,
|
||||
lineOffset: number,
|
||||
) => ExtractedDecoratorRoute[];
|
||||
|
||||
// ── Noise filtering ────────────────────────────────────────────────
|
||||
/** Built-in/stdlib names that should be filtered from the call graph for this language.
|
||||
* Default: undefined (no language-specific filtering). */
|
||||
@@ -325,13 +350,28 @@ interface LanguageProviderConfig {
|
||||
* disk store WITHOUT a main-thread re-parse. The main thread restores them
|
||||
* via the matching `ScopeResolver.applyCaptureSideChannel` hook.
|
||||
*
|
||||
* MUST return plain data (objects / arrays / primitives) so it round-trips
|
||||
* through `JSON.stringify` + the parsedfile-store interning reviver.
|
||||
* Cloneability contract: MUST return plain data (objects / arrays /
|
||||
* primitives — no functions, symbols, or tree-sitter `SyntaxNode`s) so it
|
||||
* survives BOTH the worker→main structured clone AND `JSON.stringify` + the
|
||||
* parsedfile-store interning reviver. Wrap the return with `assertCloneable`
|
||||
* from `workers/clone-safety.ts` so a future non-serializable leak is a
|
||||
* compile error at the source instead of a runtime DataCloneError (#2143).
|
||||
*
|
||||
* Default: undefined (provider has no capture-time module-level side effects).
|
||||
*/
|
||||
readonly collectCaptureSideChannel?: (filePath: string) => unknown;
|
||||
|
||||
/**
|
||||
* Per-language control-flow-graph builder (#2081 M1, PDG/taint substrate).
|
||||
* Invoked IN THE PARSE WORKER (where the AST lives) for each function node,
|
||||
* gated on the `--pdg` opt-in; the resulting per-function CFGs are serialized
|
||||
* onto `ParsedFile.cfgSideChannel` and emitted as BasicBlock nodes + CFG
|
||||
* edges during scope-resolution. `TNode` is `SyntaxNode` for the tree-sitter
|
||||
* languages. Default: undefined (language has no CFG support yet — TS/JS are
|
||||
* the M1 set).
|
||||
*/
|
||||
readonly cfgVisitor?: CfgVisitor<SyntaxNode>;
|
||||
|
||||
/**
|
||||
* Interpret a raw `@import.statement` capture group into a `ParsedImport`.
|
||||
* The central finalize algorithm resolves `ParsedImport.targetRaw` to a
|
||||
|
||||
@@ -65,7 +65,11 @@ import {
|
||||
cppReceiverBinding,
|
||||
collectCppCaptureSideChannel,
|
||||
} from './cpp/index.js';
|
||||
import { extractCppTemplateConstraints } from './cpp/constraint-extractor.js';
|
||||
import {
|
||||
extractCppTemplateConstraints,
|
||||
type CppConstraintPayload,
|
||||
} from './cpp/constraint-extractor.js';
|
||||
import { assertCloneable } from '../workers/clone-safety.js';
|
||||
|
||||
const C_BUILT_INS: ReadonlySet<string> = new Set([
|
||||
'printf',
|
||||
@@ -405,7 +409,11 @@ export const cProvider = defineLanguage({
|
||||
// `static` functions look non-file-local on the main thread and leak into
|
||||
// cross-file global free-call resolution / wildcard imports. See
|
||||
// `c/capture-side-channel.ts`.
|
||||
collectCaptureSideChannel: collectCStaticLinkageSideChannel,
|
||||
// `assertCloneable` is a runtime identity; it makes a future non-serializable
|
||||
// value in the side-channel payload a compile error here, at the source, rather
|
||||
// than a DataCloneError at the worker boundary (#2143).
|
||||
collectCaptureSideChannel: (filePath) =>
|
||||
assertCloneable(collectCStaticLinkageSideChannel(filePath)),
|
||||
interpretImport: interpretCImport,
|
||||
interpretTypeBinding: interpretCTypeBinding,
|
||||
bindingScopeFor: cBindingScopeFor,
|
||||
@@ -480,7 +488,7 @@ export const cppProvider = defineLanguage({
|
||||
// just populated for this file into plain data on `ParsedFile.captureSideChannel`,
|
||||
// so the main thread can restore them via `applyCaptureSideChannel` WITHOUT a
|
||||
// re-parse (#1983). See `cpp/capture-side-channel.ts`.
|
||||
collectCaptureSideChannel: collectCppCaptureSideChannel,
|
||||
collectCaptureSideChannel: (filePath) => assertCloneable(collectCppCaptureSideChannel(filePath)),
|
||||
interpretImport: interpretCppImport,
|
||||
interpretTypeBinding: interpretCppTypeBinding,
|
||||
bindingScopeFor: cppBindingScopeFor,
|
||||
@@ -501,7 +509,9 @@ export const cppProvider = defineLanguage({
|
||||
* functions whose constraints the extractor can't model — both cases
|
||||
* result in no constraint suffix on the node ID.
|
||||
*/
|
||||
function extractCppTemplateConstraintsForProvider(definitionNode: SyntaxNode): unknown {
|
||||
function extractCppTemplateConstraintsForProvider(
|
||||
definitionNode: SyntaxNode,
|
||||
): CppConstraintPayload | undefined {
|
||||
// Walk up to the enclosing template_declaration. Bound the walk so we
|
||||
// can't accidentally land on a far-ancestor template_declaration that
|
||||
// wraps an unrelated function.
|
||||
@@ -530,5 +540,8 @@ function extractCppTemplateConstraintsForProvider(definitionNode: SyntaxNode): u
|
||||
}
|
||||
break;
|
||||
}
|
||||
return extractCppTemplateConstraints(templateDecl, declarator);
|
||||
// Guard the boundary at the source: a future non-cloneable member of the
|
||||
// constraint payload becomes a compile error here, not a runtime
|
||||
// DataCloneError at the worker post (#2143).
|
||||
return assertCloneable(extractCppTemplateConstraints(templateDecl, declarator));
|
||||
}
|
||||
|
||||
@@ -222,8 +222,14 @@ function findFuncDeclarator(node: SyntaxNode): SyntaxNode | null {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Unwrap pointer_declarator / reference_declarator
|
||||
while (decl.type === 'pointer_declarator' || decl.type === 'reference_declarator') {
|
||||
// Unwrap declarator wrappers. Deleted free functions are represented as
|
||||
// `init_declarator(function_declarator, delete_expression)` by
|
||||
// tree-sitter-cpp 0.23.
|
||||
while (
|
||||
decl.type === 'pointer_declarator' ||
|
||||
decl.type === 'reference_declarator' ||
|
||||
decl.type === 'init_declarator'
|
||||
) {
|
||||
const next = decl.childForFieldName('declarator');
|
||||
if (next === null) {
|
||||
// reference_declarator may not use field name
|
||||
|
||||
@@ -163,6 +163,13 @@ export function emitCppScopeCaptures(
|
||||
'true',
|
||||
);
|
||||
}
|
||||
if (hasDeletedMethodClause(fnNode, grouped['@declaration.name']?.text)) {
|
||||
grouped['@declaration.is-deleted'] = syntheticCapture(
|
||||
'@declaration.is-deleted',
|
||||
fnNode,
|
||||
'true',
|
||||
);
|
||||
}
|
||||
|
||||
// Detect static storage class (file-local linkage)
|
||||
if (hasStaticStorageClass(fnNode)) {
|
||||
@@ -1685,7 +1692,13 @@ function extractDeclaratorLeafName(node: SyntaxNode): string | null {
|
||||
let cur: SyntaxNode = node;
|
||||
let safety = 16;
|
||||
while (safety-- > 0) {
|
||||
if (cur.type === 'identifier' || cur.type === 'type_identifier') return cur.text;
|
||||
if (
|
||||
cur.type === 'identifier' ||
|
||||
cur.type === 'type_identifier' ||
|
||||
cur.type === 'operator_name'
|
||||
) {
|
||||
return cur.text;
|
||||
}
|
||||
// Common wrapper nodes — follow the 'declarator' field when present.
|
||||
const next =
|
||||
cur.childForFieldName('declarator') ??
|
||||
@@ -1713,6 +1726,25 @@ function hasExplicitSpecifier(node: SyntaxNode): boolean {
|
||||
return /\bexplicit\b/.test(node.text.slice(0, 128));
|
||||
}
|
||||
|
||||
function hasDeletedMethodClause(node: SyntaxNode, callableName: string | undefined): boolean {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'delete_method_clause') return true;
|
||||
// tree-sitter-cpp 0.23 parses a deleted free-function declaration as
|
||||
// `declaration > init_declarator > delete_expression`, while class
|
||||
// members use the dedicated `delete_method_clause`.
|
||||
if (
|
||||
child?.type === 'init_declarator' &&
|
||||
child.childForFieldName('value')?.type === 'delete_expression' &&
|
||||
callableName !== undefined &&
|
||||
extractDeclaratorLeafName(child.childForFieldName('declarator') ?? child) === callableName
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a C++ function_definition or declaration has `static` storage class.
|
||||
*/
|
||||
|
||||
@@ -194,6 +194,29 @@ const CPP_SCOPE_QUERY = `
|
||||
declarator: (function_declarator
|
||||
declarator: (identifier) @declaration.name)) @declaration.function
|
||||
|
||||
;; tree-sitter-cpp 0.23 represents a deleted free function as an
|
||||
;; init_declarator whose value is a delete_expression.
|
||||
(declaration
|
||||
declarator: (init_declarator
|
||||
declarator: (function_declarator
|
||||
declarator: (identifier) @declaration.name)
|
||||
value: (delete_expression))) @declaration.function
|
||||
|
||||
;; Deleted free operator declaration.
|
||||
(declaration
|
||||
declarator: (init_declarator
|
||||
declarator: (function_declarator
|
||||
declarator: (operator_name) @declaration.name)
|
||||
value: (delete_expression))) @declaration.function
|
||||
|
||||
;; Deleted free function with a pointer return type.
|
||||
(declaration
|
||||
declarator: (init_declarator
|
||||
declarator: (pointer_declarator
|
||||
declarator: (function_declarator
|
||||
declarator: (identifier) @declaration.name))
|
||||
value: (delete_expression))) @declaration.function
|
||||
|
||||
;; Free operator prototype: std::ostream& operator<<(std::ostream&, T)
|
||||
(declaration
|
||||
declarator: (function_declarator
|
||||
|
||||
@@ -13,6 +13,7 @@ import { javaClassConfig } from '../class-extractors/configs/jvm.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||
import { javaTypeConfig } from '../type-extractors/jvm.js';
|
||||
import { extractSpringRoutes } from '../route-extractors/spring.js';
|
||||
import { javaExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
import { javaImportConfig } from '../import-resolvers/configs/jvm.js';
|
||||
@@ -126,4 +127,7 @@ export const javaProvider = defineLanguage({
|
||||
arityCompatibility: javaArityCompatibility,
|
||||
resolveImportTarget: resolveJavaImportTarget,
|
||||
orderSameNameTypeCandidates: orderJavaSameNameTypeCandidates,
|
||||
|
||||
// ── Route extraction ──
|
||||
extractDecoratorRoutes: extractSpringRoutes,
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { createClassExtractor } from '../class-extractors/generic.js';
|
||||
import { kotlinClassConfig } from '../class-extractors/configs/jvm.js';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import { assertCloneable } from '../workers/clone-safety.js';
|
||||
import { kotlinTypeConfig } from '../type-extractors/jvm.js';
|
||||
import { kotlinExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
@@ -182,7 +183,11 @@ export const kotlinProvider = defineLanguage({
|
||||
// so the main thread can restore them via `applyCaptureSideChannel` WITHOUT a
|
||||
// re-parse (#1983). Without this, companion/static dispatch emits no CALLS
|
||||
// edges on the worker path. See `kotlin/capture-side-channel.ts`.
|
||||
collectCaptureSideChannel: collectKotlinCaptureSideChannel,
|
||||
// `assertCloneable` is a runtime identity; it makes a future non-serializable
|
||||
// value in the side-channel payload a compile error here, at the source, rather
|
||||
// than a DataCloneError at the worker boundary (#2143).
|
||||
collectCaptureSideChannel: (filePath) =>
|
||||
assertCloneable(collectKotlinCaptureSideChannel(filePath)),
|
||||
interpretImport: interpretKotlinImport,
|
||||
interpretTypeBinding: interpretKotlinTypeBinding,
|
||||
bindingScopeFor: kotlinBindingScopeFor,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Parser from 'tree-sitter';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
// `tree-sitter-kotlin` is an optionalDependency that may be absent on a default
|
||||
// install (or fail its native build). Loaded lazily + guarded via parser-loader
|
||||
// `tree-sitter-kotlin` is a vendored grammar (loaded from vendor/ by absolute
|
||||
// path, never node_modules — vendored-grammars.ts / #2111) that may be absent on
|
||||
// a platform without a matching prebuild. Loaded lazily + guarded via parser-loader
|
||||
// rather than statically imported: this module is pulled onto the main thread
|
||||
// eagerly by the scope-resolution registry and the language-provider index, so
|
||||
// a top-level `import Kotlin from 'tree-sitter-kotlin'` would throw
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
javascriptClassConfig,
|
||||
} from '../class-extractors/configs/typescript-javascript.js';
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { createTypeScriptCfgVisitor } from '../cfg/visitors/typescript.js';
|
||||
import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js';
|
||||
import { tsExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
@@ -351,6 +352,8 @@ export const typescriptProvider = defineLanguage({
|
||||
// canonical capture vocabulary in ./typescript/query.ts
|
||||
// (TYPESCRIPT_SCOPE_QUERY constant).
|
||||
emitScopeCaptures: emitTsScopeCaptures,
|
||||
// CFG/PDG substrate (#2081 M1) — runs in the worker on a --pdg run.
|
||||
cfgVisitor: createTypeScriptCfgVisitor(),
|
||||
interpretImport: interpretTsImport,
|
||||
interpretTypeBinding: interpretTsTypeBinding,
|
||||
bindingScopeFor: tsBindingScopeFor,
|
||||
@@ -412,6 +415,8 @@ export const javascriptProvider = defineLanguage({
|
||||
// JSDoc type bindings) live in ./javascript/captures.ts.
|
||||
// See ./javascript/index.ts for the full per-module rationale.
|
||||
emitScopeCaptures: emitJsScopeCaptures,
|
||||
// CFG/PDG substrate (#2081 M1) — TS and JS share the same grammar family.
|
||||
cfgVisitor: createTypeScriptCfgVisitor(),
|
||||
interpretImport: interpretJsImport,
|
||||
interpretTypeBinding: interpretJsTypeBinding,
|
||||
bindingScopeFor: jsBindingScopeFor,
|
||||
|
||||
@@ -42,18 +42,11 @@ function findFunctionDeclarator(node: SyntaxNode): SyntaxNode | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect `= delete` and `= default` special member function declarations.
|
||||
* These are not callable methods and should be suppressed from extraction.
|
||||
* tree-sitter-cpp ^0.23.4 emits `delete_method_clause` / `default_method_clause`
|
||||
* as named children of the function_definition node.
|
||||
*/
|
||||
function isDeletedOrDefaulted(node: SyntaxNode): boolean {
|
||||
/** Detect a C++ special member clause by its tree-sitter node type. */
|
||||
function hasSpecialMethodClause(node: SyntaxNode, clauseType: string): boolean {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'delete_method_clause' || child?.type === 'default_method_clause') {
|
||||
return true;
|
||||
}
|
||||
if (child?.type === clauseType) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -67,10 +60,6 @@ function extractCppMethodName(node: SyntaxNode): string | undefined {
|
||||
const funcDecl = findFunctionDeclarator(node);
|
||||
if (!funcDecl) return undefined;
|
||||
|
||||
// Suppress `= delete` and `= default` special members — these are not callable
|
||||
// methods and should not appear in HAS_METHOD edges.
|
||||
if (isDeletedOrDefaulted(node)) return undefined;
|
||||
|
||||
const nameNode = funcDecl.childForFieldName('declarator');
|
||||
if (!nameNode) return undefined;
|
||||
// destructor_name: ~ClassName
|
||||
@@ -387,6 +376,10 @@ export const cppMethodConfig: MethodExtractionConfig = {
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
isDeleted(node) {
|
||||
return hasSpecialMethodClause(node, 'delete_method_clause');
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -252,6 +252,7 @@ function buildMethod(
|
||||
...(config.isAsync?.(node) ? { isAsync: true } : {}),
|
||||
...(config.isPartial?.(node) ? { isPartial: true } : {}),
|
||||
...(config.isConst?.(node) ? { isConst: true } : {}),
|
||||
...(config.isDeleted?.(node) ? { isDeleted: true } : {}),
|
||||
annotations: config.extractAnnotations?.(node) ?? [],
|
||||
sourceFile: context.filePath,
|
||||
line: node.startPosition.row + 1,
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface MethodInfo {
|
||||
isAsync?: boolean;
|
||||
isPartial?: boolean;
|
||||
isConst?: boolean;
|
||||
isDeleted?: boolean;
|
||||
annotations: string[];
|
||||
sourceFile: string;
|
||||
line: number;
|
||||
@@ -84,6 +85,7 @@ export interface MethodExtractionConfig {
|
||||
isAsync?: (node: SyntaxNode) => boolean;
|
||||
isPartial?: (node: SyntaxNode) => boolean;
|
||||
isConst?: (node: SyntaxNode) => boolean;
|
||||
isDeleted?: (node: SyntaxNode) => boolean;
|
||||
/** Owner node types where member functions are effectively static (e.g.
|
||||
* Ruby singleton_class, Kotlin companion_object / object_declaration).
|
||||
* When the ownerNode matches one of these types, isStatic is forced true. */
|
||||
|
||||
@@ -131,6 +131,7 @@ export interface AddMetadata {
|
||||
templateArguments?: string[];
|
||||
ownerId?: string;
|
||||
qualifiedName?: string;
|
||||
isDeleted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -285,6 +286,7 @@ export const createSymbolTable = (): InternalSymbolTable => {
|
||||
? { templateArguments: metadata.templateArguments }
|
||||
: {}),
|
||||
...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}),
|
||||
...(metadata?.isDeleted === true ? { isDeleted: true } : {}),
|
||||
};
|
||||
|
||||
// A. File Index — unconditional.
|
||||
|
||||
@@ -7,6 +7,7 @@ import { accumulateExportedTypesFromParsedNode, type ExportedTypeMap } from './c
|
||||
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { WorkerPool } from './workers/worker-pool.js';
|
||||
import type { SkippedPath } from './workers/clone-safety.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type {
|
||||
ParseWorkerResult,
|
||||
@@ -103,6 +104,7 @@ export const mergeChunkResults = (
|
||||
templateArguments: sym.templateArguments,
|
||||
ownerId: sym.ownerId,
|
||||
qualifiedName: sym.qualifiedName,
|
||||
isDeleted: sym.isDeleted,
|
||||
});
|
||||
}
|
||||
if (exportedTypeMap) {
|
||||
@@ -196,6 +198,29 @@ export const dispatchChunkParse = async (
|
||||
logger.warn(` Skipped unsupported languages: ${summary}`);
|
||||
}
|
||||
|
||||
// Clone-safety telemetry (#2112): files whose parse output carried a value
|
||||
// the structured-clone algorithm couldn't serialize across the worker
|
||||
// boundary. The worker sanitized/dropped the offending value so the run
|
||||
// could complete; surface the (rare) data loss so it's visible and the
|
||||
// offending extractor can be fixed at source.
|
||||
const skippedPaths: SkippedPath[] = [];
|
||||
for (const result of chunkResults) {
|
||||
for (const entry of result.skippedPaths ?? []) skippedPaths.push(entry);
|
||||
}
|
||||
if (skippedPaths.length > 0) {
|
||||
// Keep the per-file reason ("stripped N value(s) from nodes" /
|
||||
// "dropped non-serializable parsedFiles entry") — it distinguishes a
|
||||
// recoverable strip from a whole-record drop, which a path-only line loses.
|
||||
const shown = skippedPaths
|
||||
.slice(0, 10)
|
||||
.map((e) => `${e.path} (${e.reason})`)
|
||||
.join(', ');
|
||||
const more = skippedPaths.length > 10 ? ` …and ${skippedPaths.length - 10} more` : '';
|
||||
logger.warn(
|
||||
` Sanitized ${skippedPaths.length} file(s) with non-serializable parse output: ${shown}${more}`,
|
||||
);
|
||||
}
|
||||
|
||||
onFileProgress?.(total, total, 'done');
|
||||
return chunkResults;
|
||||
};
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
restoreDurableParsedFileShard,
|
||||
} from '../../../storage/parsedfile-store.js';
|
||||
import type { ParseWorkerResult } from '../workers/parse-worker.js';
|
||||
import { DEFAULT_PDG_MAX_FUNCTION_LINES } from '../cfg/collect.js';
|
||||
import type { WorkerExtractedData } from '../parsing-processor.js';
|
||||
import {
|
||||
processRoutesFromExtracted,
|
||||
@@ -461,6 +462,10 @@ export async function runChunkedParseAndResolve(
|
||||
// Initialized below before the chunk loop (same deferred-init pattern
|
||||
// as `parsedFileStorePath`); this closure only runs from the loop.
|
||||
durableParsedFileStoragePath: durableParsedFileDir,
|
||||
// CFG/PDG opt-in (#2081 M1) — baked into each worker's workerData so the
|
||||
// worker builds + attaches cfgSideChannel. Off by default.
|
||||
pdg: options?.pdg === true,
|
||||
pdgMaxFunctionLines: options?.pdgMaxFunctionLines,
|
||||
// Fan each chunk across the whole pool (#worker-idle): without this a
|
||||
// chunk smaller than the 8 MB sub-batch cap became a single job on a
|
||||
// single worker. Honors an explicit `subBatchMaxBytes` / env override.
|
||||
@@ -737,7 +742,21 @@ export async function runChunkedParseAndResolve(
|
||||
filePath: f.path,
|
||||
contentHash: fileContentHash(f.content),
|
||||
}));
|
||||
chunkHash = computeChunkHash(entries);
|
||||
chunkHash = computeChunkHash(
|
||||
entries,
|
||||
// Only worker-visible pdg config participates in the key —
|
||||
// pdgMaxEdgesPerFunction is emit-time-only and deliberately
|
||||
// excluded (see PdgCacheKey in parse-cache.ts; #2099 F3). The line
|
||||
// cap is RESOLVED to the worker's default before folding so an
|
||||
// explicit-default run shares the default run's keys (the worker
|
||||
// output is byte-identical either way).
|
||||
options?.pdg === true
|
||||
? {
|
||||
pdg: true,
|
||||
maxFunctionLines: options?.pdgMaxFunctionLines ?? DEFAULT_PDG_MAX_FUNCTION_LINES,
|
||||
}
|
||||
: false,
|
||||
);
|
||||
}
|
||||
|
||||
const cachedRaw =
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user