mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
Merge branch 'main' into fix/issue-1518-docker-local-path
This commit is contained in:
commit
4290b95e5e
674 changed files with 45683 additions and 24102 deletions
|
|
@ -5,14 +5,16 @@ description: "Use when the user needs to run GitNexus CLI commands like analyze/
|
|||
|
||||
# GitNexus CLI Commands
|
||||
|
||||
All commands work via `npx` — no global install required.
|
||||
Commands below use `node .gitnexus/run.cjs <command>` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required.
|
||||
|
||||
> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
|
||||
|
||||
## Commands
|
||||
|
||||
### analyze — Build or refresh the index
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
node .gitnexus/run.cjs analyze
|
||||
```
|
||||
|
||||
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
|
||||
|
|
@ -28,7 +30,7 @@ Run from the project root. This parses all source files, builds the knowledge gr
|
|||
### status — Check index freshness
|
||||
|
||||
```bash
|
||||
npx gitnexus status
|
||||
node .gitnexus/run.cjs status
|
||||
```
|
||||
|
||||
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
|
||||
|
|
@ -36,7 +38,7 @@ Shows whether the current repo has a GitNexus index, when it was last updated, a
|
|||
### clean — Delete the index
|
||||
|
||||
```bash
|
||||
npx gitnexus clean
|
||||
node .gitnexus/run.cjs clean
|
||||
```
|
||||
|
||||
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
|
||||
|
|
@ -49,7 +51,7 @@ Deletes the `.gitnexus/` directory and unregisters the repo from the global regi
|
|||
### wiki — Generate documentation from the graph
|
||||
|
||||
```bash
|
||||
npx gitnexus wiki
|
||||
node .gitnexus/run.cjs wiki
|
||||
```
|
||||
|
||||
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
|
||||
|
|
@ -66,7 +68,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir
|
|||
### list — Show all indexed repos
|
||||
|
||||
```bash
|
||||
npx gitnexus list
|
||||
node .gitnexus/run.cjs list
|
||||
```
|
||||
|
||||
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
|||
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ description: "Use when the user asks how code works, wants to understand archite
|
|||
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
|
||||
```
|
||||
|
||||
> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
|
|||
2. **Match your task to a skill below** and **read that skill file**
|
||||
3. **Follow the skill's workflow and checklist**
|
||||
|
||||
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
|
||||
> If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first.
|
||||
|
||||
## Skills
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ description: "Use when the user wants to know what will break if they change som
|
|||
4. Assess risk and report to user
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ description: "Use when the user wants to review a pull request, understand what
|
|||
6. Summarize findings with risk assessment
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing.
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal before reviewing.
|
||||
|
||||
## Checklist
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
|||
4. Plan update order: interfaces → implementations → callers → tests
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
|
||||
## Checklists
|
||||
|
||||
|
|
|
|||
153
.devcontainer/Dockerfile
Normal file
153
.devcontainer/Dockerfile
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Base image: Microsoft's TypeScript+Node devcontainer image. It works on both
|
||||
# linux/amd64 and linux/arm64, gets monthly security patches, and ships the
|
||||
# non-root `node` user (UID 1000, i.e. user ID 1000), zsh + Oh My Zsh, eslint
|
||||
# global, and the `gh` CLI.
|
||||
#
|
||||
# We pin the image by digest, not by tag. That way a silent upstream retag can't
|
||||
# change the build under us. This matches the Dockerfile.cli /
|
||||
# gitnexus/Dockerfile.test convention and issue #1451.
|
||||
#
|
||||
# We pin it as a bare `name@digest` with NO `:tag` prefix on purpose. The
|
||||
# production Dockerfiles use plain `docker build`, but this one is built by
|
||||
# `@devcontainers/cli` / the VS Code Dev Containers resolver. That resolver's
|
||||
# image-name parser rejects the combined `name:tag@sha256:...` form.
|
||||
#
|
||||
# The digest below is for the `1-22-bookworm` tag. It is the multi-arch
|
||||
# manifest-list digest, so it still picks the right platform. To refresh it when
|
||||
# bumping the readable tag, run:
|
||||
# docker buildx imagetools inspect \
|
||||
# mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm \
|
||||
# --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).
|
||||
ARG BUN_VERSION
|
||||
ARG TZ=UTC
|
||||
ARG USERNAME=node
|
||||
|
||||
# Copy the build-only ARGs into runtime ENV so shells and lifecycle scripts can
|
||||
# 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} \
|
||||
BUN_INSTALL=/home/${USERNAME}/.bun \
|
||||
TZ=${TZ} \
|
||||
DEVCONTAINER=true \
|
||||
NODE_OPTIONS=--max-old-space-size=4096 \
|
||||
POWERLEVEL9K_DISABLE_GITSTATUS=true
|
||||
|
||||
# Native build toolchain that gitnexus/postinstall needs. It compiles
|
||||
# tree-sitter native bindings, the vendored Dart/Proto/Swift grammars, and the
|
||||
# @ladybugdb/core N-API addon (a native Node add-on). python3, make, and g++ are
|
||||
# required. This mirrors the apt block in the existing Dockerfile.cli /
|
||||
# gitnexus/Dockerfile.test images.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
python3 make g++ git curl ca-certificates bash unzip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create and chown the named-volume mount points (~/.npm, ~/.local,
|
||||
# /commandhistory) up front. That way an empty volume inherits `node:node`
|
||||
# ownership the first time it is mounted. The three CLI config dirs (~/.claude,
|
||||
# ~/.codex, ~/.cursor) are bind-mounted from the host instead. A bind mount
|
||||
# completely hides the image-side ownership, so those paths need no chown here.
|
||||
RUN mkdir -p \
|
||||
/home/${USERNAME}/.npm \
|
||||
/home/${USERNAME}/.local/bin \
|
||||
/commandhistory \
|
||||
&& chown -R ${USERNAME}:${USERNAME} \
|
||||
/home/${USERNAME}/.npm \
|
||||
/home/${USERNAME}/.local \
|
||||
/commandhistory
|
||||
|
||||
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 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 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
|
||||
# version is fetched even though the install script itself is downloaded fresh
|
||||
# on every build. NOTE: this is the ONE remote script we run unverified in
|
||||
# this image — Cursor and the base image are pinned by sha256/digest. Hardening
|
||||
# path: switch to a pinned tarball + per-arch sha256 in the Cursor style
|
||||
# (artifacts at github.com/oven-sh/bun/releases). `BUN_INSTALL` is set in ENV
|
||||
# above so the binary lands at a known path regardless of any rc-file edits
|
||||
# the installer makes (which we ignore — we own the shell rc files).
|
||||
RUN set -eux; \
|
||||
curl -fsSL --retry 3 --max-time 120 https://bun.sh/install \
|
||||
| bash -s "bun-v${BUN_VERSION}"; \
|
||||
test -x "${BUN_INSTALL}/bin/bun"
|
||||
|
||||
# Put ~/.local/bin and Bun's bin dir on PATH for interactive shells and
|
||||
# lifecycle scripts. ~/.local/bin is where Cursor's installer drops the `agent`
|
||||
# and `cursor-agent` symlinks; ${BUN_INSTALL}/bin is where the Bun installer
|
||||
# drops `bun` / `bunx`.
|
||||
ENV PATH=/home/${USERNAME}/.local/bin:${BUN_INSTALL}/bin:${PATH}
|
||||
364
.devcontainer/README.md
Normal file
364
.devcontainer/README.md
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
# GitNexus Devcontainer
|
||||
|
||||
A cross-platform Dev Container that pre-installs Claude Code, OpenAI Codex CLI, Cursor CLI, and Bun alongside the GitNexus native build chain. Supported hosts: **macOS, Linux, Windows 11 (native), and Windows 11 via WSL2.** Windows-native needs a **one-time `HOME` env var setup** — handled automatically by the `initializeCommand` on first run (see [Windows 11 setup](#windows-11-setup)).
|
||||
|
||||
> ### ⚠️ Read this before using it on a work machine
|
||||
>
|
||||
> This devcontainer **does not write to your host AI-CLI config.** Your skills, agents, commands, plugins, memory, prompts, and rules are **copied once** from a read-only host stage into a per-container volume on first create; the container edits its own copy and can never write back. So a compromised workspace dependency running in the container **cannot** drop a malicious agent, command, skill, or plugin onto your host for your next host CLI session to load — the write-through vector earlier versions had is closed. Your **credentials** (Claude/Codex/Cursor logins, plus `gh`) likewise stay in per-container volumes and are never written back, and `~/.ssh`, `~/.aws`, `~/.azure`, and `~/.docker` are mounted **read-only**.
|
||||
>
|
||||
> What is **still** exposed: the read-only host stages (`/host/.claude`, `/host/.codex`, `/host/.cursor`, `/host/.claude-mem`) and the read-only credential mounts are all **readable** inside the container. A compromised dependency can therefore READ your host CLI config, memory, SSH/cloud credentials, and GitHub token — and there is **no egress firewall yet**, so it has the network to exfiltrate what it reads. Read-only protects you from tampering and write-back, not from disclosure.
|
||||
>
|
||||
> The trade-off of the copy model: host and container config **diverge after first create.** A skill or plugin you add on the host later won't appear in the container until you wipe the config volume and rebuild (see [§ Rebuild / reset](#rebuild--reset)). Edits you make inside the container persist across rebuilds but never reach the host.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Install [Docker Desktop](https://docs.docker.com/desktop/) (Windows/macOS) or Docker Engine (Linux).
|
||||
2. Install [VS Code](https://code.visualstudio.com/) with the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers).
|
||||
3. Install [Node.js](https://nodejs.org/) on the **host** (Node 18+). This is the only host-side toolchain dependency beyond Docker and VS Code — the devcontainer's `initializeCommand` runs `node .devcontainer/ensure-host-config-dirs.cjs` to set up the bind-mount source directories before container create. If you already use Claude Code or another Node-based CLI on the host, you're already set.
|
||||
4. Open the repo in VS Code → Command Palette → **Dev Containers: Reopen in Container**.
|
||||
5. Wait for the first build (~3–6 minutes) and `postCreateCommand` to finish installing workspace dependencies.
|
||||
6. Authenticate the three CLIs once — see [First-time CLI authentication](#first-time-cli-authentication) below.
|
||||
|
||||
## Windows 11 setup
|
||||
|
||||
### Windows-native (one-time setup, then "just works")
|
||||
|
||||
The host bind mounts use `${localEnv:HOME}/.claude` (and `.codex`, `.cursor`, `.ssh`, `.config/git`, `.config/gh`, `.gitconfig`). VS Code resolves `${localEnv:HOME}` by reading its own process env, and Windows doesn't set `HOME` by default — it uses `USERPROFILE`. So the bind mounts can't resolve until you tell Windows to also expose your profile as `HOME`.
|
||||
|
||||
The `initializeCommand` (`node .devcontainer/ensure-host-config-dirs.cjs`) handles this automatically:
|
||||
|
||||
1. **First time you Reopen in Container**, the script detects the missing `HOME`, runs `setx HOME "%USERPROFILE%"` (which writes to your user-level Windows env — no admin needed), prints a one-time setup banner, and exits.
|
||||
2. **Close all VS Code windows** (File → Exit) and reopen. VS Code picks up the new `HOME` at startup.
|
||||
3. **Reopen in Container again.** The script now sees `HOME=C:\Users\<you>`, skips the setup block, creates the bind-mount source dirs, and Docker brings the container up.
|
||||
|
||||
Subsequent rebuilds work normally with no extra steps. The `HOME` env var is set persistently in your Windows user environment, so it'll be there for every future VS Code session (and any other tool that wants `HOME`).
|
||||
|
||||
If you'd rather set it manually before opening the container:
|
||||
|
||||
```powershell
|
||||
setx HOME "%USERPROFILE%"
|
||||
# Close & reopen VS Code
|
||||
```
|
||||
|
||||
### Known trade-offs of Windows-native vs WSL2
|
||||
|
||||
Windows-native works, but Docker Desktop's Windows bind-mount layer has rough edges that WSL2 avoids:
|
||||
|
||||
- **File watchers can miss events.** Vite / jest `--watch` running inside the container watching workspace files mounted from `D:\...` may miss changes — chokidar polling (`CHOKIDAR_USEPOLLING=true`) is the usual workaround.
|
||||
- **`npm install` is 3-5× slower** through the Windows-to-Linux bind-mount translation than on a WSL2-native filesystem.
|
||||
- **Permission edge cases.** The husky `.husky/_/h` EPERM class we hit earlier in this PR is specific to Windows-side bind mounts changing UID ownership between container runs. `post-create.sh` clears the cache defensively to keep this from being fatal, but it's still a real source of friction.
|
||||
|
||||
If you hit any of those and want to migrate to WSL2 later, the steps are below.
|
||||
|
||||
### WSL2 (faster, fewer edge cases)
|
||||
|
||||
To clone and open the repo inside WSL2:
|
||||
|
||||
```bash
|
||||
# 1. Install WSL2 and a Linux distro if you haven't already.
|
||||
wsl --install -d Ubuntu
|
||||
|
||||
# 2. Enter WSL.
|
||||
wsl
|
||||
|
||||
# 3. Clone the repo inside your WSL2 home directory.
|
||||
cd ~
|
||||
git clone https://github.com/abhigyanpatwari/GitNexus.git
|
||||
cd GitNexus
|
||||
|
||||
# 4. Launch VS Code from inside WSL — this opens VS Code attached to the WSL2
|
||||
# filesystem, so `${localEnv:HOME}` resolves to the WSL user's home and
|
||||
# subsequent "Reopen in Container" uses the WSL2-side path.
|
||||
code .
|
||||
```
|
||||
|
||||
Then run **Dev Containers: Reopen in Container**. The workspace will be bind-mounted from `\\wsl$\Ubuntu\home\<user>\GitNexus`, which is fast and gives reliable file-system events. **Make sure Docker Desktop's WSL integration is enabled** for your distro: Docker Desktop → Settings → Resources → WSL Integration → toggle on the distro you cloned into.
|
||||
|
||||
## macOS
|
||||
|
||||
Open the repo folder in VS Code → **Reopen in Container**. The image is multi-arch; on Apple Silicon you'll pull the `linux/arm64` variant automatically.
|
||||
|
||||
## Linux
|
||||
|
||||
Same as macOS — open in VS Code and reopen in container. `updateRemoteUserUID: true` (default) shifts the container's `node` user UID/GID to match your host user, so bind-mounted files stay writable without extra setup.
|
||||
|
||||
## How CLI state flows from your host
|
||||
|
||||
### AI CLIs (Claude Code, Codex, Cursor): copy-once from a read-only host stage + per-container credentials
|
||||
|
||||
The three AI CLIs use a **copy-from-read-only-stage topology**: the host's `~/.<cli>` folders (and `~/.claude-mem`) are mounted **read-only** at `/host/.<cli>`, and `post-create.sh` copies out of them into per-container named volumes. Credentials, identity, and single config files are copied on **every** create; the shareable subdirs (plugins, skills, agents, memory, commands, prompts, rules) are copied **once** on first create and then owned by the container. Nothing is bind-mounted read-write into the host's CLI config, so the container can never modify your host setup. Session sub-paths overlay the config volume via their own named volumes (Docker mount precedence — more specific path wins).
|
||||
|
||||
| Mount | Source | Target | Mode | Purpose |
|
||||
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Container Claude config dir | _named volume_ `claude-config-${devcontainerId}` | `/home/node/.claude` | rw | Per-container credentials + identity |
|
||||
| Container Codex config dir | _named volume_ `codex-config-${devcontainerId}` | `/home/node/.codex` | rw | Per-container credentials |
|
||||
| Container Cursor config dir | _named volume_ `cursor-config-${devcontainerId}` | `/home/node/.cursor` | rw | Per-container credentials |
|
||||
| Container gh config dir | _named volume_ `gh-config-${devcontainerId}` | `/home/node/.config/gh` | rw | Per-container `gh` auth (`hosts.yml`/`config.yml`) seeded from host stage; in-container login persists |
|
||||
| **Claude sessions** (overlay on the config volume) | _named volume_ `…-claude-sessions-${devcontainerId}` | `/home/node/.claude/projects` | rw | `--resume` transcripts; survives the `<cli>-config` volume wipe — see [Session resume](#session-resume-across-container-recreation) |
|
||||
| **Codex sessions** | _named volume_ `…-codex-sessions-${devcontainerId}` | `/home/node/.codex/sessions` | rw | `codex resume` rollouts; SQLite index backfills on recreation |
|
||||
| **Cursor sessions** | _named volumes_ `…-cursor-sessions-${devcontainerId}`, `…-cursor-projects-${devcontainerId}` | `/home/node/.cursor/chats`, `/home/node/.cursor/projects` | rw | `cursor-agent resume` store (best-effort — layout reverse-engineered) |
|
||||
| **claude-mem store** | _named volume_ `claude-mem-${devcontainerId}` | `/home/node/.claude-mem` | rw | claude-mem's SQLite DB + Chroma vector store; **seeded once** from `/host/.claude-mem`, then container-private — see note below |
|
||||
| Host Claude state, read-only stage | `$HOME/.claude` | `/host/.claude` | **read-only** | `post-create.sh` reads credentials + identity from here on container-create |
|
||||
| claude-mem store, read-only stage | `$HOME/.claude-mem` | `/host/.claude-mem` | **read-only** | `post-create.sh` seeds the claude-mem volume from here on first create |
|
||||
| Host Codex state, read-only stage | `$HOME/.codex` | `/host/.codex` | **read-only** | Same purpose for Codex |
|
||||
| Host Cursor state, read-only stage | `$HOME/.cursor` | `/host/.cursor` | **read-only** | Same purpose for Cursor |
|
||||
| **Claude shareable subdirs** | _seeded into the config volume from_ `$HOME/.claude/{plugins/marketplaces,plugins/cache,skills,agents,memory,commands}` | same under `/home/node/.claude/` | n/a (copy) | **Seed-once** copy from the read-only stage; container owns its copy after |
|
||||
| **Codex shareable subdirs** | _seeded from_ `$HOME/.codex/{plugins,prompts,memories,skills}` | same under `/home/node/.codex/` | n/a (copy) | **Seed-once** copy (whole `plugins/` dir — no path-bearing registry inside it) |
|
||||
| **Cursor shareable subdirs** | _seeded from_ `$HOME/.cursor/{plugins/marketplaces,plugins/local,rules,commands,agents,skills}` | same under `/home/node/.cursor/` | n/a (copy) | **Seed-once** copy of the Cursor 2.5 plugin/rules/commands surface |
|
||||
|
||||
**What gets seeded once from the host (copy, not bind):**
|
||||
|
||||
- **Claude**: `plugins/marketplaces`, `plugins/cache`, `skills/`, `agents/`, `memory/`, `commands/`
|
||||
- **Codex**: `plugins/` (whole dir), `prompts/`, `memories/`, `skills/`
|
||||
- **Cursor**: `plugins/marketplaces`, `plugins/local`, `rules/`, `commands/`, `agents/`, `skills/`
|
||||
|
||||
On the **first** container-create, `post-create.sh` copies each of these out of the read-only `/host/.<cli>` stage into the per-container config volume, then writes a `.devcontainer-shareable-seeded` marker. On every later rebuild the marker is present, so the copy is skipped and the container keeps whatever it has accumulated. A plugin/skill/agent you install **inside** the container persists across rebuilds; one you add on the **host** after first create won't appear in the container until you remove the config volume and rebuild (see [§ Rebuild / reset](#rebuild--reset)). Nothing here is writable back to the host — `/plugin marketplace add` inside the container installs into the container's own volume copy, not your host `~/.<cli>/plugins/`.
|
||||
|
||||
**Single config files are copied on container-create, not bind-mounted** — on Docker Desktop Windows a single-file bind is 9p while the named volume is ext4, and atomic config writes (`tmp` → rename onto target) trip EXDEV (this is what caused Codex's `config/batchWrite failed in TUI`). So these are synced from host on rebuild and the container rewrites its own copy until the next rebuild: `settings.json` + `$HOME/.claude.json` (Claude), `config.toml` (Codex), `cli-config.json` + `mcp.json` (Cursor). `hooks.json` (Cursor) is deliberately **not** synced — Cursor hooks execute shell commands, so sharing them would widen the supply-chain attack surface; add it yourself if you want it.
|
||||
|
||||
**Plugin registry files with absolute paths are translated, not copied verbatim** — Claude's `known_marketplaces.json` / `installed_plugins.json` / `plugin-catalog-cache.json` and Cursor's `installed_plugins.json` bake in `C:\Users\…` (Windows) or `/Users/…` (macOS) install paths. `post-create.sh` rewrites those to `/home/node/.<cli>/plugins/…` and writes the result into the named volume, so plugins resolve inside Linux instead of failing with `cache-miss`. This translation is **also seed-once per CLI** — it runs only for a CLI being seeded that create (`translate-plugin-registries.cjs claude cursor`), so it stays consistent with the seed-once `cache/` copy and won't overwrite a plugin you installed inside the container on a later rebuild. Codex needs no translation — its enablement registry is `config.toml` (git URLs + logical keys, no filesystem paths), so its whole `plugins/` dir is copied as-is.
|
||||
|
||||
**What stays per-container (in the named volume) and is synced from host on container-create:**
|
||||
|
||||
- `.credentials.json` (Claude OAuth tokens), `auth.json` (Codex), `cli-config.json` (Cursor) — credentials
|
||||
- `~/.claude/.claude.json` (Claude's identity-only file: `userID`, `oauthAccount`, migration tracking) — kept per-container so logging in via container doesn't overwrite host's stored identity
|
||||
|
||||
`post-create.sh` runs on every container-create, copies host's credentials into the volume if present, then container manages refresh from there. Sync is "always overwrite if host has the file, otherwise leave container alone". So:
|
||||
|
||||
- Host has credentials → container starts logged in.
|
||||
- Host has no credentials → `claude login` / `codex login --device-auth` / `cursor-agent login` inside container; credentials stay in the named volume across rebuilds (volume is keyed by `${devcontainerId}`, stable for the workspace path).
|
||||
- `claude logout` inside container clears volume credentials only; host is untouched.
|
||||
|
||||
**Why CLAUDE_CONFIG_DIR is intentionally NOT set:** Claude's default `~/.claude` matches the named-volume mount target, so the env var added no behavior — but setting it changed which file Claude reads `hasCompletedOnboarding` from. With it set, Claude reads `$CLAUDE_CONFIG_DIR/.claude.json` (the small identity-only file) and re-onboards every container; without it, Claude reads `$HOME/.claude.json` (copied from the read-only `/host/.claude.json` stage on container-create via `seed-claude-config.cjs`, with `hasCompletedOnboarding: true`).
|
||||
|
||||
**Host CLI config is protected from write-through.** The shareable dirs are copied out of a **read-only** stage into the container's own volume, so a compromised npm package in the workspace dep tree — running inside the container — **cannot** write a malicious agent, command, skill, or plugin back to `~/.claude/`, `~/.codex/`, or `~/.cursor/` on the host. The earlier design bind-mounted these read-write and accepted that write-through as the cost of live sync; this design closes it. An even earlier alternative (read-only stage + symlinks) made `/plugin marketplace add` inside the container fail with EROFS; copying into a writable volume avoids that, because the container writes to its own copy rather than a read-only mount. What a compromised dependency can still do is **read** the read-only host stages (`/host/.<cli>`, `/host/.claude-mem`) and the read-only credential mounts and exfiltrate them — there is [no egress firewall yet](#whats-not-included-yet). The cost of the copy model is **divergence**: host edits made after first create don't reach the container until you wipe the config volume and rebuild.
|
||||
|
||||
**Refresh-token divergence between rebuilds.** Container's credentials match host's at container-create time; after that, container manages its own refresh until the next rebuild. Anthropic rotates refresh tokens on every use, so an unattended container that hasn't talked to the API in weeks can hit a silent 401 if the host has refreshed since. Re-run `claude login` inside the container, or rebuild, to recover.
|
||||
|
||||
**claude-mem is seeded once, then container-private.** The [claude-mem](https://github.com/thedotmack/claude-mem) store (`$HOME/.claude-mem` — a multi-GB SQLite DB `claude-mem.db` + `-wal`/`-shm`, plus a Chroma vector store `chroma/chroma.sqlite3` and its HNSW index binaries) is the one shareable-looking folder that is **deliberately not a host bind**, for the same SQLite reason as sessions below: a multi-GB WAL database over the 9p/virtiofs bind risks unreliable `fcntl` locking and corruption — sharply so if claude-mem ran on the host and in the container against the same DB at once. So it gets its own per-container named volume (`claude-mem-${devcontainerId}`), and `post-create.sh` **seeds it once** from the read-only `/host/.claude-mem` stage _only when the volume has no DB yet_. The first container-create copies the host's store in (a one-time copy, possibly several GB); every later rebuild keeps whatever the container accumulated and skips the copy. The container's memory and the host's **diverge from that seed point** — writes do not flow back — which is the price of keeping SQLite off a shared bind. To re-seed from the host's current store, remove the volume (`docker volume rm claude-mem-<id>`) and rebuild. `ensure-host-config-dirs.cjs` creates an empty `~/.claude-mem` on hosts that never installed claude-mem, so the read-only stage bind always resolves; the seed then finds no DB and the container simply starts with empty memory.
|
||||
|
||||
### Session resume across container recreation
|
||||
|
||||
`claude --resume`, `codex resume`, and `cursor-agent resume` all read **local** transcript files. Those live _inside_ each CLI's config dir, which is a per-container named volume — so they already survive an ordinary **Rebuild Container**. What they did _not_ survive were the very things this README tells you to do: `docker volume rm <cli>-config-${devcontainerId}` to force a re-login or clear an `EACCES`, a `${devcontainerId}` change, or a full delete-and-recreate. Each of those drops the config volume and takes your session history with it.
|
||||
|
||||
So the resume/transcript directories get their **own** named volumes (mount group 6 in `devcontainer.json`), keyed like the `node_modules` volumes (`${localWorkspaceFolderBasename}-…-${devcontainerId}`) and mounted _over_ the config volume at the session sub-paths:
|
||||
|
||||
| Resume command | Persisted volume → target | What's stored |
|
||||
| -------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `claude --resume` / `--continue` | `…-claude-sessions-…` → `~/.claude/projects` | `<encoded-cwd>/<uuid>.jsonl` transcripts + `sessions-index.json`. Container cwd is always `/workspace`, so only that slice is stored. Pure JSONL/JSON — no SQLite. |
|
||||
| `codex resume` / `resume --last` | `…-codex-sessions-…` → `~/.codex/sessions` | `YYYY/MM/DD/rollout-*.jsonl`. The `state_5.sqlite` thread index stays on the config volume (a single WAL file we don't split out); when it's absent after a recreation, Codex rebuilds it from these rollouts on the next start (a one-time backfill). |
|
||||
| `cursor-agent resume` / `ls` | `…-cursor-sessions-…` → `~/.cursor/chats`; `…-cursor-projects-…` → `~/.cursor/projects` | `chats/{hash}/{uuid}/store.db` (one SQLite db per session, each in its own dir) + `projects/.../agent-transcripts`. cursor-agent's layout is reverse-engineered, so treat this as best-effort. |
|
||||
|
||||
Because these are **separate** volumes from `<cli>-config-${devcontainerId}`, the re-login fix (`docker volume rm claude-config-…`) no longer destroys your sessions — that was the point.
|
||||
|
||||
**Survives:** Rebuild Container, Rebuild Without Cache, a full delete-and-recreate of the container, and the `docker volume rm <cli>-config-…` re-login / `EACCES` fix.
|
||||
|
||||
**Does _not_ survive** (same durability tier as the `node_modules` volumes): `docker volume prune`, a `${devcontainerId}` change (moving the checkout to a new path, or switching between Windows-native and WSL2), or moving to a new machine. To deliberately wipe sessions, remove the session volumes too — see [Rebuild / reset](#rebuild--reset). Two checkouts with the **same folder name** on one host would share session volumes only if they also share a `${devcontainerId}`; they don't, so they stay separate.
|
||||
|
||||
**First rebuild after adopting this, one-time:** if a container created _before_ these volumes existed already had sessions on the config volume (`~/.claude/projects`, `~/.codex/sessions`, …), the new empty session volume mounts _over_ that sub-path and **masks** the old content — same Docker-precedence shadowing described for plugins above. The old sessions are hidden, not deleted. To carry them forward once, copy them out of the config volume into the session volume; or just start fresh — new sessions land on the session volume from then on.
|
||||
|
||||
**Why sessions are container-private and not even seeded from the host.** The shareable config dirs are _seeded once_ from the host (you want your skills/agents/plugins in the container). Sessions are deliberately _not_ seeded and never touch the host, because a transcript can contain anything you pasted or the agent read — API keys, file contents, connection strings. Binding or copying them to/from the host would (a) spill that to host disk, (b) add a write-through surface a compromised dependency can reach (there's still [no egress firewall](#whats-not-included-yet)), and (c) leak _every other project's_ transcripts into the container (Codex `sessions/` and Cursor `chats/` aren't project-scoped). Container-private volumes avoid all three while still surviving recreation. And Claude/Codex transcripts embed the container cwd (`/workspace`), so even if you _did_ bind them to the host, the host CLI wouldn't natively `--resume` them — its encoded-cwd folder differs.
|
||||
|
||||
**Opt in to host-shared sessions anyway.** If you want transcripts visible/portable on the host and accept the trade-offs above, uncomment the host-bind block in `devcontainer.json` (just below the group-6 volumes) and add the matching source dirs to `ensure-host-config-dirs.cjs`'s `DIRS` so Docker can resolve the binds. That block scopes Claude to `/workspace`'s encoded subdir to limit the cross-project leak; the Codex and Cursor stores can't be scoped that way, so they expose every project's transcripts.
|
||||
|
||||
### Other host bind mounts
|
||||
|
||||
| Container path | Host source | Mode | Why |
|
||||
| --------------- | ------------------- | ------------- | -------------------------------------------------------------------------------------- |
|
||||
| `~/.config/git` | `$HOME/.config/git` | **read-only** | XDG-style git config / ignore / attributes |
|
||||
| `~/.ssh` | `$HOME/.ssh` | **read-only** | SSH commit signing + git push over SSH |
|
||||
| `~/.config/gh` | `$HOME/.config/gh` | **copy → volume** | `gh` CLI auth (PR/issue create, checks) — seeded from your host login on create into a per-container volume; in-container `gh auth login` persists across rebuilds and never writes back to the host |
|
||||
| `~/.docker` | `$HOME/.docker` | **read-only** | Container registry auth + buildx config (inert until you add Docker CLI via a Feature) |
|
||||
| `~/.aws` | `$HOME/.aws` | **read-only** | AWS CLI / SDK credentials (forward-compat — empty by default) |
|
||||
| `~/.azure` | `$HOME/.azure` | **read-only** | Azure CLI credentials (forward-compat — empty by default) |
|
||||
|
||||
**Why `ssh`/`aws`/`azure`/`docker` are read-only, and why `gh` is copied into a volume:** `ssh`/`aws`/`azure` are consumed read-only by their clients (the SSH client and the AWS/Azure SDKs only read their credential files), so a one-way mount loses nothing. `docker` _can_ write its own state (`docker login` / buildx write `config.json`), but a read-write host bind would let a compromised in-container dependency rewrite your host `~/.docker/config.json` (point a `credHelper` at an attacker-controlled binary) — a credential-takeover vector. The common case is _reading_ an existing host login, so `docker` stays **read-only**: registry pulls/pushes using your host creds work, only a `docker login` inside the container won't persist back. `gh` used to be read-only for the same reason, but that meant an in-container `gh auth login` had nowhere to write and silently failed. So `gh` now uses the **copy-into-volume** model (the same one the AI-CLI credentials use): the host `~/.config/gh` is a read-only _stage_ at `/host/.config/gh`, and `post-create.sh` copies `hosts.yml`/`config.yml` out of it into the per-container `gh-config` volume on create. The container gets a **writable** copy — `gh auth login` / `gh auth refresh` inside the container now work and persist across rebuilds — while the read-only stage guarantees nothing is ever written back to the host's token. If you want `docker` to behave the same way, give it the same treatment (a `/host/.docker` stage + a docker-config volume + a copy step in `post-create.sh`).
|
||||
|
||||
`~/.gitconfig` is **not** bind-mounted — VS Code's Dev Containers extension auto-copies the host's gitconfig into the container at attach time (this is built-in behavior, not something this devcontainer configures). The bind-mount approach conflicts with that auto-copy mechanism, so we let VS Code own it. The end result is the same: your host's `user.name` / `user.email` are available inside the container.
|
||||
|
||||
If a host source dir doesn't exist when the container is first created, the `initializeCommand` (`node .devcontainer/ensure-host-config-dirs.cjs`) creates it empty — so the bind mount always has a valid source.
|
||||
|
||||
### Per-CLI quirks worth knowing
|
||||
|
||||
- **Claude Code on macOS** stores credentials in the system Keychain, not in `~/.claude/.credentials.json`. The sync silently no-ops; run `claude login` inside the container once and the named volume persists it.
|
||||
- **Codex on macOS / Linux with `cli_auth_credentials_store = "keyring"`** stores auth in the OS keyring (Keychain / Secret Service), so `~/.codex/auth.json` may not exist on host. Same fallback: `codex login --device-auth` inside the container.
|
||||
- **Cursor CLI inside containers** has [known upstream auth issues](https://forum.cursor.com/t/cursor-agent-authentication-issue-inside-docker/143995) — even with a correctly-synced `cli-config.json`, you may need to re-run `cursor-agent login` inside the container.
|
||||
- **Stale named volumes from old rebuilds can carry forward.** If you delete and re-create the same workspace, or if a prior container left interim state with a different `userID`, deleting the named volumes before rebuild guarantees a clean sync: `docker volume rm claude-config-${devcontainerId} codex-config-${devcontainerId} cursor-config-${devcontainerId}` (look them up with `docker volume ls | grep -config-`).
|
||||
- **User-scope MCP servers with absolute host paths won't resolve in-container.** `~/.claude.json` (Claude), `~/.codex/config.toml` (Codex), and `~/.cursor/mcp.json` (Cursor) are copied from host on container-create, so their user-scope `mcpServers` entries come along. But an entry whose `command` is an absolute host path (`C:\tools\foo.exe`, `/usr/local/bin/foo`) points at a binary that doesn't exist in the container — that server silently fails to launch. Only registry/`npx`-based servers (like this repo's `.mcp.json`, which uses `npx -y gitnexus@latest mcp`) and remote/URL servers work unchanged. The path-translation pass only rewrites `*/.<cli>/plugins/*` registry paths, **not** arbitrary `mcpServers` command paths (there's no correct container target for a host-local binary). Install such MCP servers inside the container, or use `npx`/remote ones.
|
||||
- **Host config is seeded once per devcontainer, then diverges — this now applies to everything.** A `mcpServers` entry, setting, plugin, skill, agent, or command you add **on the host after** the container was created is not visible in the container until you remove the config volume and rebuild. Single config files (`mcpServers`, `settings.json`, …) are copy-on-create; the shareable dirs (plugins/skills/agents/memory/commands/prompts/rules) are copy-on-**first**-create (they persist across ordinary rebuilds and aren't even re-copied). Both diverge from the host after their copy. To pull host-side changes in, wipe the relevant volume and rebuild (see [§ Rebuild / reset](#rebuild--reset)).
|
||||
- **Plugins/skills/agents installed in-container persist; they do not reach the host.** A `/plugin marketplace add` (or `codex plugin add`, or a new skill/agent) inside the container writes to the container's own config volume and survives ordinary rebuilds. It never appears on the host — the host dirs are read-only sources, not bind targets. To get a plugin onto the host, install it on the host (then wipe + rebuild to seed it into the container).
|
||||
- **No cross-checkout plugin contention.** Because each container copies plugins into its own per-`${devcontainerId}` volume rather than sharing one host bind source, two containers (or checkouts) installing plugins at the same time no longer interleave git clones/extractions against a shared host dir. Each writes only its own copy.
|
||||
|
||||
### What you still don't have inside the container
|
||||
|
||||
These are commonly-needed CLIs that aren't installed by default — adding them would be follow-up work, not in this PR's scope:
|
||||
|
||||
- **Docker CLI** (for `docker push` / `docker build` from inside the container). Add via `ghcr.io/devcontainers/features/docker-outside-of-docker:1` to the `features` block — `~/.docker/` is already mounted **read-only**, so your host `docker login` state works immediately for pulls/pushes; an in-container `docker login` won't persist to the host (drop `,readonly` on that mount if you need it to).
|
||||
- **AWS CLI / Azure CLI / gcloud / kubectl** — same pattern: add the matching Feature, the host config dirs already flow through.
|
||||
- **Private npm registry auth** (`~/.npmrc`) — you don't have a global one on this host. If you ever start using private packages, add `source=${localEnv:HOME}/.npmrc,target=/home/node/.npmrc,type=bind,readonly` to the mounts.
|
||||
|
||||
That means:
|
||||
|
||||
- **Authentication is shared.** If you're already logged in on the host (`claude login`, `codex login`, `cursor-agent login`, `gh auth login`), you're already logged in inside the container. No second login step.
|
||||
- **Plugins, skills, agents, memory, and commands are seeded from the host once, then container-private.** On first create the container copies your host's plugins/skills/agents/memory/commands (and Codex prompts/memories, Cursor rules) into its own volume. After that they're independent: install or edit inside the container and it stays in the container (persists across rebuilds); add a plugin or agent on the host and the container won't see it until you wipe the config volume and rebuild. Nothing the container does reaches the host. (`settings.json` and the user-scope `~/.claude.json` are copy-on-create the same way; `~/.claude/projects/` is container-local by design.)
|
||||
- **Git identity comes from the host.** Commits from inside the container use your host's `user.name` / `user.email` — VS Code's Dev Containers extension auto-copies your `~/.gitconfig` into the container at attach time. Any XDG-style config under `~/.config/git/` flows through via the read-only bind mount. To change git identity, edit `~/.gitconfig` on the host (container-side `git config --global` writes to a container-local file that's discarded on rebuild).
|
||||
- **SSH keys flow through (read-only).** Push over SSH remotes and SSH commit signing work inside the container using your host keys. The mount is read-only so container code can't exfiltrate or modify private keys — agent-perspective, this means you get git operations but the keys stay vendor-side.
|
||||
- **`gh` auth is shared, and in-container logins persist.** If you're logged in on the host, `gh pr create`, `gh pr checks`, `gh issue create` work inside the container without re-authenticating. If you're not, run `gh auth login` inside the container once — because `gh` config lives in a writable per-container volume (seeded from the host stage), that login persists across rebuilds and never touches the host's token.
|
||||
- **No per-workspace duplication.** All your devcontainers across all your projects see the same host CLI state, just like all your host shells do.
|
||||
|
||||
The bind mount source directories are guaranteed to exist by the `initializeCommand` (`node .devcontainer/ensure-host-config-dirs.cjs`), which runs on the host before container create. It's a Node script (not a shell one-liner) so the same command works on Windows `cmd.exe` and POSIX shells. It creates the top-level bind-mount source dirs — `~/.claude`, `~/.codex`, `~/.cursor`, `~/.claude-mem`, plus `~/.ssh`, `~/.docker`, `~/.aws`, `~/.azure`, `~/.config/{gh,git}`. It deliberately does **not** pre-create the shareable subdirs (skills/agents/plugins/…): those are no longer bind sources (they're copied out of the whole-`~/.<cli>` read-only stage), and pre-creating empty ones would needlessly write into the host of someone who never used that CLI.
|
||||
|
||||
### Trust boundary, concretely
|
||||
|
||||
Host and container share a single trust boundary by design — fine for personal-dev, but the consequence is concrete. Any malicious npm package or `postinstall` script in the workspace dep tree, running inside the container, has direct **read** access to:
|
||||
|
||||
- **Host AI CLI state** — the read-only stage at `/host/.claude`, `/host/.codex`, `/host/.cursor`, `/host/.claude-mem`, which exposes your **entire** host `~/.<cli>` tree (credentials, identity, AND the shareable skills/agents/plugins/memory/commands) for _reading_. The container copies what it needs out of this stage; a compromised dep can read all of it. It is read-only, so none of it can be written back
|
||||
- The **container's own credential snapshots** at `/home/node/.claude/.credentials.json` etc. (copied from host on container-create)
|
||||
- `~/.claude/memory/` / per-project memory (which may contain user-stored secrets if you've used the `/remember` skill)
|
||||
- The **current container's own session transcripts** (`~/.claude/projects`, `~/.codex/sessions`, `~/.cursor/chats`/`projects` — the group-6 volumes), which can hold anything pasted into or read during a session. These are container-private (see one-way note below), so this is read access to _this_ container's sessions only, not the host's or other projects'
|
||||
- Your **`gh` token** (`~/.config/gh`)
|
||||
- Your **SSH private keys** (`~/.ssh/`)
|
||||
- Docker registry tokens in **`~/.docker/config.json`** (if you've `docker login`-ed)
|
||||
- AWS/Azure CLI credentials if you've populated `~/.aws/` or `~/.azure/`
|
||||
|
||||
It does **not** have write-through to the host's CLI config. The shareable dirs are copied out of the read-only stage into the container's own volume, so a compromised in-container dep **cannot** write into your host `~/.claude/{plugins,agents,skills,commands,memory}/`, `~/.codex/{plugins,prompts,memories,skills}/`, or `~/.cursor/{plugins,rules,commands,agents,skills}/`. The persistence vector earlier versions had — drop a malicious auto-loaded agent/command/skill/rule onto the host, have it run in your next **host** session — is closed: there is no writable path from the container to those host folders. (Cursor's `hooks.json` is still additionally withheld from even the _container's_ copy, because hooks fire without an agent invoking them.) The boundary is now one-way for **all** of the host CLI config, not just credentials.
|
||||
|
||||
**What stays one-way (genuinely protected):** everything. Credentials never flow back to host — `.credentials.json` / `auth.json` / `cli-config.json` live only in the per-container named volumes, and the `/host/.<cli>` stage they're copied from is mounted **read-only**, so the snapshot can't be overwritten back. The shareable AI-CLI dirs (skills/agents/plugins/memory/commands/prompts/rules) are now copy-on-create from that same read-only stage, so they have the one-way property too — readable for the copy, never writable back. `~/.ssh`, `~/.config/git`, `~/.aws`, `~/.azure`, and **`~/.docker`** are read-only binds with the same property — a compromised dep can _read_ your registry tokens but cannot _rewrite_ them to hijack your future host auth. **`~/.config/gh`** is now a read-only _stage_ copied into a per-container volume, so it keeps that same one-way property: the container reads it once to seed its own writable copy, and the read-only stage means an in-container `gh auth login` can never overwrite your host token. **Session transcripts** live in per-workspace named volumes (mount group 6) and are never seeded from or written back to the host, and the container can't see any _other_ project's transcripts. The opt-in host-bind block in `devcontainer.json` reverses that for sessions only — enable it only if you accept transcripts on host disk; see [Session resume across container recreation](#session-resume-across-container-recreation).
|
||||
|
||||
**The egress firewall is the key compensating control that is still missing.** It's deferred (see "What's not included (yet)" below), so a compromised package currently has unrestricted outbound network to exfiltrate anything in the read list above. Until it lands, treat that read surface as exposed to any code you run in the container — don't use this devcontainer on a machine whose host credentials you couldn't afford to rotate. The isolated-volume setup below removes host AI-CLI config/credentials from that surface entirely.
|
||||
|
||||
**If a workspace dep is ever found compromised**, rotate credentials at the vendor side — local file deletion is insufficient because tokens may have already left:
|
||||
|
||||
- Anthropic: [console.anthropic.com → Settings → Keys](https://console.anthropic.com/settings/keys), revoke the OAuth session under Account
|
||||
- OpenAI / Codex: [platform.openai.com/api-keys](https://platform.openai.com/api-keys), revoke session under Profile
|
||||
- Cursor: dashboard → Integrations, rotate API key + revoke CLI session
|
||||
- GitHub: `gh auth refresh` or revoke the token at github.com/settings/tokens
|
||||
|
||||
For high-trust enterprise environments where the container should not even be able to **read** host CLI state, remove the three read-only stage binds (`/host/.claude`, `/host/.codex`, `/host/.cursor`) — plus `/host/.claude-mem` and `/host/.claude.json` — from `.devcontainer/devcontainer.json`. With no stage to copy from, `post-create.sh`'s seed and credential-sync steps quietly do nothing (their `[ -f ]` / `[ -d ]` guards), and each devcontainer starts with empty, fully isolated config and credentials (Anthropic's reference pattern). You give up seeding your host setup into the container in exchange for removing host config/credentials from the container's read surface entirely; log in inside each container instead.
|
||||
|
||||
## First-time CLI authentication
|
||||
|
||||
Each CLI works either way:
|
||||
|
||||
- **Log in on host first** → the container picks it up automatically on the next rebuild (`sync_from_host` copies the credential file into the named volume during `post-create.sh`). Host stays the source of truth.
|
||||
- **Log in inside the container** → credentials write to the named volume. They persist across ordinary rebuilds (volume is keyed by `${devcontainerId}`, which is stable for a given workspace folder). The host's credentials are untouched.
|
||||
|
||||
You can mix and match per-CLI. A common setup is "Claude logged in on host, Codex/Cursor logged in inside container".
|
||||
|
||||
### Claude Code
|
||||
|
||||
```bash
|
||||
claude login
|
||||
```
|
||||
|
||||
Opens a browser auth flow. VS Code's port forwarding handles the OAuth callback automatically. After auth, `~/.claude/` is populated and visible from both host and container. The `DISABLE_AUTOUPDATER=1` env var prevents the in-container CLI from auto-updating — rebuild the container to pick up a newer Claude Code.
|
||||
|
||||
### OpenAI Codex CLI
|
||||
|
||||
```bash
|
||||
codex login --device-auth
|
||||
```
|
||||
|
||||
The device-code flow prints a URL and a one-time code. Visit the URL on your host browser, paste the code, and the CLI authenticates without needing a callback listener — this is the most reliable path inside containers. Credentials land in `~/.codex/auth.json` (shared with host).
|
||||
|
||||
`codex login` (browser-callback variant) also works but can be flaky in some headless contexts; prefer `--device-auth`.
|
||||
|
||||
### Cursor CLI
|
||||
|
||||
```bash
|
||||
cursor-agent login
|
||||
```
|
||||
|
||||
Opens a browser auth flow; VS Code's port forwarding handles the callback. Credentials persist in `~/.cursor/cli-config.json` (shared with host).
|
||||
|
||||
Verify any time with `cursor-agent status`.
|
||||
|
||||
## Alternative: API key authentication (CI / headless)
|
||||
|
||||
For non-interactive use (CI runners, automated scripts), all three CLIs accept API keys via env vars:
|
||||
|
||||
| CLI | Env var | Where to get the key |
|
||||
| ----------- | ------------------- | --------------------------------------------- |
|
||||
| Claude Code | `ANTHROPIC_API_KEY` | <https://console.anthropic.com/settings/keys> |
|
||||
| Codex | `OPENAI_API_KEY` | <https://platform.openai.com/api-keys> |
|
||||
| Cursor | `CURSOR_API_KEY` | Cursor dashboard → Integrations |
|
||||
|
||||
These env vars are intentionally **not** injected into the container from the host. `${localEnv:VAR}` resolves an unset host variable to an empty string, and some CLIs (Cursor in particular) treat a set-but-empty key as "use this key" rather than "fall back to stored login" — which would silently break the login flow for everyone who hasn't pre-set the host var.
|
||||
|
||||
To use an API key inside the container, export it in your terminal session:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
# or OPENAI_API_KEY, or CURSOR_API_KEY
|
||||
```
|
||||
|
||||
For persistence across container shells, carry the export via your VS Code [dotfiles repository](https://code.visualstudio.com/docs/devcontainers/containers#_personalizing-with-dotfile-repositories). VS Code clones the dotfiles repo into the container on attach and runs your install command, so the export lands in `~/.bashrc` / `~/.zshrc` per your own setup — and your API keys stay out of this repo's committed `devcontainer.json`.
|
||||
|
||||
A non-empty API key env var takes precedence over stored login credentials for each CLI.
|
||||
|
||||
## Port forwarding
|
||||
|
||||
| Port | Service | Notes |
|
||||
| ------ | -------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `5173` | Vite dev server (`gitnexus-web`) | Auto-forwarded with notification |
|
||||
| `4747` | `gitnexus serve` HTTP API | **Must not be remapped** — `gitnexus-web` hardcodes `http://localhost:4747` as the default backend URL |
|
||||
| `4173` | Static web (Vite preview) | Silently forwarded |
|
||||
|
||||
VS Code's Ports panel shows forwarded ports once their listener starts.
|
||||
|
||||
## Known gotchas
|
||||
|
||||
- **LadybugDB integration tests may fail in containers** (file-locking, `AGENTS.md` § Testing). Default to `npm run test:unit` inside the container; run integration tests on the host. Tracking issue: documented as a known limitation.
|
||||
- **Single-writer LadybugDB constraint** (`GUARDRAILS.md` § LadybugDB lock). Don't run `gitnexus analyze` on the host and inside the container against the same `.gitnexus/` directory simultaneously — the second writer will get `database busy`.
|
||||
- **Native grammar builds add ~30s to first install.** Tree-sitter Dart/Proto/Swift grammars build during `gitnexus`'s `postinstall`. To skip them (loses parsing for those three languages), set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` in your shell or add it to `remoteEnv` and rebuild.
|
||||
- **`tree-sitter-kotlin` warnings on install** are expected (per `AGENTS.md`). Ignore them.
|
||||
- **`.mcp.json` works inside the container**: `npx -y gitnexus@latest mcp` resolves cleanly because npm registry is reachable and the workspace bind mount exposes the same `.mcp.json` the host sees.
|
||||
- **Husky pre-commit fires inside the container** without extra setup. The root `npm install` (run automatically in `postCreateCommand`) installs the hook via `package.json` `prepare`.
|
||||
|
||||
## Rebuild / reset
|
||||
|
||||
- **Rebuild Container** (Command Palette) — re-runs the Dockerfile build and `postCreateCommand` against the existing named volumes (auth, history, **and sessions** persist).
|
||||
- **Rebuild Container Without Cache** — fresh image layers, same volumes.
|
||||
- **To force a re-login / clear an `EACCES`** — remove the per-container _config_ volumes and rebuild. As of the session-volume change this **no longer drops your `--resume` history** (sessions are on separate volumes — see [Session resume](#session-resume-across-container-recreation)):
|
||||
```bash
|
||||
docker volume ls | grep -- -config- # the credential / identity volumes
|
||||
docker volume rm claude-config-<id> codex-config-<id> cursor-config-<id> gh-config-<id>
|
||||
```
|
||||
⚠️ Since the shareable dirs are now seeded into the config volume (not bind-mounted), wiping `<cli>-config` **also discards any plugin/skill/agent/command you installed _inside_ the container** and re-seeds those dirs from the host on the next rebuild. That is the intended way to pull host-side config changes in, but if you have in-container-only plugins you want to keep, reinstall them after the rebuild (or install them on the host first so the re-seed brings them along).
|
||||
- **To also wipe session history** (a true clean slate) — remove the session volumes too (`<name>` is your workspace folder name):
|
||||
```bash
|
||||
docker volume ls | grep -E -- '-(sessions|cursor-projects)-' # the group-6 volumes
|
||||
docker volume rm <name>-claude-sessions-<id> <name>-codex-sessions-<id> \
|
||||
<name>-cursor-sessions-<id> <name>-cursor-projects-<id>
|
||||
```
|
||||
Then rebuild.
|
||||
- **To re-seed claude-mem from the host** (the container's memory has diverged and you want the host's current store back) — remove the claude-mem volume and rebuild; `post-create.sh` copies the host store in again on the next create:
|
||||
```bash
|
||||
docker volume rm claude-mem-<id>
|
||||
```
|
||||
|
||||
## Bumping CLI versions
|
||||
|
||||
Bump the version pins in `.devcontainer/devcontainer.json` `build.args` and rebuild — all three are real, fail-loud pins. Claude Code installs via `npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}` and Codex via `npm install -g @openai/codex@${CODEX_VERSION}`. **Cursor is pinned too:** bump `CURSOR_VERSION` **and** both `CURSOR_SHA256_X64` / `CURSOR_SHA256_ARM64` together — the Dockerfile downloads the pinned `downloads.cursor.com/lab/<version>/linux/<arch>/agent-cli-package.tar.gz` artifact directly (no remote install script) and fails the build on a sha256 mismatch. Re-hash each arch with `curl -fSL <url> | sha256sum`. To stop Cursor from auto-updating in the running container, don't call `cursor-agent update`.
|
||||
|
||||
## What's not included (yet)
|
||||
|
||||
- **Egress firewall — the most important hardening still outstanding.** The original plan included an opt-in iptables/ipset firewall adapted from Anthropic's reference devcontainer. It was deferred to a follow-up PR — `runArgs` is static in `devcontainer.json`, so toggling NET_ADMIN/NET_RAW capabilities cleanly requires either a separate `devcontainer-firewall.json` profile or an `initializeCommand`-generated overlay. Until it lands, the read surface in [§ Trust boundary](#trust-boundary-concretely) has no network containment — anything readable can be exfiltrated. Track at the project's issue tracker if you need this.
|
||||
- **Codespaces tuning.** The current config works in Codespaces incidentally (no privileged capabilities, no host-mount assumptions), but isn't actively tested there.
|
||||
- **Playwright e2e support.** `gitnexus-web`'s `npm run test:e2e` needs Chromium libs that the base image doesn't ship. Use the host for e2e until a Playwright layer is added.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GitNexus devcontainer one-time Windows setup` banner from `initializeCommand` | First-time Windows-native Reopen-in-Container; `HOME` env var was missing | The script just ran `setx HOME "%USERPROFILE%"` for you. Close ALL VS Code windows (File → Exit) and reopen — see [Windows 11 setup](#windows-11-setup) |
|
||||
| `bind source path does not exist: /.claude` (or similar) from Docker | Windows-native `HOME` env var is still missing even after one rebuild — `setx` may have failed or VS Code wasn't fully restarted | Run `setx HOME "%USERPROFILE%"` in a Windows shell manually, fully exit VS Code (check Task Manager that no `Code.exe` remains), reopen |
|
||||
| `EACCES` / `EPERM` writing into `~/.claude`, `~/.codex`, or `~/.cursor` inside the container | Stale state from a previous container with a different effective UID | Move the affected dir aside and let the CLI rebuild it (`mv ~/.claude ~/.claude.bak` and log in again). Long-term: WSL2 setup, which doesn't hit this class of issue |
|
||||
| `EPERM: operation not permitted, copyfile ... '.husky/_/h'` in `postCreateCommand` | Leftover `.husky/_/` from a previous container run on a Windows-side bind mount | `post-create.sh` already runs `rm -rf .husky/_` defensively. If you hit this on an older config, delete `.husky/_/` on the host and rebuild. Long-term: clone in WSL2 |
|
||||
| Vite never hot-reloads | Repo cloned on Windows side, not WSL2 | Re-clone inside WSL2 |
|
||||
| `gitnexus-web` can't reach the backend | `4747` was remapped or backend isn't running | Verify the Ports panel shows `4747` forwarded with no remap; start the backend with `cd gitnexus && npx gitnexus serve` |
|
||||
| `npm install` fails on tree-sitter-swift / proto / dart | Native build toolchain missing | This shouldn't happen in the devcontainer — verify the apt layer installed `python3 make g++`. If iterating, set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` to skip the vendored grammars |
|
||||
| Integration tests fail with `database busy` | LadybugDB single-writer constraint | Don't run host-side `gitnexus analyze` while the container is also analyzing the same repo; choose one writer |
|
||||
| API key env vars not visible inside the container | They are intentionally not auto-propagated from the host (so an empty/stale host var can't silently break `*-login` for everyone else) | `export ANTHROPIC_API_KEY=...` / `OPENAI_API_KEY=...` / `CURSOR_API_KEY=...` inside the container shell, or carry it via your VS Code [dotfiles repo](https://code.visualstudio.com/docs/devcontainers/containers#_personalizing-with-dotfile-repositories) for persistence |
|
||||
| `git commit` produces commits with empty author | `~/.gitconfig` is missing or empty on the host (VS Code's auto-copy had nothing to copy) | Set `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"` from the host shell, then rebuild the container |
|
||||
| `gh: not logged in` inside the container | Not logged in on the host (nothing to seed), or the `gh-config` volume is empty | Just run `gh auth login` **inside the container** — `gh` config lives in a writable per-container volume, so the login persists across rebuilds. (Logging in on the host instead also works: it seeds in on the next container create.) |
|
||||
9
.devcontainer/devcontainer-lock.json
Normal file
9
.devcontainer/devcontainer-lock.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671",
|
||||
"integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671"
|
||||
}
|
||||
}
|
||||
}
|
||||
396
.devcontainer/devcontainer.json
Normal file
396
.devcontainer/devcontainer.json
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
// Devcontainer for GitNexus. It pre-installs Claude Code, the OpenAI Codex
|
||||
// CLI, and the Cursor CLI, plus the Node.js native build chain. It works on
|
||||
// macOS, Linux, Windows via WSL2, and Windows native. Windows native needs a
|
||||
// one-time HOME setup. That setup runs automatically via initializeCommand.
|
||||
// See .devcontainer/README.md § Windows 11 setup. Open it with the VS Code
|
||||
// Dev Containers extension.
|
||||
//
|
||||
// For first-time setup, auth flows, and troubleshooting, see
|
||||
// .devcontainer/README.md.
|
||||
{
|
||||
"name": "GitNexus AI CLI Devcontainer",
|
||||
|
||||
"build": {
|
||||
"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
|
||||
// unverified remote script — chosen at request time for simplicity.
|
||||
// To bump: pick a tag from github.com/oven-sh/bun/releases and update
|
||||
// this value.
|
||||
"BUN_VERSION": "1.3.14",
|
||||
"TZ": "${localEnv:TZ:UTC}"
|
||||
}
|
||||
},
|
||||
|
||||
// Runs on the HOST, not the container, before the container is created. We
|
||||
// write it as a single string on purpose. The spec treats the single-string
|
||||
// form as one command that each OS runs its own way. The object form means
|
||||
// "named parallel tasks", not per-OS dispatch. We run it with Node so the
|
||||
// same command works in cmd.exe on Windows and in bash/zsh on Linux, macOS,
|
||||
// and WSL. The script reads `os.homedir()`, which respects $HOME on
|
||||
// Linux/macOS and %USERPROFILE% on Windows. It then creates the host-side
|
||||
// bind mount source folders, and it is safe to re-run. Host prerequisite:
|
||||
// Node on PATH. That is the only host-side tool needed beyond Docker Desktop
|
||||
// and the VS Code Dev Containers extension.
|
||||
"initializeCommand": "node .devcontainer/ensure-host-config-dirs.cjs",
|
||||
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {}
|
||||
},
|
||||
|
||||
"remoteUser": "node",
|
||||
"updateRemoteUserUID": true,
|
||||
|
||||
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
|
||||
"workspaceFolder": "/workspace",
|
||||
|
||||
// Mount topology, by group:
|
||||
//
|
||||
// 1. AI CLI host config — a READ-ONLY stage at /host/.<cli>. On container-
|
||||
// create, `post-create.sh` COPIES out of it: credentials, identity, and
|
||||
// single config files (always), plus the shareable subfolders (Claude
|
||||
// plugins/skills/agents/memory/commands; Codex plugins/prompts/memories/
|
||||
// skills; Cursor plugins/rules/commands/agents/skills) ONCE on first create.
|
||||
// Everything copied lands in the per-container named volume (role 2). It is
|
||||
// read-only so a container process can NEVER write back to the host — there
|
||||
// is no read-write bind into the host's CLI config at all. This protects the
|
||||
// host's on-disk setup: a compromised in-container dependency cannot drop a
|
||||
// skill, agent, command, or plugin onto the host for the next host session
|
||||
// to load. The cost is that host and container DIVERGE after the first
|
||||
// create — host edits don't reach the container until you wipe the config
|
||||
// volume and rebuild. See README § "Trust boundary, concretely".
|
||||
//
|
||||
// 2. AI CLI container config — one named volume per devcontainer. CODEX_HOME
|
||||
// points here. CLAUDE_CONFIG_DIR is left unset on purpose, so it resolves
|
||||
// to the default ~/.claude, which is this same path. Credentials,
|
||||
// identity, and single config files (.credentials.json,
|
||||
// ~/.claude/.claude.json, settings.json, config.toml, cli-config.json,
|
||||
// mcp.json) live here with correct Linux permissions. They are NOT
|
||||
// bind-mounted, because single-file binds break on Docker Desktop Windows
|
||||
// (the EXDEV error — see the SINGLE-FILE note below). Container-managed
|
||||
// state (sessions, history, caches, IDE locks) stays separate per
|
||||
// devcontainer. So two GitNexus checkouts on the same host can't corrupt
|
||||
// each other.
|
||||
//
|
||||
// 3. Other host config — read-only bind mounts for credential and identity
|
||||
// folders that lack the permission-flattening and onboarding-state
|
||||
// complications Claude Code has (ssh, aws, azure, git config, plus gh and
|
||||
// docker). gh and docker are read-only so a compromised dependency can't
|
||||
// rewrite the GitHub token or the Docker credHelper. See the inline note
|
||||
// at those mounts. `~/.gitconfig` is not mounted here. VS Code auto-copies
|
||||
// it separately.
|
||||
//
|
||||
// 4. Per-instance state — scoped by `${devcontainerId}`: shell history and
|
||||
// the npm cache. These survive rebuilds and stay separate between sibling
|
||||
// instances.
|
||||
//
|
||||
// 5. Per-workspace-name AND per-instance state — the workspace `node_modules`
|
||||
// volumes use both `${localWorkspaceFolderBasename}` (so you can spot them
|
||||
// in `docker volume ls`) and `${devcontainerId}` (so sibling instances of
|
||||
// the same repo never collide). This keeps tree-sitter native binaries and
|
||||
// onnxruntime off the workspace bind mount, which is faster on Windows and
|
||||
// macOS.
|
||||
//
|
||||
// 6. Per-workspace session state — dedicated named volumes for each CLI's
|
||||
// resume/transcript dirs (Claude projects/, Codex sessions/, Cursor chats/
|
||||
// + projects/). Same `${localWorkspaceFolderBasename}` + `${devcontainerId}`
|
||||
// keying as group 5, but SEPARATE volumes from the group-2 config volumes.
|
||||
// That separation is the point: the `docker volume rm <cli>-config-*`
|
||||
// re-login / EACCES fix (README § Rebuild/reset) no longer wipes sessions,
|
||||
// so `claude --resume`, `codex resume`, and `cursor-agent resume` survive a
|
||||
// rebuild, a full delete-and-recreate, AND that wipe. They overlay the
|
||||
// config volume at the session sub-paths (Docker precedence: more specific
|
||||
// path wins). Container-private by design — transcripts can hold pasted
|
||||
// secrets, and like the group-1 config (read-only stage, copy-once) these
|
||||
// add NO host write-through surface and leak no other projects' transcripts.
|
||||
// They do
|
||||
// NOT survive `docker volume prune`, a `${devcontainerId}` change (moving
|
||||
// the checkout, WSL vs native), or a new machine — same tier as group 5.
|
||||
// To make sessions host-visible/portable instead, see the commented
|
||||
// host-bind block below and README § "Session resume across recreation".
|
||||
"mounts": [
|
||||
// One named volume per container for credentials and identity state. Each
|
||||
// CLI's real `~/.<cli>` config folder lives in a volume. That keeps
|
||||
// credentials (with correct Linux 600 permissions) and per-container
|
||||
// session state separate from the host. Logging in inside the container
|
||||
// and logging in on the host are independent. The bind mounts BELOW these
|
||||
// volumes override the volume's contents at the paths they cover. Docker
|
||||
// mount precedence is: the more specific path wins.
|
||||
"source=claude-config-${devcontainerId},target=/home/node/.claude,type=volume",
|
||||
"source=codex-config-${devcontainerId},target=/home/node/.codex,type=volume",
|
||||
"source=cursor-config-${devcontainerId},target=/home/node/.cursor,type=volume",
|
||||
|
||||
// gh CLI config as a per-container named volume, same model as the AI CLI
|
||||
// configs above: post-create.sh COPIES hosts.yml/config.yml out of the
|
||||
// read-only /host/.config/gh stage into this volume on container-create.
|
||||
// The container then owns a WRITABLE copy, so `gh auth login` /
|
||||
// `gh auth refresh` run INSIDE the container persist across rebuilds — and
|
||||
// still never write back to the host (the stage is read-only). If the host
|
||||
// is logged in, that login seeds in; if not, an in-container login sticks.
|
||||
"source=gh-config-${devcontainerId},target=/home/node/.config/gh,type=volume",
|
||||
|
||||
// claude-mem store. UNLIKE the shareable dirs below (skills/agents/memory),
|
||||
// this is NOT a host bind. $HOME/.claude-mem is a large, multi-GB SQLite +
|
||||
// Chroma vector store (claude-mem.db + -wal/-shm, chroma/chroma.sqlite3, HNSW
|
||||
// index binaries). A read-write host bind would (a) push every byte over the
|
||||
// 9p/virtiofs share, and (b) expose those SQLite WAL files to unreliable
|
||||
// fcntl locking across that boundary — with a real corruption risk if
|
||||
// claude-mem ran on the host and in the container against the same DB at
|
||||
// once. So it gets its OWN per-container named volume here, same durability
|
||||
// tier as the config volumes (survives Rebuild Container and a
|
||||
// delete-and-recreate; keyed by ${devcontainerId}). post-create.sh SEEDS it
|
||||
// ONCE from the /host/.claude-mem read-only stage when the volume is empty,
|
||||
// then the container owns its copy — rebuilds never clobber it, and changes
|
||||
// do NOT flow back to the host. (Container and host memory diverge from the
|
||||
// seed point on; that is the price of safe SQLite.) Removed by the same
|
||||
// `docker volume rm` reset flow as the other volumes — see README.
|
||||
"source=claude-mem-${devcontainerId},target=/home/node/.claude-mem,type=volume",
|
||||
|
||||
// Per-workspace SESSION volumes (mount group 6). These OVERLAY the config
|
||||
// volumes above at the session sub-paths so "resume my last session"
|
||||
// survives container recreation the way the seeded config dirs do.
|
||||
// They are SEPARATE volumes from <cli>-config-${devcontainerId}, so the
|
||||
// README's `docker volume rm <cli>-config-${devcontainerId}` re-login fix
|
||||
// does not touch them. post-create.sh chowns each one explicitly (its
|
||||
// `find -xdev` stops at the config-volume filesystem boundary and won't
|
||||
// descend into these).
|
||||
//
|
||||
// KEEP IN SYNC: if you add/rename/remove a session sub-path, update all
|
||||
// three places that name it — (1) the mount line here, (2) the DIRS array
|
||||
// in post-create.sh (so its chown covers the volume), and (3) the mount
|
||||
// table + Session-resume section in README.md.
|
||||
//
|
||||
// Claude: projects/ holds <encoded-cwd>/<uuid>.jsonl transcripts plus the
|
||||
// sessions-index.json that the `/resume` picker reads. The container cwd is
|
||||
// always /workspace (encodes to the `-workspace` subdir), so this is the
|
||||
// container's own slice only. Pure JSONL/JSON — no SQLite/WAL, so a volume
|
||||
// here is clean. `claude --resume` / `--continue` read straight from it.
|
||||
"source=${localWorkspaceFolderBasename}-claude-sessions-${devcontainerId},target=/home/node/.claude/projects,type=volume",
|
||||
// Codex: sessions/ holds YYYY/MM/DD/rollout-*.jsonl transcripts. The thread
|
||||
// index (state_5.sqlite + -wal/-shm) stays at the ~/.codex root on the
|
||||
// config volume — it is a single WAL file we must NOT split onto a host
|
||||
// bind. On a recreation that drops the config volume, that index is cleanly
|
||||
// absent and Codex rebuilds it from these rollout files on the next start
|
||||
// (backfill). See README for the one-time-rebuild and corruption caveats.
|
||||
"source=${localWorkspaceFolderBasename}-codex-sessions-${devcontainerId},target=/home/node/.codex/sessions,type=volume",
|
||||
// Cursor: chats/{hash}/{uuid}/store.db is one SQLite db per session, each in
|
||||
// its own leaf dir — a DIRECTORY volume keeps each db beside its -wal/-shm
|
||||
// sidecar, so there is no cross-filesystem single-file hazard. projects/
|
||||
// (agent-transcripts) is added too. cursor-agent's on-disk layout is
|
||||
// community-reverse-engineered (LOW confidence), so this is best-effort;
|
||||
// keeping it container-private means a wrong guess can't corrupt host state.
|
||||
"source=${localWorkspaceFolderBasename}-cursor-sessions-${devcontainerId},target=/home/node/.cursor/chats,type=volume",
|
||||
"source=${localWorkspaceFolderBasename}-cursor-projects-${devcontainerId},target=/home/node/.cursor/projects,type=volume",
|
||||
//
|
||||
// OPT-IN: host-shared sessions (like the plugin/skill binds). Uncomment to
|
||||
// put transcripts on the host — fully visible and portable, but they then
|
||||
// land on host disk and become a write-through surface for a compromised
|
||||
// in-container dependency, and the whole-dir binds expose OTHER projects'
|
||||
// transcripts to the container. Claude is scoped to /workspace's encoded
|
||||
// subdir to limit that leak; Codex/Cursor stores are not project-scoped, so
|
||||
// they expose every project. If you enable these, also add the matching
|
||||
// source dirs to ensure-host-config-dirs.cjs — to its DIRS array (these are
|
||||
// directory binds), not FILES (which is only for single-file bind sources
|
||||
// like ~/.claude.json) — so Docker can resolve the binds. Read README
|
||||
// § "Session resume across recreation" first.
|
||||
// "source=${localEnv:HOME}/.claude/projects/-workspace,target=/home/node/.claude/projects/-workspace,type=bind",
|
||||
// "source=${localEnv:HOME}/.codex/sessions,target=/home/node/.codex/sessions,type=bind",
|
||||
// "source=${localEnv:HOME}/.cursor/chats,target=/home/node/.cursor/chats,type=bind",
|
||||
// "source=${localEnv:HOME}/.cursor/projects,target=/home/node/.cursor/projects,type=bind",
|
||||
|
||||
// Read-only host stage that post-create.sh copies FROM on container-create.
|
||||
// It is read-only so a container process can never write back to host CLI
|
||||
// state — that write-back is the attack vector we block. post-create.sh
|
||||
// reads two kinds of thing from here: (a) the credential + identity files
|
||||
// (copied into the volume always), and (b) the shareable dirs — skills,
|
||||
// agents, plugins, memory, commands, prompts, rules — which it copies into
|
||||
// the volume ONCE on first create (see step 3/4). Nothing here is bound
|
||||
// read-write into the container, so the host's on-disk setup is protected.
|
||||
"source=${localEnv:HOME}/.claude,target=/host/.claude,type=bind,readonly",
|
||||
"source=${localEnv:HOME}/.codex,target=/host/.codex,type=bind,readonly",
|
||||
"source=${localEnv:HOME}/.cursor,target=/host/.cursor,type=bind,readonly",
|
||||
// Read-only host stage for the claude-mem store. post-create.sh COPIES it
|
||||
// into the claude-mem named volume on first create (seed-once). Read-only so
|
||||
// the container can never write back to the host's live DB — the seed is a
|
||||
// one-way snapshot. ensure-host-config-dirs.cjs creates ~/.claude-mem on the
|
||||
// host so this bind resolves even when claude-mem was never installed there.
|
||||
"source=${localEnv:HOME}/.claude-mem,target=/host/.claude-mem,type=bind,readonly",
|
||||
|
||||
// NO read-write bind mounts for the shareable subfolders. They USED to be
|
||||
// bound here (Claude skills/agents/memory/commands/plugins; Codex plugins/
|
||||
// prompts/memories/skills; Cursor rules/commands/agents/skills/plugins) so
|
||||
// host and container shared one copy both ways. That bind was a write-through
|
||||
// hole: a compromised in-container dependency could drop a malicious skill,
|
||||
// agent, command, or plugin straight onto the host, which the next HOST
|
||||
// session would auto-load. To protect the host's on-disk setup, these are
|
||||
// now COPIED once from the read-only /host/.<cli> stage into the per-container
|
||||
// named volume by post-create.sh (step 3/4), exactly like claude-mem and the
|
||||
// session volumes. Trade-offs of the copy model:
|
||||
// - The container gets its OWN writable copy and can never write back to
|
||||
// the host. Host setup is protected.
|
||||
// - It is seed-ONCE: host edits made after first create don't reach the
|
||||
// container until you remove the config volume and rebuild. Container
|
||||
// edits persist across rebuilds. (See README § Rebuild/reset to re-seed.)
|
||||
// - The plugin REGISTRY JSONs (Claude known_marketplaces.json /
|
||||
// installed_plugins.json / plugin-catalog-cache.json; Cursor
|
||||
// installed_plugins.json) carry absolute OS-native paths, so they can't
|
||||
// be copied verbatim — post-create.sh translates their paths to the
|
||||
// container's Linux paths, also seed-once, alongside the cache/ copy so
|
||||
// the two stay consistent. Codex needs no translation (config.toml holds
|
||||
// git URLs, not paths), so its whole plugins/ dir is copied as-is.
|
||||
// - The old read-only-stage-plus-symlink design failed `/plugin marketplace
|
||||
// add` in the container with EROFS; copy-into-a-writable-volume avoids
|
||||
// that — the container writes to its own copy, not a read-only mount.
|
||||
//
|
||||
// SINGLE-FILE binds for settings.json, .claude.json, and config.toml are
|
||||
// deliberately ABSENT. On Docker Desktop Windows the named volume sits on
|
||||
// one filesystem (ext4, /dev/sdd) and a single-file bind from the host sits
|
||||
// on another (the 9p drvfs share). Apps save a config by writing `foo.tmp`
|
||||
// and renaming it over `foo`. That rename can't cross filesystems: it hits
|
||||
// the EXDEV error and fails with `Device or resource busy` or `inter-device
|
||||
// move failed`. Codex's TUI shows this as "config/batchWrite failed in
|
||||
// TUI"; Claude just silently loses the write the same way. Instead, we use
|
||||
// a read-only host stage at /host/.claude, and post-create.sh copies these
|
||||
// files into the named volume on every container-create. Host changes show
|
||||
// up on the next rebuild. Container changes stay inside the container until
|
||||
// a rebuild.
|
||||
"source=${localEnv:HOME}/.claude.json,target=/host/.claude.json,type=bind,readonly",
|
||||
"source=${localEnv:HOME}/.config/git,target=/home/node/.config/git,type=bind,readonly",
|
||||
"source=${localEnv:HOME}/.ssh,target=/home/node/.ssh,type=bind,readonly",
|
||||
// gh uses the COPY-INTO-VOLUME model (read-only host stage at
|
||||
// /host/.config/gh + the gh-config named volume above). post-create.sh seeds
|
||||
// hosts.yml/config.yml from this stage into the volume on create, so the
|
||||
// container has a writable copy: an in-container `gh auth login` persists
|
||||
// across rebuilds, and nothing is ever written back to the host because this
|
||||
// stage is read-only. docker stays a direct READ-ONLY bind: the container
|
||||
// reads your EXISTING host login (the common case), and a compromised
|
||||
// in-container dependency can't rewrite ~/.docker/config.json (the registry
|
||||
// credHelper, which points at a binary). A `docker login` run inside the
|
||||
// container won't persist back to the host — re-run it on the host, or give
|
||||
// docker the same copy-into-volume treatment as gh. See README § Trust boundary.
|
||||
"source=${localEnv:HOME}/.config/gh,target=/host/.config/gh,type=bind,readonly",
|
||||
"source=${localEnv:HOME}/.docker,target=/home/node/.docker,type=bind,readonly",
|
||||
"source=${localEnv:HOME}/.aws,target=/home/node/.aws,type=bind,readonly",
|
||||
"source=${localEnv:HOME}/.azure,target=/home/node/.azure,type=bind,readonly",
|
||||
"source=commandhistory-${devcontainerId},target=/commandhistory,type=volume",
|
||||
"source=npm-cache-${devcontainerId},target=/home/node/.npm,type=volume",
|
||||
"source=${localWorkspaceFolderBasename}-root-node-modules-${devcontainerId},target=/workspace/node_modules,type=volume",
|
||||
"source=${localWorkspaceFolderBasename}-gitnexus-node-modules-${devcontainerId},target=/workspace/gitnexus/node_modules,type=volume",
|
||||
"source=${localWorkspaceFolderBasename}-gitnexus-web-node-modules-${devcontainerId},target=/workspace/gitnexus-web/node_modules,type=volume",
|
||||
"source=${localWorkspaceFolderBasename}-gitnexus-shared-node-modules-${devcontainerId},target=/workspace/gitnexus-shared/node_modules,type=volume"
|
||||
],
|
||||
|
||||
// Interactive login is the default way to authenticate for all three CLIs.
|
||||
// Credentials live in the per-container named volumes (claude-config,
|
||||
// codex-config, cursor-config), NOT in the host bind mounts. They are copied
|
||||
// from the read-only /host/.<cli> stage into the volume on container-create.
|
||||
// Single-file binds would break on Docker Desktop Windows (the EXDEV error).
|
||||
// Shareable content (plugins, skills, agents, memory, commands) is NOT bound
|
||||
// read-write — it is copied once from the read-only /host/.<cli> stage into
|
||||
// the volume on first create, so the host's on-disk setup stays protected.
|
||||
// API keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, CURSOR_API_KEY) are NOT
|
||||
// injected via containerEnv. `${localEnv:VAR}` turns an unset host var into
|
||||
// an empty string. Cursor in particular treats `CURSOR_API_KEY=""` as "use
|
||||
// this empty key" instead of "fall back to the stored login", which would
|
||||
// silently break `cursor-agent login`. If you need API-key auth, `export`
|
||||
// the var in your container shell, or carry it in your VS Code dotfiles repo
|
||||
// (see .devcontainer/README.md).
|
||||
// CLAUDE_CONFIG_DIR is left unset on purpose. The Claude default is
|
||||
// `$HOME/.claude` (= `/home/node/.claude`), which is exactly where the
|
||||
// claude-config named volume mounts. Setting the env var would change which
|
||||
// file Claude reads `hasCompletedOnboarding` from. With the var set, Claude
|
||||
// reads `$CLAUDE_CONFIG_DIR/.claude.json`, the small identity file. Without
|
||||
// it, Claude reads `$HOME/.claude.json`, the big onboarding-state file that
|
||||
// actually holds `hasCompletedOnboarding`, the user-scope MCP config, and
|
||||
// per-project trust. Leaving the var unset matches host behavior. It also
|
||||
// lets post-create.sh's sync of `$HOME/.claude.json` skip the setup wizard
|
||||
// on every container-create.
|
||||
//
|
||||
// CODEX_HOME is kept even though it matches the Codex default, as a canary.
|
||||
// If we ever move the Codex named volume target, this env var makes the
|
||||
// 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"
|
||||
},
|
||||
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"anthropic.claude-code",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"eamodio.gitlens"
|
||||
],
|
||||
"settings": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"files.eol": "\n",
|
||||
"terminal.integrated.defaultProfile.linux": "zsh",
|
||||
"terminal.integrated.profiles.linux": {
|
||||
"bash": { "path": "bash", "icon": "terminal-bash" },
|
||||
"zsh": { "path": "zsh" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Do not remap port 4747 (gitnexus serve). gitnexus-web hardcodes
|
||||
// http://localhost:4747 as its default backend URL.
|
||||
"forwardPorts": [5173, 4747, 4173],
|
||||
"portsAttributes": {
|
||||
"5173": {
|
||||
"label": "Vite dev (gitnexus-web)",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"4747": {
|
||||
"label": "gitnexus serve HTTP API",
|
||||
"onAutoForward": "notify",
|
||||
"requireLocalPort": true
|
||||
},
|
||||
"4173": {
|
||||
"label": "Static web (Vite preview)",
|
||||
"onAutoForward": "silent"
|
||||
}
|
||||
},
|
||||
|
||||
// Lifecycle split (from the Dev Container spec):
|
||||
// - `updateContentCommand` runs on container-create AND whenever the
|
||||
// workspace content changes, such as a lockfile update. It owns installing
|
||||
// the workspace dependencies. Re-installing on every container-create
|
||||
// wastes time when nothing changed, but it must re-run when deps change.
|
||||
// - `postCreateCommand` runs once on container-create. It owns syncing the
|
||||
// AI CLI credentials and identity from the host. That work should happen
|
||||
// exactly once per container instance, not on every content update.
|
||||
// Run both with an explicit `bash` so they don't depend on the script's
|
||||
// executable bit surviving the workspace bind mount.
|
||||
"updateContentCommand": "bash .devcontainer/install-deps.sh",
|
||||
"postCreateCommand": "bash .devcontainer/post-create.sh"
|
||||
}
|
||||
149
.devcontainer/ensure-host-config-dirs.cjs
Normal file
149
.devcontainer/ensure-host-config-dirs.cjs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// This runs on the HOST, not inside the container, before the dev container is
|
||||
// created. devcontainer.json calls it via `initializeCommand`. Its job is to
|
||||
// make sure the bind-mount source folders listed in devcontainer.json already
|
||||
// exist on the host. Docker rejects a bind mount when its source is missing,
|
||||
// which happens if a CLI has never been used.
|
||||
//
|
||||
// It works on every platform. `os.homedir()` returns the home folder ($HOME on
|
||||
// Mac/Linux, %USERPROFILE% on Windows). `fs.mkdirSync({recursive: true})`
|
||||
// creates folders. It is safe to run repeatedly: a path that already exists is
|
||||
// left alone. We deliberately do NOT handle `~/.gitconfig` here. VS Code's Dev
|
||||
// Containers extension copies the host gitconfig into the container when you
|
||||
// attach, and a bind mount fights with that, so it was removed.
|
||||
//
|
||||
// The path-creating logic is exported (ensurePaths/DIRS/FILES) so tests can use
|
||||
// it. The Windows HOME side effect only runs when this file is run directly as
|
||||
// the initializeCommand. That keeps tests able to drive it against a temp dir
|
||||
// without touching the real home or calling `setx`.
|
||||
//
|
||||
// Host prerequisite: Node.js must be on PATH. That is the only host requirement
|
||||
// beyond Docker Desktop and the VS Code Dev Containers extension. Everything
|
||||
// else runs inside the container.
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
// Folders that are bind-mount sources in devcontainer.json. Docker rejects a
|
||||
// bind mount whose source is missing, so we create each one.
|
||||
//
|
||||
// We create the TOP per-CLI folders (~/.claude, ~/.codex, ~/.cursor) and
|
||||
// ~/.claude-mem. These back the /host/.<cli> and /host/.claude-mem read-only
|
||||
// STAGE mounts that post-create.sh copies from on container-create. We do NOT
|
||||
// create the shareable subfolders (skills/agents/plugins/memory/commands/...)
|
||||
// here anymore: they used to be read-write bind sources, but they are now
|
||||
// copied once out of the read-only stage into the per-container volume, so they
|
||||
// are no longer bind sources and pre-creating empty ones would needlessly write
|
||||
// into the host of someone who never used that CLI. post-create.sh's seed step
|
||||
// simply skips any subfolder the host doesn't have. The read-only stage bind is
|
||||
// the whole ~/.<cli> dir, so whatever shareable subfolders DO exist are visible
|
||||
// to the seed without being listed here.
|
||||
const DIRS = [
|
||||
'.claude',
|
||||
// claude-mem store ($HOME/.claude-mem). A SEPARATE top-level folder from
|
||||
// ~/.claude, holding claude-mem's SQLite DB + Chroma vector store. It is NOT
|
||||
// bind-mounted (a multi-GB SQLite/WAL store is unsafe over a 9p bind on Docker
|
||||
// Desktop Windows). post-create.sh SEEDS it once into a per-container named
|
||||
// volume from the /host/.claude-mem read-only stage. We create the source here
|
||||
// so that stage bind resolves even for a host that never ran claude-mem
|
||||
// (Docker rejects a missing bind source); the seed then finds no DB to copy
|
||||
// and the container starts with empty memory.
|
||||
'.claude-mem',
|
||||
'.codex',
|
||||
'.cursor',
|
||||
'.ssh',
|
||||
'.docker',
|
||||
'.aws',
|
||||
'.azure',
|
||||
path.join('.config', 'gh'),
|
||||
path.join('.config', 'git'),
|
||||
];
|
||||
|
||||
// Files to pre-create. Only `~/.claude.json` is created here. It is the one
|
||||
// source that is bound as a single file (read-only at /host/.claude.json). If
|
||||
// that source is missing, Docker would create a FOLDER in its place, so it has
|
||||
// to exist as a file first. `~/.claude/settings.json` and
|
||||
// `~/.codex/config.toml` are NOT single-file binds. post-create.sh copies them
|
||||
// out of the /host/.<cli> read-only folder stage, and `sync_from_host` simply
|
||||
// does nothing when they are absent (the `[ -f ]` guard). Creating them here
|
||||
// would needlessly write to the host of someone who never ran that CLI, so we
|
||||
// don't.
|
||||
const FILES = ['.claude.json'];
|
||||
|
||||
// Create every folder and touch every file under `home`. Safe to run again:
|
||||
// an existing path is left untouched. The root is a parameter so tests can run
|
||||
// it against a temp dir.
|
||||
function ensurePaths(home, dirs = DIRS, files = FILES) {
|
||||
for (const dir of dirs) {
|
||||
const full = path.join(home, dir);
|
||||
if (!fs.existsSync(full)) {
|
||||
fs.mkdirSync(full, { recursive: true });
|
||||
}
|
||||
}
|
||||
for (const file of files) {
|
||||
const full = path.join(home, file);
|
||||
if (!fs.existsSync(full)) {
|
||||
fs.closeSync(fs.openSync(full, 'a'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ensurePaths, DIRS, FILES };
|
||||
|
||||
if (require.main === module) {
|
||||
// One-time setup for native Windows. VS Code fills in the bind-mount sources
|
||||
// using `${localEnv:HOME}`, which reads its own process environment. Windows
|
||||
// does not set `HOME` by default; it uses `USERPROFILE`. With no `HOME`, the
|
||||
// bind sources shrink to filesystem-root paths (`/.claude`, `/.codex`, ...)
|
||||
// and Docker rejects them with `bind source path does not exist`.
|
||||
//
|
||||
// The fix is to save `HOME=%USERPROFILE%` into the user's environment with
|
||||
// `setx`. `setx` writes to `HKCU\Environment`. Every process the user starts
|
||||
// after that inherits the new value, including VS Code once it restarts. The
|
||||
// current VS Code process can't see the change, because its environment was
|
||||
// set when it launched. So we tell the user to restart VS Code once.
|
||||
//
|
||||
// Later runs see that `HOME` is set, skip this block, and continue normally.
|
||||
// Mac, Linux, and WSL hosts already have `HOME` set by the shell, so this
|
||||
// block does nothing on those platforms.
|
||||
if (process.platform === 'win32' && !process.env.HOME) {
|
||||
const userprofile = process.env.USERPROFILE;
|
||||
if (userprofile) {
|
||||
try {
|
||||
require('child_process').execFileSync('setx', ['HOME', userprofile], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
console.error('');
|
||||
console.error('='.repeat(70));
|
||||
console.error(' GitNexus devcontainer one-time Windows setup');
|
||||
console.error('='.repeat(70));
|
||||
console.error('');
|
||||
console.error(`HOME has been set to %USERPROFILE% (${userprofile}).`);
|
||||
console.error("VS Code reads this at startup, so the current session can't pick it up.");
|
||||
console.error('');
|
||||
console.error(' 1. Close ALL VS Code windows (File > Exit, not just the window).');
|
||||
console.error(' 2. Reopen VS Code, open this folder, and re-run Reopen in Container.');
|
||||
console.error('');
|
||||
console.error('This is a one-time setup. Subsequent rebuilds work normally.');
|
||||
console.error('='.repeat(70));
|
||||
process.exit(1);
|
||||
} catch (err) {
|
||||
console.error('ERROR: failed to set HOME automatically: ' + err.message);
|
||||
console.error('');
|
||||
console.error('Run this in a Windows shell, then restart VS Code:');
|
||||
console.error(' setx HOME "%USERPROFILE%"');
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.error('ERROR: neither HOME nor USERPROFILE is set on this host.');
|
||||
console.error('');
|
||||
console.error('Set HOME to your user profile directory and restart VS Code:');
|
||||
console.error(' setx HOME "%USERPROFILE%"');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
ensurePaths(os.homedir());
|
||||
}
|
||||
68
.devcontainer/install-deps.sh
Normal file
68
.devcontainer/install-deps.sh
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
#!/usr/bin/env bash
|
||||
# Devcontainer updateContentCommand. The Dev Container spec runs this when the
|
||||
# container is created AND whenever workspace content changes (for example a
|
||||
# lockfile update). This script installs workspace dependencies only. Syncing AI
|
||||
# CLI state lives in post-create.sh, which runs once right after this.
|
||||
#
|
||||
# Why the split: updateContentCommand re-runs on content changes, but
|
||||
# postCreateCommand runs only at container-create. Keeping `npm install` here
|
||||
# means a rebuild after pulling new dependencies refreshes them. The AI CLI
|
||||
# credential and path-translation work does not re-run each time.
|
||||
|
||||
set -euo pipefail
|
||||
cd /workspace
|
||||
|
||||
echo "[install-deps] 1/4: chown workspace node_modules + npm cache mount points"
|
||||
# The named volumes (workspace/*/node_modules and ~/.npm) are created at first
|
||||
# mount. They inherit ownership from the image's UID before realignment. Then
|
||||
# `updateRemoteUserUID: true` shifts the `node` user's UID. Now the volumes are
|
||||
# owned by the old, stale UID and npm install cannot write to them. So we chown
|
||||
# again here, after realignment. Running it again later changes nothing.
|
||||
#
|
||||
# We use `find -xdev -exec chown -h` (the same idiom as post-create.sh) instead
|
||||
# of a plain `chown -R`. There are two separate guards. First, `-xdev` stops
|
||||
# find from descending past each volume's own filesystem, so it won't recurse
|
||||
# into a host folder mounted underneath. Second, `-h` makes chown change the
|
||||
# symlink itself instead of following it to its target. Without `-h`, a symlink
|
||||
# in the tree (one a dependency's postinstall drops, or a dangling
|
||||
# node_modules/.bin link) would either send the chown onto a target on another
|
||||
# filesystem, or fail to follow and abort the whole script under `set -e`. For
|
||||
# regular files and directories `-h` does nothing, so the ownership fix is the
|
||||
# same.
|
||||
for d in /workspace/node_modules \
|
||||
/workspace/gitnexus/node_modules \
|
||||
/workspace/gitnexus-web/node_modules \
|
||||
/workspace/gitnexus-shared/node_modules \
|
||||
/home/node/.npm; do
|
||||
sudo find "$d" -xdev -exec chown -h node:node {} +
|
||||
done
|
||||
|
||||
echo "[install-deps] 2/4: clear stale .husky/_ runtime cache"
|
||||
# On Docker Desktop for Windows, the bind-mount permission translation won't let
|
||||
# the new container's `node` user overwrite a `.husky/_/h` file that an earlier
|
||||
# container wrote under a different UID. So we delete it. `.husky/_` is a
|
||||
# gitignored runtime cache, and husky rebuilds it during the root `npm install`.
|
||||
# Husky upstream has no fix for this UID clash.
|
||||
rm -rf .husky/_
|
||||
|
||||
echo "[install-deps] 3/4: npm install at root, then gitnexus-shared (build required)"
|
||||
# Install order matters. Root goes first, for lint-staged, husky, and prettier.
|
||||
# Then gitnexus-shared, which must be built before installing gitnexus-web or
|
||||
# gitnexus. Both of those depend on it via `file:../gitnexus-shared`.
|
||||
npm install
|
||||
cd /workspace/gitnexus-shared
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
echo "[install-deps] 4/4: npm install gitnexus-web, then gitnexus"
|
||||
# gitnexus-web goes before gitnexus. The gitnexus `prepare` script runs
|
||||
# scripts/build.js, which compiles gitnexus-web when that directory is present.
|
||||
# In the devcontainer the whole workspace is bind-mounted, so gitnexus-web/ is
|
||||
# present when gitnexus installs. The production Dockerfiles COPY only selected
|
||||
# files, so the directory is not present there.
|
||||
cd /workspace/gitnexus-web
|
||||
npm install
|
||||
cd /workspace/gitnexus
|
||||
npm install
|
||||
|
||||
echo "[install-deps] done"
|
||||
310
.devcontainer/post-create.sh
Normal file
310
.devcontainer/post-create.sh
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
#!/usr/bin/env bash
|
||||
# Devcontainer postCreate script. It runs once, right after the container is
|
||||
# created. devcontainer.json wires it up via `postCreateCommand`. Workspace
|
||||
# dependencies are installed elsewhere, in install-deps.sh (`updateContentCommand`).
|
||||
# That script runs BEFORE this one — that is the order the devcontainer spec
|
||||
# defines. This script does one job: sync the AI CLI credentials and identity
|
||||
# from the host.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
echo "[post-create] 1/4: chown AI CLI named-volume mount points"
|
||||
# Fix ownership on the named volumes (~/.claude, ~/.codex, ~/.cursor,
|
||||
# /commandhistory). When they first mount, they take the user ID baked into the
|
||||
# image, before any realignment. Then `updateRemoteUserUID: true` shifts the
|
||||
# `node` user to a new ID. Now the volumes are owned by the old, stale ID, and
|
||||
# writes into them fail. (~/.local is a directory in the image, not a volume.
|
||||
# We chown it too, just to be safe.) install-deps.sh fixes the workspace side.
|
||||
# This script fixes the AI CLI side, so each lifecycle hook handles its own part.
|
||||
#
|
||||
# There are two separate guards here, and they do different things. `-xdev`
|
||||
# keeps find from descending into other filesystems. The shareable dirs (skills,
|
||||
# agents, plugins, memory, commands, prompts, rules) are no longer host bind
|
||||
# mounts — they now live INSIDE the config volume (seeded in step 3/4), so
|
||||
# `-xdev` correctly walks and chowns them as the container-private volume files
|
||||
# they are. What `-xdev` still stops at are the SESSION volumes (mount group 6),
|
||||
# which remain separate filesystems mounted at sub-paths (see below). `-h` tells
|
||||
# chown to act on a symlink ITSELF instead of following it, so it never lands on
|
||||
# a target across a filesystem boundary and never aborts on a broken symlink
|
||||
# under `set -e` (a legacy Option-B symlink could still exist on a carried-over
|
||||
# volume). For regular files and directories `-h` does nothing extra.
|
||||
#
|
||||
# The session volumes (mount group 6: .claude/projects, .codex/sessions,
|
||||
# .cursor/chats, .cursor/projects) are their OWN filesystems mounted at
|
||||
# sub-paths, so `-xdev` rooted at the config-volume parent deliberately skips
|
||||
# them. That is why each one is listed as its own root below: rooted there,
|
||||
# `-xdev` walks just that volume and chowns its top level, so the CLI's first
|
||||
# write doesn't hit EACCES on a stale image UID. These are container-private
|
||||
# volumes, not the host's own files — the read-only /host/.<cli> stages we copy
|
||||
# from are mounted elsewhere and are never chowned.
|
||||
DIRS=(
|
||||
/home/node/.claude
|
||||
/home/node/.claude/projects
|
||||
/home/node/.codex
|
||||
/home/node/.codex/sessions
|
||||
/home/node/.cursor
|
||||
/home/node/.cursor/chats
|
||||
/home/node/.cursor/projects
|
||||
/home/node/.config/gh
|
||||
/home/node/.local
|
||||
/commandhistory
|
||||
)
|
||||
# claude-mem volume: chown it ONLY on first create (its completion sentinel is
|
||||
# absent). The step-4/4 seed copies the store as the node user, so a populated
|
||||
# claude-mem volume is already node-owned on every later rebuild — a recursive
|
||||
# `find` over a multi-GB store (the 7GB+ DB plus the Chroma index) just to
|
||||
# re-stamp ownership that is already correct would add real latency to every
|
||||
# rebuild for nothing. On first create the volume is empty, so this chown of the
|
||||
# bare mount point is trivial and lets the seed write into it.
|
||||
[ -f /home/node/.claude-mem/.claude-mem-seeded ] || DIRS+=(/home/node/.claude-mem)
|
||||
for d in "${DIRS[@]}"; do
|
||||
# Skip a root that isn't present rather than aborting the whole run under
|
||||
# `set -e`. Docker creates every declared volume's mount point before this
|
||||
# script runs, so in the normal case all roots exist and this is a no-op.
|
||||
# The guard matters only if a session volume is later removed from
|
||||
# devcontainer.json without its matching DIRS entry being removed too — then
|
||||
# provisioning skips it instead of failing before credentials ever sync.
|
||||
[ -d "$d" ] || continue
|
||||
sudo find "$d" -xdev -exec chown -h node:node {} +
|
||||
done
|
||||
|
||||
echo "[post-create] 2/4: sync AI CLI credentials + identity from host"
|
||||
# Clean up after an older devcontainer design (Option B). Back then these paths
|
||||
# were symlinks pointing into the read-only host stage
|
||||
# (e.g. /home/node/.claude/plugins -> /host/.claude/plugins). A write through
|
||||
# such a symlink would land on a read-only host file and fail. Delete any that
|
||||
# survive on a carried-over volume. The shareable dirs are now real directories
|
||||
# in the named volume, seeded from the host in step 3/4 below.
|
||||
for p in plugins skills agents memory commands; do
|
||||
[ -L "/home/node/.claude/$p" ] && rm "/home/node/.claude/$p"
|
||||
done
|
||||
for p in plugins prompts memories skills config.toml; do
|
||||
[ -L "/home/node/.codex/$p" ] && rm "/home/node/.codex/$p"
|
||||
done
|
||||
for p in plugins rules commands agents skills; do
|
||||
[ -L "/home/node/.cursor/$p" ] && rm "/home/node/.cursor/$p"
|
||||
done
|
||||
mkdir -p /home/node/.claude/plugins /home/node/.cursor/plugins
|
||||
|
||||
# Shareable content (skills, agents, plugins, memory, commands, prompts, rules)
|
||||
# is NO LONGER bind-mounted. It is COPIED once from the read-only host stage into
|
||||
# the named volume in step 3/4 below, so a compromised in-container dependency
|
||||
# can't write through to the host's on-disk CLI setup. This step handles only the
|
||||
# credentials, identity, and single config files. Those stay per-container in the
|
||||
# named volume and are COPIED from the host once when the container is created:
|
||||
# - .credentials.json (Claude OAuth tokens)
|
||||
# - .claude/.claude.json (Claude identity: userID, oauthAccount, and
|
||||
# migration tracking — a different file from $HOME/.claude.json)
|
||||
# - settings.json (Claude), config.toml (Codex), mcp.json (Cursor). These are
|
||||
# single config files, and single files can't be bind-mounted on Windows
|
||||
# (the EXDEV error explained below).
|
||||
# - auth.json (Codex), cli-config.json (Cursor — which mixes auth and settings)
|
||||
# - the plugin registry JSONs that contain absolute paths (Claude + Cursor).
|
||||
# Those are translated below.
|
||||
#
|
||||
# How the sync behaves: it ALWAYS overwrites from the host when the container is
|
||||
# created. A fresh container then starts logged in as the host's user, if the
|
||||
# host had credentials. From that point the container manages its own login,
|
||||
# until the next rebuild copies the host files again. Logging out inside the
|
||||
# container does NOT log out the host. Per-container login is the goal, and
|
||||
# bind-mounting these files would instead make a logout shared between both.
|
||||
|
||||
sync_from_host() {
|
||||
local src=$1
|
||||
local dst=$2
|
||||
local mode=${3:-600}
|
||||
if [ -f "$src" ]; then
|
||||
rm -f "$dst"
|
||||
cp "$src" "$dst"
|
||||
chmod "$mode" "$dst"
|
||||
fi
|
||||
}
|
||||
|
||||
sync_from_host \
|
||||
/host/.claude/.credentials.json /home/node/.claude/.credentials.json
|
||||
sync_from_host \
|
||||
/host/.claude/.claude.json /home/node/.claude/.claude.json 644
|
||||
|
||||
# These config files are COPIED from the host, not bind-mounted. We tried
|
||||
# bind-mounting them as single files and it didn't work. On Docker Desktop for
|
||||
# Windows the named volume (ext4) and the host bind mount (9p drvfs) are
|
||||
# different filesystems. Apps save a config by writing a temp file and renaming
|
||||
# it over the real one, and that rename fails across filesystems (the "EXDEV" or
|
||||
# "Device or resource busy" error). So copy the host's version into the named
|
||||
# volume when the container is created. The container can then rewrite it freely
|
||||
# until the next rebuild copies the host version again.
|
||||
sync_from_host /host/.claude/settings.json /home/node/.claude/settings.json 644
|
||||
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).
|
||||
node "$SCRIPT_DIR/seed-claude-config.cjs"
|
||||
|
||||
# Codex auth. Some hosts store credentials in the OS keyring instead of on disk
|
||||
# (`cli_auth_credentials_store = "keyring"`, the default on macOS). Those hosts
|
||||
# have no auth.json file, so the copy below quietly does nothing. In that case,
|
||||
# log in inside the container with `codex login --device-auth`.
|
||||
sync_from_host \
|
||||
/host/.codex/auth.json /home/node/.codex/auth.json
|
||||
|
||||
# Cursor CLI. Its cli-config.json holds both auth and settings in one file.
|
||||
# Cursor has known upstream problems authenticating inside Docker, even when the
|
||||
# config is copied correctly. If `cursor-agent` reports auth errors after the
|
||||
# copy, run `cursor-agent login` again inside the container. mcp.json (Cursor's
|
||||
# MCP server config) is also a single file, so it is copied on create rather
|
||||
# than bind-mounted, for the same EXDEV reason as above. hooks.json is left out
|
||||
# on purpose. Cursor hooks run shell commands, and sharing the host's hooks
|
||||
# would widen the supply-chain attack surface inside the container. Copy it in
|
||||
# yourself if you want the host's hooks in the container.
|
||||
sync_from_host \
|
||||
/host/.cursor/cli-config.json /home/node/.cursor/cli-config.json
|
||||
sync_from_host \
|
||||
/host/.cursor/mcp.json /home/node/.cursor/mcp.json 644
|
||||
|
||||
# gh CLI auth + settings. Same copy-into-volume model as the credentials above:
|
||||
# hosts.yml holds the GitHub token (mode 600), config.yml holds settings (644).
|
||||
# Copied from the read-only /host/.config/gh stage into the gh-config named
|
||||
# volume on create. Because the volume is writable, an in-container
|
||||
# `gh auth login` / `gh auth refresh` persists across rebuilds; because the
|
||||
# stage is read-only, nothing flows back to the host. If the host had no login,
|
||||
# both copies quietly no-op and whatever the container wrote is kept.
|
||||
sync_from_host /host/.config/gh/hosts.yml /home/node/.config/gh/hosts.yml
|
||||
sync_from_host /host/.config/gh/config.yml /home/node/.config/gh/config.yml 644
|
||||
|
||||
echo "[post-create] 3/4: seed shareable config dirs from host (first create only)"
|
||||
# The shareable dirs (Claude skills/agents/memory/commands/plugins; Codex
|
||||
# plugins/prompts/memories/skills; Cursor rules/commands/agents/skills/plugins)
|
||||
# used to be read-write host bind mounts, so a write inside the container landed
|
||||
# directly on the host's files. That exposed the host's on-disk CLI setup: a
|
||||
# compromised workspace dependency running in the container could drop a malicious
|
||||
# skill, agent, command, or plugin into the host's folders, which the next HOST
|
||||
# session would then auto-load. To protect the host, these are no longer bound.
|
||||
# Instead we COPY them once from the read-only /host/.<cli> stage into the
|
||||
# per-container named volume, exactly like claude-mem (step 4/4) and the session
|
||||
# volumes. The container gets its own writable copy and can NEVER write back to
|
||||
# the host. The container also avoids the old read-only-stage EROFS failure,
|
||||
# because it writes to its own volume copy, not a read-only mount.
|
||||
#
|
||||
# Seed-once, persist: a per-CLI marker file records that the copy has happened.
|
||||
# On the first container-create the marker is absent, so we copy; on every later
|
||||
# rebuild the marker is present, so we skip and keep whatever the container has
|
||||
# accumulated. Host edits made AFTER the first create do NOT reach the container
|
||||
# until you remove the config volume and rebuild (see README § Rebuild/reset).
|
||||
seed_shareable() {
|
||||
# seed_shareable <cli> <subdir>...: copy each /host/.<cli>/<subdir> into the
|
||||
# named volume, once. Skips a subdir the host doesn't have. We use `cp -r`,
|
||||
# NOT `cp -a`/`cp -p`: this script runs as the non-root node user, and the
|
||||
# host-stage files are owned by a different UID, so trying to preserve
|
||||
# ownership would fail with EPERM and abort the run under `set -e` (the same
|
||||
# reason sync_from_host uses plain cp). `cp -r` copies contents owned by node
|
||||
# — exactly what we want — and preserves symlinks as symlinks (GNU default).
|
||||
local cli=$1
|
||||
shift
|
||||
local marker="/home/node/.$cli/.devcontainer-shareable-seeded"
|
||||
[ -f "$marker" ] && return 0
|
||||
for sub in "$@"; do
|
||||
local src="/host/.$cli/$sub"
|
||||
local dst="/home/node/.$cli/$sub"
|
||||
[ -d "$src" ] || continue
|
||||
mkdir -p "$dst"
|
||||
cp -r "$src/." "$dst/"
|
||||
done
|
||||
}
|
||||
|
||||
# Decide which plugin registries to translate BEFORE seeding sets the markers.
|
||||
# We translate only a CLI being seeded this run, so a plugin installed inside the
|
||||
# container isn't overwritten by the host's registry on a later rebuild. Codex
|
||||
# has no path-bearing registry (config.toml holds git URLs), so it's never here.
|
||||
TRANSLATE_CLIS=()
|
||||
[ -f /home/node/.claude/.devcontainer-shareable-seeded ] || TRANSLATE_CLIS+=(claude)
|
||||
[ -f /home/node/.cursor/.devcontainer-shareable-seeded ] || TRANSLATE_CLIS+=(cursor)
|
||||
|
||||
seed_shareable claude skills agents memory commands plugins/marketplaces plugins/cache
|
||||
seed_shareable codex plugins prompts memories skills
|
||||
seed_shareable cursor rules commands agents skills plugins/marketplaces plugins/local
|
||||
|
||||
# Translate the path-bearing plugin registries (Claude + Cursor) for the CLIs we
|
||||
# just seeded. They store absolute, OS-native install paths
|
||||
# (`C:\Users\X\.claude\plugins\...` on Windows), which the Linux container can't
|
||||
# resolve — it would fail with `cache-miss`. translate-plugin-registries.cjs
|
||||
# rewrites those to the container's paths and writes the result into the volume.
|
||||
if [ "${#TRANSLATE_CLIS[@]}" -gt 0 ]; then
|
||||
node "$SCRIPT_DIR/translate-plugin-registries.cjs" "${TRANSLATE_CLIS[@]}"
|
||||
fi
|
||||
|
||||
# Record that each CLI's shareable surface is seeded, so later rebuilds keep the
|
||||
# container's copy. Touch even when the host had nothing to copy — an empty CLI
|
||||
# is still "seeded", and we don't want to re-scan the host on every rebuild.
|
||||
#
|
||||
# ORDERING INVARIANT — do NOT move these touches earlier (e.g. into
|
||||
# seed_shareable per-CLI). The markers must be written only AFTER the registry
|
||||
# translation above, because seed (cache copy) and translate (registry rewrite)
|
||||
# are logically atomic: a marker set between them would let a later rebuild skip
|
||||
# translation for an already-seeded CLI, leaving its cache/ in place but its
|
||||
# registry still pointing at host paths (`cache-miss`). Writing all markers here,
|
||||
# after translate, means any abort mid-seed leaves NO markers, so the next create
|
||||
# re-runs the whole seed+translate. The cost is re-copying an already-copied CLI
|
||||
# on retry; `cp -r` overwrites in place, so that is idempotent and cheap relative
|
||||
# to a broken plugin registry.
|
||||
for cli in claude codex cursor; do
|
||||
touch "/home/node/.$cli/.devcontainer-shareable-seeded"
|
||||
done
|
||||
|
||||
echo "[post-create] 4/4: seed claude-mem store from host (first create only)"
|
||||
# claude-mem keeps its memory in $HOME/.claude-mem — a SQLite DB (claude-mem.db
|
||||
# plus -wal/-shm) and a Chroma vector store (chroma/chroma.sqlite3 + HNSW index
|
||||
# binaries). It is mounted as a per-container named volume, NOT a host bind:
|
||||
# pushing a multi-GB SQLite/WAL store over the 9p/virtiofs bind risks unreliable
|
||||
# fcntl locking and corruption, especially if claude-mem ran on the host and in
|
||||
# the container against the same files at once (see devcontainer.json).
|
||||
#
|
||||
# So seed it ONCE, then let the container own its copy. On every later rebuild
|
||||
# we skip the copy and keep whatever the container has accumulated since —
|
||||
# rebuilds never clobber it. The container's memory and the host's diverge from
|
||||
# this seed point on; that is the deliberate cost of keeping SQLite off a shared
|
||||
# bind. To re-seed from the host, remove the volume (`docker volume rm
|
||||
# claude-mem-<id>`) and rebuild.
|
||||
#
|
||||
# The skip guard is a COMPLETION SENTINEL (.claude-mem-seeded), NOT the presence
|
||||
# of claude-mem.db. Keying on the DB file would be a trap: a multi-GB `cp -r` can
|
||||
# be interrupted (disk full, I/O error) and abort the script under `set -e`,
|
||||
# leaving a PARTIAL claude-mem.db behind. The next create would then see that
|
||||
# truncated file and treat the store as "already seeded", sticking the container
|
||||
# with a corrupt DB forever. With a sentinel touched only AFTER `cp` returns 0,
|
||||
# an interrupted seed leaves no sentinel; the next create clears the half-copied
|
||||
# store and retries cleanly. CONSISTENCY: copying a live WAL database is only
|
||||
# crash-consistent if claude-mem is NOT writing on the host during the copy — do
|
||||
# not run claude-mem on the host during a first-create or a re-seed rebuild.
|
||||
#
|
||||
# `cp -r` (not `cp -a`/`cp -p`) copies the DB together with its -wal/-shm
|
||||
# sidecars in one pass. We avoid preserving ownership for the same reason as the
|
||||
# shareable seed above: this runs as the non-root node user against host-owned
|
||||
# files, so `cp -a` would fail with EPERM and abort under `set -e`. `cp -r`
|
||||
# leaves the copies owned by node. The host stage is read-only, so this can
|
||||
# never write back to the host's live DB.
|
||||
if [ -f /host/.claude-mem/claude-mem.db ] && [ ! -f /home/node/.claude-mem/.claude-mem-seeded ]; then
|
||||
echo "[post-create] seeding ~/.claude-mem from host (one-time copy, may be several GB)"
|
||||
# Clear any partial store left by a previously-interrupted seed (mindepth 1
|
||||
# so the volume mount point itself is never removed), then copy and only then
|
||||
# write the sentinel. A partial store is node-owned (cp runs as node, and
|
||||
# step 1 re-chowns the volume whenever the sentinel is absent), so no sudo.
|
||||
find /home/node/.claude-mem -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
cp -r /host/.claude-mem/. /home/node/.claude-mem/
|
||||
touch /home/node/.claude-mem/.claude-mem-seeded
|
||||
else
|
||||
echo "[post-create] skipping claude-mem seed (already seeded, or host has no store)"
|
||||
fi
|
||||
|
||||
echo "[post-create] done"
|
||||
83
.devcontainer/seed-claude-config.cjs
Normal file
83
.devcontainer/seed-claude-config.cjs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// Builds the container's $HOME/.claude.json from the host's copy. It does NOT
|
||||
// copy the host file verbatim. The host's ~/.claude.json holds two kinds of
|
||||
// data. Some is portable account and onboarding state: hasCompletedOnboarding,
|
||||
// oauthAccount, userID, projects, tipsHistory. We keep that. The rest tracks
|
||||
// how Claude was installed on the host machine, and that is never right inside
|
||||
// this container.
|
||||
//
|
||||
// Here is why the install fields break things. The image installs Claude with
|
||||
// `npm install -g`. But if the host's `installMethod` says something like
|
||||
// "native", Claude looks for ~/.local/bin/claude and fails with
|
||||
// "claude command not found at /home/node/.local/bin/claude". So we drop the
|
||||
// install and machine fields. With them gone, the npm-global binary detects its
|
||||
// own install method. We also force hasCompletedOnboarding so the setup wizard
|
||||
// is skipped, even when the host has never run Claude before.
|
||||
//
|
||||
// This logic was pulled out of a heredoc in post-create.sh. As its own file the
|
||||
// transform can be unit-tested and prettier-checked (see seed-claude-config.test
|
||||
// via the translate-plugin-registries test harness). DISABLE_AUTOUPDATER=1 in
|
||||
// containerEnv already stops runtime updates. This file only quiets the doctor
|
||||
// mismatch and the native-path probe.
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
// Fields that describe how Claude was installed on the host machine. They are
|
||||
// never valid in an `npm install -g` container. Removing them lets Claude
|
||||
// detect the npm-global install on its own.
|
||||
const MACHINE_FIELDS = [
|
||||
'installMethod',
|
||||
'autoUpdates',
|
||||
'autoUpdatesProtectedForNative',
|
||||
'shiftEnterKeyBindingInstalled',
|
||||
];
|
||||
|
||||
// Pure transform: take whatever the host file parsed to and return a config
|
||||
// object suitable for the container. It also guards against a host file that is
|
||||
// valid JSON but not an object. A bare number, string, or array would pass the
|
||||
// parse try/catch. Then the field deletes would do nothing, the
|
||||
// hasCompletedOnboarding assignment would silently fail, and onboarding would
|
||||
// trigger again on every rebuild. The guard replaces such a value with {}.
|
||||
function sanitizeClaudeConfig(parsed) {
|
||||
let cfg = parsed;
|
||||
if (cfg === null || typeof cfg !== 'object' || Array.isArray(cfg)) {
|
||||
cfg = {};
|
||||
}
|
||||
for (const k of MACHINE_FIELDS) {
|
||||
delete cfg[k];
|
||||
}
|
||||
cfg.hasCompletedOnboarding = true; // skip the wizard, even on a first-time host
|
||||
return cfg;
|
||||
}
|
||||
|
||||
function readHostConfig(src) {
|
||||
try {
|
||||
if (fs.existsSync(src) && fs.statSync(src).size > 0) {
|
||||
return JSON.parse(fs.readFileSync(src, 'utf8'));
|
||||
}
|
||||
} catch {
|
||||
// Host file is malformed or unreadable. Fall back to an empty config so the
|
||||
// container still gets a valid file that carries hasCompletedOnboarding.
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const src = process.argv[2] || '/host/.claude.json';
|
||||
const dst = process.argv[3] || '/home/node/.claude.json';
|
||||
const cfg = sanitizeClaudeConfig(readHostConfig(src));
|
||||
try {
|
||||
fs.writeFileSync(dst, JSON.stringify(cfg, null, 2));
|
||||
fs.chmodSync(dst, 0o644);
|
||||
} catch (err) {
|
||||
console.error(`[post-create] ERROR: failed to seed ${dst}: ${err && err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { sanitizeClaudeConfig, readHostConfig, MACHINE_FIELDS };
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
107
.devcontainer/translate-plugin-registries.cjs
Normal file
107
.devcontainer/translate-plugin-registries.cjs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// Rewrites the host paths inside Claude and Cursor plugin-registry JSON files
|
||||
// so they point at the container's Linux paths, then writes the results into
|
||||
// the named volume.
|
||||
//
|
||||
// Why: both CLIs store absolute, OS-native install paths in their registry
|
||||
// JSONs. On Windows that looks like `C:\Users\X\.claude\plugins\...`; on macOS
|
||||
// like `/Users/X/.cursor/...`. The Linux container can't use those paths. If we
|
||||
// just bind-mounted the host files in, the CLI would try to resolve a Windows
|
||||
// path under Linux and fail with `cache-miss`. So for each CLI we read the host
|
||||
// registry, rewrite every absolute path ending in `/.<cli>/plugins/<rest>` to
|
||||
// `/home/node/.<cli>/plugins/<rest>`, and write the result into the named volume.
|
||||
//
|
||||
// Codex is left alone. Its registry is config.toml and holds git URLs, not
|
||||
// filesystem paths, so there's nothing to translate — its whole plugins/ dir is
|
||||
// copied as-is into the container volume instead (seeded once by post-create.sh).
|
||||
//
|
||||
// This code lived inside a post-create.sh heredoc. We pulled it out so the regex
|
||||
// and the deep rewrite can be unit-tested and prettier-checked. The regex has
|
||||
// had path-handling bugs before.
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Build a regex that matches an absolute path containing
|
||||
// `<sep>.<cli><sep>plugins<sep><rest>`, where <sep> is `/` or `\`. It's anchored
|
||||
// at the start of the string. The lazy `.*?` eats the home prefix up to the
|
||||
// FIRST `.<cli>/plugins` segment.
|
||||
function buildRe(cliName) {
|
||||
return new RegExp(`^(?:[A-Za-z]:)?[\\\\/].*?[\\\\/]\\.${cliName}[\\\\/]plugins[\\\\/](.*)$`);
|
||||
}
|
||||
|
||||
// Walk `obj` and rewrite every string value that matches `re`. A match is
|
||||
// remapped under `ctr`, the container's plugins dir. Windows backslashes in the
|
||||
// matched part are switched to forward slashes.
|
||||
function rewriteDeep(obj, re, ctr) {
|
||||
if (Array.isArray(obj)) return obj.map((v) => rewriteDeep(v, re, ctr));
|
||||
if (obj && typeof obj === 'object') {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(obj)) out[k] = rewriteDeep(v, re, ctr);
|
||||
return out;
|
||||
}
|
||||
if (typeof obj === 'string') {
|
||||
return obj.replace(re, (_, rest) => `${ctr}/${rest.replace(/\\/g, '/')}`);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
const REGISTRIES = [
|
||||
{
|
||||
cli: 'claude',
|
||||
host: '/host/.claude/plugins',
|
||||
ctr: '/home/node/.claude/plugins',
|
||||
files: ['known_marketplaces.json', 'installed_plugins.json', 'plugin-catalog-cache.json'],
|
||||
},
|
||||
{
|
||||
cli: 'cursor',
|
||||
host: '/host/.cursor/plugins',
|
||||
ctr: '/home/node/.cursor/plugins',
|
||||
files: ['installed_plugins.json'],
|
||||
},
|
||||
];
|
||||
|
||||
function translate(registries) {
|
||||
for (const reg of registries) {
|
||||
const re = buildRe(reg.cli);
|
||||
try {
|
||||
fs.mkdirSync(reg.ctr, { recursive: true });
|
||||
} catch (err) {
|
||||
console.error(`[post-create] ERROR: failed to create ${reg.ctr}: ${err && err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
for (const name of reg.files) {
|
||||
const src = path.join(reg.host, name);
|
||||
const dst = path.join(reg.ctr, name);
|
||||
if (!fs.existsSync(src) || fs.statSync(src).size === 0) continue;
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(fs.readFileSync(src, 'utf8'));
|
||||
} catch {
|
||||
continue; // Skip a malformed host registry instead of aborting.
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(dst, JSON.stringify(rewriteDeep(data, re, reg.ctr), null, 2));
|
||||
} catch (err) {
|
||||
console.error(`[post-create] ERROR: failed to write ${dst}: ${err && err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter the registry table by CLI name. post-create.sh passes the CLIs it is
|
||||
// seeding this run (e.g. `claude`), so a registry is only (re)generated on the
|
||||
// FIRST container-create for that CLI — never on a rebuild, where it would
|
||||
// clobber a plugin the user installed inside the container. An empty filter
|
||||
// (no args) means "translate every registry" — the original behavior.
|
||||
function selectRegistries(registries, only) {
|
||||
return only && only.length ? registries.filter((r) => only.includes(r.cli)) : registries;
|
||||
}
|
||||
|
||||
module.exports = { buildRe, rewriteDeep, REGISTRIES, translate, selectRegistries };
|
||||
|
||||
if (require.main === module) {
|
||||
translate(selectRegistries(REGISTRIES, process.argv.slice(2)));
|
||||
}
|
||||
436
.devcontainer/translate-plugin-registries.test.cjs
Normal file
436
.devcontainer/translate-plugin-registries.test.cjs
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
// Unit tests for the devcontainer host->container config transforms.
|
||||
//
|
||||
// This code used to live inside post-create.sh heredocs, where lint could not
|
||||
// see it and tests could not reach it. We test three things:
|
||||
// - plugin-registry path translation (buildRe + rewriteDeep + the real
|
||||
// filesystem translate() driver). Path handling here has had bugs before.
|
||||
// - the strip of machine-specific fields from $HOME/.claude.json
|
||||
// (sanitizeClaudeConfig + readHostConfig + the seed-claude-config main()
|
||||
// entry point)
|
||||
// - the host bind-source bootstrap (ensurePaths). One test guards against a
|
||||
// regression: ensurePaths must NOT pre-create settings.json / config.toml
|
||||
// on the host.
|
||||
//
|
||||
// We test both pure functions and code that touches the filesystem. The
|
||||
// filesystem tests use throwaway directories under os.tmpdir() and delete them
|
||||
// when done. So they run in CI with no mounts and never touch the real home dir.
|
||||
//
|
||||
// Run with the built-in Node test runner (no extra dependencies):
|
||||
// node --test .devcontainer/
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const os = require('node:os');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const {
|
||||
buildRe,
|
||||
rewriteDeep,
|
||||
translate,
|
||||
selectRegistries,
|
||||
} = require('./translate-plugin-registries.cjs');
|
||||
const { sanitizeClaudeConfig, readHostConfig } = require('./seed-claude-config.cjs');
|
||||
const { ensurePaths, DIRS, FILES } = require('./ensure-host-config-dirs.cjs');
|
||||
|
||||
const CLAUDE = '/home/node/.claude/plugins';
|
||||
const CURSOR = '/home/node/.cursor/plugins';
|
||||
|
||||
// Make a fresh throwaway directory under the OS temp root. mkdtemp picks a
|
||||
// unique name on every call, so we don't need Date.now() or random names.
|
||||
function tmp() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'gn-dc-'));
|
||||
}
|
||||
|
||||
function rw(value, cli, ctr) {
|
||||
return rewriteDeep(value, buildRe(cli), ctr);
|
||||
}
|
||||
|
||||
test('claude: Windows backslash absolute path -> container path', () => {
|
||||
assert.equal(
|
||||
rw('C:\\Users\\gergo\\.claude\\plugins\\cache\\x\\1.0', 'claude', CLAUDE),
|
||||
'/home/node/.claude/plugins/cache/x/1.0',
|
||||
);
|
||||
});
|
||||
|
||||
test('claude: Windows forward-slash absolute path -> container path', () => {
|
||||
assert.equal(
|
||||
rw('C:/Users/gergo/.claude/plugins/marketplaces/m', 'claude', CLAUDE),
|
||||
'/home/node/.claude/plugins/marketplaces/m',
|
||||
);
|
||||
});
|
||||
|
||||
test('claude: macOS POSIX path -> container path', () => {
|
||||
assert.equal(
|
||||
rw('/Users/alice/.claude/plugins/marketplaces/m', 'claude', CLAUDE),
|
||||
'/home/node/.claude/plugins/marketplaces/m',
|
||||
);
|
||||
});
|
||||
|
||||
test('claude: Linux POSIX path -> container path', () => {
|
||||
assert.equal(
|
||||
rw('/home/bob/.claude/plugins/cache/foo', 'claude', CLAUDE),
|
||||
'/home/node/.claude/plugins/cache/foo',
|
||||
);
|
||||
});
|
||||
|
||||
test('cursor: Windows path -> container cursor path', () => {
|
||||
assert.equal(
|
||||
rw('C:\\Users\\gergo\\.cursor\\plugins\\local\\myplug', 'cursor', CURSOR),
|
||||
'/home/node/.cursor/plugins/local/myplug',
|
||||
);
|
||||
});
|
||||
|
||||
test('cross-CLI isolation: claude regex leaves a .cursor path untouched', () => {
|
||||
const input = 'C:\\Users\\g\\.cursor\\plugins\\x';
|
||||
assert.equal(rw(input, 'claude', CLAUDE), input);
|
||||
});
|
||||
|
||||
test('non-path strings pass through unchanged', () => {
|
||||
assert.equal(rw('not-a-path', 'claude', CLAUDE), 'not-a-path');
|
||||
assert.equal(
|
||||
rw('https://github.com/EveryInc/x.git', 'claude', CLAUDE),
|
||||
'https://github.com/EveryInc/x.git',
|
||||
);
|
||||
});
|
||||
|
||||
test('non-string scalars pass through unchanged', () => {
|
||||
assert.equal(rw(42, 'claude', CLAUDE), 42);
|
||||
assert.equal(rw(null, 'claude', CLAUDE), null);
|
||||
assert.equal(rw(true, 'claude', CLAUDE), true);
|
||||
});
|
||||
|
||||
test('nested objects/arrays are rewritten deeply', () => {
|
||||
const input = {
|
||||
'compound-engineering@m': [
|
||||
{ installPath: 'C:\\Users\\g\\.claude\\plugins\\cache\\ce\\3.9.2', version: '3.9.2' },
|
||||
],
|
||||
nested: { installLocation: '/Users/g/.claude/plugins/marketplaces/m' },
|
||||
};
|
||||
const out = rw(input, 'claude', CLAUDE);
|
||||
assert.equal(
|
||||
out['compound-engineering@m'][0].installPath,
|
||||
'/home/node/.claude/plugins/cache/ce/3.9.2',
|
||||
);
|
||||
assert.equal(out['compound-engineering@m'][0].version, '3.9.2');
|
||||
assert.equal(out.nested.installLocation, '/home/node/.claude/plugins/marketplaces/m');
|
||||
});
|
||||
|
||||
test('sanitizeClaudeConfig: strips machine fields, forces hasCompletedOnboarding', () => {
|
||||
const out = sanitizeClaudeConfig({
|
||||
installMethod: 'native',
|
||||
autoUpdates: false,
|
||||
autoUpdatesProtectedForNative: true,
|
||||
shiftEnterKeyBindingInstalled: true,
|
||||
userID: 'abc',
|
||||
oauthAccount: { emailAddress: 'x@y.z' },
|
||||
});
|
||||
assert.equal(out.installMethod, undefined);
|
||||
assert.equal(out.autoUpdates, undefined);
|
||||
assert.equal(out.autoUpdatesProtectedForNative, undefined);
|
||||
assert.equal(out.shiftEnterKeyBindingInstalled, undefined);
|
||||
assert.equal(out.userID, 'abc');
|
||||
assert.equal(out.oauthAccount.emailAddress, 'x@y.z');
|
||||
assert.equal(out.hasCompletedOnboarding, true);
|
||||
});
|
||||
|
||||
test('sanitizeClaudeConfig: non-object inputs become a valid onboarding-bearing object', () => {
|
||||
for (const bad of [42, 'x', null, ['a'], true]) {
|
||||
const out = sanitizeClaudeConfig(bad);
|
||||
assert.equal(typeof out, 'object');
|
||||
assert.equal(Array.isArray(out), false);
|
||||
assert.equal(out.hasCompletedOnboarding, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('sanitizeClaudeConfig: empty object still gets hasCompletedOnboarding', () => {
|
||||
assert.deepEqual(sanitizeClaudeConfig({}), { hasCompletedOnboarding: true });
|
||||
});
|
||||
|
||||
// --- readHostConfig: reading the file, and the fallbacks when it fails ------
|
||||
|
||||
test('readHostConfig: missing file -> {}', () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
assert.deepEqual(readHostConfig(path.join(dir, 'nope.json')), {});
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('readHostConfig: empty (zero-byte) file -> {}', () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const f = path.join(dir, 'empty.json');
|
||||
fs.writeFileSync(f, '');
|
||||
assert.deepEqual(readHostConfig(f), {});
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('readHostConfig: malformed JSON -> {}', () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const f = path.join(dir, 'bad.json');
|
||||
fs.writeFileSync(f, '{ not valid json');
|
||||
assert.deepEqual(readHostConfig(f), {});
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('readHostConfig: valid object is parsed through', () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const f = path.join(dir, 'ok.json');
|
||||
fs.writeFileSync(f, JSON.stringify({ userID: 'u', hasCompletedOnboarding: false }));
|
||||
const out = readHostConfig(f);
|
||||
assert.equal(out.userID, 'u');
|
||||
assert.equal(out.hasCompletedOnboarding, false);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// --- translate(): runs against real registry files on disk ------------------
|
||||
|
||||
test('translate: rewrites host absolute paths and writes into the ctr dir', () => {
|
||||
const hostDir = tmp();
|
||||
const ctrParent = tmp();
|
||||
const ctrDir = path.join(ctrParent, 'plugins'); // need not exist yet; translate creates it
|
||||
try {
|
||||
const reg = [{ cli: 'claude', host: hostDir, ctr: ctrDir, files: ['installed_plugins.json'] }];
|
||||
fs.writeFileSync(
|
||||
path.join(hostDir, 'installed_plugins.json'),
|
||||
JSON.stringify({ 'p@m': [{ installPath: 'C:\\Users\\g\\.claude\\plugins\\cache\\p\\1.0' }] }),
|
||||
);
|
||||
translate(reg);
|
||||
const out = JSON.parse(fs.readFileSync(path.join(ctrDir, 'installed_plugins.json'), 'utf8'));
|
||||
assert.equal(out['p@m'][0].installPath, `${ctrDir}/cache/p/1.0`);
|
||||
} finally {
|
||||
fs.rmSync(hostDir, { recursive: true, force: true });
|
||||
fs.rmSync(ctrParent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('translate: idempotent — a second run reproduces byte-identical output', () => {
|
||||
const hostDir = tmp();
|
||||
const ctrParent = tmp();
|
||||
const ctrDir = path.join(ctrParent, 'plugins');
|
||||
try {
|
||||
const reg = [{ cli: 'claude', host: hostDir, ctr: ctrDir, files: ['installed_plugins.json'] }];
|
||||
fs.writeFileSync(
|
||||
path.join(hostDir, 'installed_plugins.json'),
|
||||
JSON.stringify({ 'p@m': [{ installPath: 'C:\\Users\\g\\.claude\\plugins\\cache\\p\\1.0' }] }),
|
||||
);
|
||||
translate(reg);
|
||||
const first = fs.readFileSync(path.join(ctrDir, 'installed_plugins.json'), 'utf8');
|
||||
translate(reg);
|
||||
const second = fs.readFileSync(path.join(ctrDir, 'installed_plugins.json'), 'utf8');
|
||||
assert.equal(first, second);
|
||||
} finally {
|
||||
fs.rmSync(hostDir, { recursive: true, force: true });
|
||||
fs.rmSync(ctrParent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('translate: malformed host registry is skipped, dst not written', () => {
|
||||
const hostDir = tmp();
|
||||
const ctrParent = tmp();
|
||||
const ctrDir = path.join(ctrParent, 'plugins');
|
||||
try {
|
||||
const reg = [{ cli: 'claude', host: hostDir, ctr: ctrDir, files: ['installed_plugins.json'] }];
|
||||
fs.writeFileSync(path.join(hostDir, 'installed_plugins.json'), '{ broken');
|
||||
translate(reg);
|
||||
assert.equal(fs.existsSync(path.join(ctrDir, 'installed_plugins.json')), false);
|
||||
} finally {
|
||||
fs.rmSync(hostDir, { recursive: true, force: true });
|
||||
fs.rmSync(ctrParent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('translate: empty and missing host registries are skipped without error', () => {
|
||||
const hostDir = tmp();
|
||||
const ctrParent = tmp();
|
||||
const ctrDir = path.join(ctrParent, 'plugins');
|
||||
try {
|
||||
const reg = [
|
||||
{ cli: 'claude', host: hostDir, ctr: ctrDir, files: ['empty.json', 'missing.json'] },
|
||||
];
|
||||
fs.writeFileSync(path.join(hostDir, 'empty.json'), ''); // we never create missing.json
|
||||
translate(reg);
|
||||
assert.equal(fs.existsSync(path.join(ctrDir, 'empty.json')), false);
|
||||
assert.equal(fs.existsSync(path.join(ctrDir, 'missing.json')), false);
|
||||
} finally {
|
||||
fs.rmSync(hostDir, { recursive: true, force: true });
|
||||
fs.rmSync(ctrParent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// --- selectRegistries: the per-CLI filter post-create.sh drives translate with
|
||||
|
||||
test('selectRegistries: no filter -> all registries (original behavior)', () => {
|
||||
const regs = [{ cli: 'claude' }, { cli: 'cursor' }];
|
||||
assert.deepEqual(selectRegistries(regs, []), regs);
|
||||
assert.deepEqual(selectRegistries(regs, undefined), regs);
|
||||
});
|
||||
|
||||
test('selectRegistries: filter keeps only the named CLIs', () => {
|
||||
const regs = [{ cli: 'claude' }, { cli: 'cursor' }];
|
||||
assert.deepEqual(selectRegistries(regs, ['claude']), [{ cli: 'claude' }]);
|
||||
assert.deepEqual(selectRegistries(regs, ['cursor']), [{ cli: 'cursor' }]);
|
||||
assert.deepEqual(selectRegistries(regs, ['claude', 'cursor']), regs);
|
||||
});
|
||||
|
||||
test('selectRegistries: an unknown CLI name selects nothing', () => {
|
||||
const regs = [{ cli: 'claude' }, { cli: 'cursor' }];
|
||||
assert.deepEqual(selectRegistries(regs, ['codex']), []);
|
||||
});
|
||||
|
||||
test('selectRegistries: empty registry table stays empty under any filter', () => {
|
||||
assert.deepEqual(selectRegistries([], ['claude']), []);
|
||||
assert.deepEqual(selectRegistries([], []), []);
|
||||
});
|
||||
|
||||
// --- seed-claude-config main(): end-to-end, through the real CLI entry point
|
||||
|
||||
const SEED_SCRIPT = path.join(__dirname, 'seed-claude-config.cjs');
|
||||
|
||||
test('seed main: strips machine fields, keeps account, sets onboarding, chmod 644', () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const src = path.join(dir, 'host.claude.json');
|
||||
const dst = path.join(dir, 'out.claude.json');
|
||||
fs.writeFileSync(
|
||||
src,
|
||||
JSON.stringify({
|
||||
installMethod: 'native',
|
||||
userID: 'abc',
|
||||
oauthAccount: { emailAddress: 'x@y.z' },
|
||||
}),
|
||||
);
|
||||
execFileSync(process.execPath, [SEED_SCRIPT, src, dst]);
|
||||
const out = JSON.parse(fs.readFileSync(dst, 'utf8'));
|
||||
assert.equal(out.installMethod, undefined);
|
||||
assert.equal(out.userID, 'abc');
|
||||
assert.equal(out.oauthAccount.emailAddress, 'x@y.z');
|
||||
assert.equal(out.hasCompletedOnboarding, true);
|
||||
if (process.platform !== 'win32') {
|
||||
assert.equal(fs.statSync(dst).mode & 0o777, 0o644);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('seed main: missing host file still writes a valid onboarding-bearing file', () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const dst = path.join(dir, 'out.claude.json');
|
||||
execFileSync(process.execPath, [SEED_SCRIPT, path.join(dir, 'nope.json'), dst]);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(dst, 'utf8')), { hasCompletedOnboarding: true });
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('seed main: chmodSync widens a pre-existing restrictive dst to 0o644', () => {
|
||||
// This checks the file's permission bits, which only exist on POSIX systems.
|
||||
//
|
||||
// The catch: CI's default umask is 022, so a plain writeFileSync already
|
||||
// creates files at mode 0o644. Asserting 0o644 right after a fresh write
|
||||
// would therefore NOT prove the explicit chmodSync did anything.
|
||||
//
|
||||
// So we pre-create dst at the stricter mode 0o600. Opening a file in 'w'
|
||||
// mode replaces its contents but KEEPS the mode of a file that already
|
||||
// exists. That means the only way dst can end up at 0o644 is the chmodSync
|
||||
// inside seed-claude-config.cjs. This pins the test to the chmod and not to
|
||||
// the umask: delete the chmodSync line and this test fails, while the other
|
||||
// seed test still passes.
|
||||
if (process.platform === 'win32') return;
|
||||
const dir = tmp();
|
||||
try {
|
||||
const src = path.join(dir, 'host.claude.json');
|
||||
const dst = path.join(dir, 'out.claude.json');
|
||||
fs.writeFileSync(src, JSON.stringify({ userID: 'u' }));
|
||||
fs.writeFileSync(dst, '{}');
|
||||
fs.chmodSync(dst, 0o600);
|
||||
execFileSync(process.execPath, [SEED_SCRIPT, src, dst]);
|
||||
assert.equal(fs.statSync(dst).mode & 0o777, 0o644);
|
||||
assert.equal(JSON.parse(fs.readFileSync(dst, 'utf8')).userID, 'u');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// --- ensurePaths: sets up the host paths the bind mounts point at -----------
|
||||
|
||||
test('ensurePaths: creates every DIR and FILE under a temp home, idempotently', () => {
|
||||
const home = tmp();
|
||||
try {
|
||||
ensurePaths(home);
|
||||
for (const d of DIRS) {
|
||||
assert.equal(fs.statSync(path.join(home, d)).isDirectory(), true, `not a dir: ${d}`);
|
||||
}
|
||||
for (const f of FILES) {
|
||||
assert.equal(fs.statSync(path.join(home, f)).isFile(), true, `not a file: ${f}`);
|
||||
}
|
||||
// Running it again must not throw and must not overwrite existing content.
|
||||
fs.writeFileSync(path.join(home, '.claude.json'), '{"keep":true}');
|
||||
ensurePaths(home);
|
||||
assert.equal(fs.readFileSync(path.join(home, '.claude.json'), 'utf8'), '{"keep":true}');
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('ensurePaths: does NOT pre-create settings.json / config.toml (no gratuitous host mutation)', () => {
|
||||
const home = tmp();
|
||||
try {
|
||||
ensurePaths(home);
|
||||
assert.equal(fs.existsSync(path.join(home, '.claude', 'settings.json')), false);
|
||||
assert.equal(fs.existsSync(path.join(home, '.codex', 'config.toml')), false);
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('ensurePaths: does NOT pre-create the shareable subdirs (now copied, not bound)', () => {
|
||||
// The shareable dirs are seeded into the per-container volume from the
|
||||
// read-only /host stage, so they are no longer bind-mount sources. Pre-creating
|
||||
// empty ones would needlessly write into the host of someone who never used a
|
||||
// CLI. This pins the DIRS trim: re-adding any of these would fail the test.
|
||||
const home = tmp();
|
||||
const mustNotExist = [
|
||||
path.join('.claude', 'skills'),
|
||||
path.join('.claude', 'agents'),
|
||||
path.join('.claude', 'memory'),
|
||||
path.join('.claude', 'commands'),
|
||||
path.join('.claude', 'plugins'),
|
||||
path.join('.codex', 'plugins'),
|
||||
path.join('.codex', 'prompts'),
|
||||
path.join('.codex', 'memories'),
|
||||
path.join('.codex', 'skills'),
|
||||
path.join('.cursor', 'rules'),
|
||||
path.join('.cursor', 'commands'),
|
||||
path.join('.cursor', 'agents'),
|
||||
path.join('.cursor', 'skills'),
|
||||
path.join('.cursor', 'plugins'),
|
||||
];
|
||||
try {
|
||||
ensurePaths(home);
|
||||
for (const sub of mustNotExist) {
|
||||
assert.equal(fs.existsSync(path.join(home, sub)), false, `should not pre-create: ${sub}`);
|
||||
}
|
||||
// The top-level stage roots that ARE still bind sources must exist.
|
||||
for (const top of ['.claude', '.codex', '.cursor', '.claude-mem']) {
|
||||
assert.equal(fs.statSync(path.join(home, top)).isDirectory(), true, `missing root: ${top}`);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
15
.gitattributes
vendored
15
.gitattributes
vendored
|
|
@ -1,2 +1,17 @@
|
|||
* text=auto eol=lf
|
||||
.husky/* text eol=lf
|
||||
|
||||
# Shell scripts: force LF unconditionally so devcontainer scripts
|
||||
# (e.g. anything COPYed into a Linux container) execute correctly when
|
||||
# checked out on Windows hosts with core.autocrlf=true.
|
||||
*.sh text eol=lf
|
||||
*.bash text eol=lf
|
||||
|
||||
# Native and binary assets shouldn't be treated as text under any
|
||||
# auto-detection or eol normalization.
|
||||
*.node binary
|
||||
*.wasm binary
|
||||
*.onnx binary
|
||||
*.so binary
|
||||
*.dll binary
|
||||
*.dylib binary
|
||||
|
|
|
|||
4
.github/CODEOWNERS
vendored
Normal file
4
.github/CODEOWNERS
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Code owners
|
||||
|
||||
* @Arvuno
|
||||
* @magyargergo
|
||||
|
|
@ -332,6 +332,111 @@ def vendored_drift_summary(
|
|||
}
|
||||
|
||||
|
||||
# ── Assert mode (CI gate) ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def assert_current() -> int:
|
||||
"""Assert every grammar's ABI is loadable by the CURRENT runtime.
|
||||
|
||||
Unlike the readiness report (which probes the npm registry + upstream
|
||||
main for the *target* runtime), this mode is hermetic and offline: it
|
||||
reads only what's checked out / installed locally and asserts each
|
||||
grammar's compiled ABI lies within the current runtime's
|
||||
``RUNTIME_ABI_RANGES`` window. It is the static half of the #1922 ABI
|
||||
gate; the runtime load-smoke (`parser-loader-abi.test.ts`) is the
|
||||
dynamic half.
|
||||
|
||||
Coverage, reusing the existing helpers:
|
||||
- npm-installed grammars: ABI from node_modules/<name>/<parser.c>.
|
||||
- vendored grammars (dart/proto/swift): ABI via ``vendored_drift_summary``.
|
||||
- Swift is prebuilt-only (no parser.c) → not introspectable here;
|
||||
treated as "covered by the runtime load-smoke", not asserted.
|
||||
- INTENTIONAL_PINS are honored: a pinned grammar is expected to sit at
|
||||
an ABI the current runtime loads (that's *why* it's pinned), so it is
|
||||
asserted like any other rather than skipped.
|
||||
|
||||
Returns 0 when every introspectable grammar is in range, 1 otherwise.
|
||||
Prints a plain-text (non-Markdown) report so CI logs stay readable.
|
||||
"""
|
||||
current_runtime = read_current_runtime()
|
||||
abi_range = RUNTIME_ABI_RANGES.get(current_runtime)
|
||||
if abi_range is None:
|
||||
print(
|
||||
f"FAIL: RUNTIME_ABI_RANGES has no entry for current runtime "
|
||||
f"{current_runtime!r}; add it before asserting.",
|
||||
)
|
||||
return 1
|
||||
lo, hi = abi_range
|
||||
pinned_versions = read_pinned_grammar_versions()
|
||||
|
||||
print(
|
||||
f"Asserting all grammar ABIs load on tree-sitter@{current_runtime}.x "
|
||||
f"(ABI {lo}–{hi})."
|
||||
)
|
||||
|
||||
failures: list[str] = []
|
||||
checked = 0
|
||||
skipped: list[str] = []
|
||||
|
||||
for name, (upstream_repo, upstream_branch, parser_path) in sorted(GRAMMARS.items()):
|
||||
pinned_spec = pinned_versions.get(name, "—")
|
||||
pin_note = f" [intentional pin: {pinned_spec}]" if name in INTENTIONAL_PINS else ""
|
||||
|
||||
if is_vendored_pin(pinned_spec):
|
||||
v = vendored_drift_summary(name, upstream_repo, upstream_branch, parser_path)
|
||||
abi = v["vendored_abi"]
|
||||
if abi is None:
|
||||
# Prebuilt-only vendor (e.g. tree-sitter-swift): no parser.c to
|
||||
# introspect. The runtime load-smoke covers it instead.
|
||||
skipped.append(f"{name} (vendored, prebuilt — covered by load-smoke)")
|
||||
continue
|
||||
checked += 1
|
||||
if lo <= abi <= hi:
|
||||
print(f" OK {name}: vendored ABI {abi} in range{pin_note}")
|
||||
else:
|
||||
msg = (
|
||||
f"{name}: vendored ABI {abi} outside current runtime range "
|
||||
f"{lo}..{hi}{pin_note}"
|
||||
)
|
||||
print(f" FAIL {msg}")
|
||||
failures.append(msg)
|
||||
continue
|
||||
|
||||
installed_parser = GITNEXUS_DIR / "node_modules" / name / parser_path
|
||||
if not installed_parser.is_file():
|
||||
installed_parser = GITNEXUS_DIR / "node_modules" / name / "src" / "parser.c"
|
||||
abi = extract_language_version(installed_parser)
|
||||
if abi is None:
|
||||
skipped.append(f"{name} (not installed / no parser.c — covered by load-smoke)")
|
||||
continue
|
||||
checked += 1
|
||||
if lo <= abi <= hi:
|
||||
print(f" OK {name}: installed ABI {abi} in range{pin_note}")
|
||||
else:
|
||||
msg = (
|
||||
f"{name}: installed ABI {abi} outside current runtime range "
|
||||
f"{lo}..{hi}{pin_note}"
|
||||
)
|
||||
print(f" FAIL {msg}")
|
||||
failures.append(msg)
|
||||
|
||||
print("")
|
||||
if skipped:
|
||||
print("Not statically introspectable (asserted via runtime load-smoke):")
|
||||
for s in skipped:
|
||||
print(f" - {s}")
|
||||
print("")
|
||||
|
||||
if failures:
|
||||
print(f"RESULT: FAIL — {len(failures)} grammar(s) out of range, {checked} checked.")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
return 1
|
||||
|
||||
print(f"RESULT: OK — all {checked} introspectable grammar ABIs in range.")
|
||||
return 0
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -806,4 +911,9 @@ if __name__ == "__main__":
|
|||
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
# `--assert-current` is the offline CI gate (#1922): assert every grammar's
|
||||
# ABI loads on the CURRENT runtime. Bare invocation keeps the original
|
||||
# target-runtime readiness report behaviour.
|
||||
if "--assert-current" in sys.argv[1:]:
|
||||
sys.exit(assert_current())
|
||||
sys.exit(main())
|
||||
|
|
|
|||
127
.github/workflows/ci-devcontainer.yml
vendored
Normal file
127
.github/workflows/ci-devcontainer.yml
vendored
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
name: Devcontainer Smoke
|
||||
|
||||
# Smoke-tests .devcontainer/ whenever it changes. Two things happen here.
|
||||
# First, unit tests run on the pure host->container config transforms: the
|
||||
# plugin-registry path translation, and the strip of the machine field from
|
||||
# $HOME/.claude.json. Second, the devcontainer image is built through the
|
||||
# standard @devcontainers/cli path. That CLI reads build.args from
|
||||
# devcontainer.json, so the version pin there stays the single source of truth.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.devcontainer/**'
|
||||
- '.github/workflows/ci-devcontainer.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.devcontainer/**'
|
||||
- '.github/workflows/ci-devcontainer.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Grouped per branch or tag. Cancel a PR run when a newer one replaces it.
|
||||
# Never cancel a push-to-main run.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
config-transforms:
|
||||
name: Config-transform unit tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# 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
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Unit-test the host->container config transforms
|
||||
run: node --test .devcontainer/translate-plugin-registries.test.cjs
|
||||
- name: Syntax-check the lifecycle shell scripts
|
||||
run: |
|
||||
bash -n .devcontainer/install-deps.sh
|
||||
bash -n .devcontainer/post-create.sh
|
||||
|
||||
build:
|
||||
name: Build devcontainer image
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
# 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
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
# Builds the image the same way a developer's "Reopen in Container" does.
|
||||
# @devcontainers/cli reads devcontainer.json (jsonc format), resolves
|
||||
# build.args (the CLAUDE_CODE_VERSION / CODEX_VERSION pins), and runs the
|
||||
# Dockerfile. This smoke catches Dockerfile regressions and any drift from
|
||||
# the canonical version pins. The lifecycle hooks (post-create.sh) do not
|
||||
# run here. They need the host config mounts, and CI has none.
|
||||
#
|
||||
# ARCH COVERAGE: this runs on an x64 runner with no --platform or QEMU, so
|
||||
# it builds only the amd64 Cursor branch (CURSOR_SHA256_X64). The arm64
|
||||
# branch (CURSOR_SHA256_ARM64 plus the arm64 tarball URL) is pinned by a
|
||||
# sha256 checked against the published artifact, but it is not BUILT here.
|
||||
# Cursor's extract-and-symlink step does not depend on the architecture, so
|
||||
# the only remaining gap is a stale arm64 URL or hash. If that becomes a
|
||||
# concern, add a linux/arm64 matrix leg (docker/setup-qemu-action plus
|
||||
# `--platform`).
|
||||
#
|
||||
# The @devcontainers/cli version is pinned on purpose. A bare
|
||||
# `npx --yes @devcontainers/cli` would resolve @latest at run time. A
|
||||
# breaking or malicious publish could then change CI behavior, or change
|
||||
# how devcontainer.json is read, with no diff to show for it. Bump this pin
|
||||
# deliberately, alongside the Dockerfile and devcontainer.json pins.
|
||||
#
|
||||
# @devcontainers/cli wraps the Dockerfile with `# syntax=docker/dockerfile:1`,
|
||||
# which BuildKit resolves from Docker Hub. Hub blips surface as
|
||||
# `DeadlineExceeded` / `i/o timeout` on the syntax frontend (see run
|
||||
# 26797815133). Build retry (2 attempts, 45s backoff) matches
|
||||
# `.github/actions/docker-build-push-retry` (docker/build-push-action#1422).
|
||||
# Pre-pull of docker/dockerfile:1 is extra hardening; best-effort so the
|
||||
# build retry still runs if Hub is flaky only during pull.
|
||||
- name: Pre-pull BuildKit Dockerfile frontend (retry)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
img="docker/dockerfile:1"
|
||||
for attempt in 1 2 3; do
|
||||
if docker pull "$img"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::docker pull ${img} attempt ${attempt} failed"
|
||||
if [ "$attempt" -lt 3 ]; then
|
||||
sleep $((attempt * 15))
|
||||
fi
|
||||
done
|
||||
echo "::warning::failed to pre-pull ${img} after 3 attempts; continuing — build step may still succeed"
|
||||
exit 1
|
||||
- name: Build devcontainer via @devcontainers/cli
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2; do
|
||||
if npx --yes @devcontainers/cli@0.87.0 build --workspace-folder .; then
|
||||
if [ "$attempt" -eq 2 ]; then
|
||||
echo "::notice::devcontainer build retry succeeded (attempt 2); investigate if this recurs across runs."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
if [ "$attempt" -eq 2 ]; then
|
||||
echo "::error::devcontainer build failed after 2 attempts"
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::devcontainer build attempt ${attempt} failed; retrying in 45s…"
|
||||
sleep 45
|
||||
done
|
||||
87
.github/workflows/ci-scope-parity.yml
vendored
87
.github/workflows/ci-scope-parity.yml
vendored
|
|
@ -1,87 +0,0 @@
|
|||
name: Scope Resolution Parity
|
||||
|
||||
# Reusable workflow — called from ci.yml. Does NOT declare concurrency;
|
||||
# it inherits the caller's concurrency group per the convention documented
|
||||
# in CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
#
|
||||
# ── Purpose (RFC #909 Ring 3, §6.4 "Observability gates") ──────────────
|
||||
# For every language in `MIGRATED_LANGUAGES` (exported from
|
||||
# `gitnexus/src/core/ingestion/registry-primary-flag.ts`), run the
|
||||
# resolver integration test at `test/integration/resolvers/<slug>.test.ts`
|
||||
# TWICE on every PR:
|
||||
#
|
||||
# 1. `REGISTRY_PRIMARY_<LANG>=0` — legacy DAG path (guarantees we haven't
|
||||
# broken the old path while migrating). Known legacy gaps may be skipped
|
||||
# through the resolver test helper's expected-failure list.
|
||||
# 2. `REGISTRY_PRIMARY_<LANG>=1` — registry-primary path (guarantees the
|
||||
# new path carries the same behavior — the parity gate).
|
||||
#
|
||||
# BOTH must pass. The source of truth is the TypeScript constant — adding
|
||||
# a language to that `Set` is the ONLY contributor action; CI auto-
|
||||
# discovers it, runs parity, and the language's default production path
|
||||
# flips to registry-primary in the same change.
|
||||
#
|
||||
# When the set is empty (e.g. mid-Ring-3 for every language), the parity
|
||||
# matrix is skipped and the workflow reports success — no-op until a
|
||||
# language is explicitly claimed migrated.
|
||||
#
|
||||
# ── Consolidation (chore/vitest-speed-strategy) ────────────────────────
|
||||
# Previously each language was a separate GitHub Actions matrix job,
|
||||
# meaning N languages × 1 checkout+install+build per shard. The build
|
||||
# cost dwarfed the test cost (~5 min setup for ~15 sec test execution).
|
||||
#
|
||||
# Now a single job runs `scripts/run-parity.ts` which loops through all
|
||||
# migrated languages sequentially (2 vitest invocations per language:
|
||||
# legacy + registry-primary). All failures are collected and reported
|
||||
# at the end (equivalent to the old fail-fast: false behavior).
|
||||
#
|
||||
# Adding a new language to MIGRATED_LANGUAGES still requires no workflow
|
||||
# edit — the script auto-discovers the set at runtime.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
discover:
|
||||
name: Discover migrated languages
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
has-any: ${{ steps.read.outputs.has-any }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
|
||||
- name: Extract MIGRATED_LANGUAGES from registry-primary-flag.ts
|
||||
id: read
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LANGS=$(npx tsx scripts/ci-list-migrated-languages.ts)
|
||||
COUNT=$(printf '%s' "$LANGS" | jq 'length')
|
||||
HAS_ANY="false"
|
||||
if [[ "$COUNT" -gt 0 ]]; then HAS_ANY="true"; fi
|
||||
echo "has-any=$HAS_ANY" >> "$GITHUB_OUTPUT"
|
||||
echo "Discovered $COUNT migrated language(s): $LANGS"
|
||||
echo "Parity will run: $HAS_ANY"
|
||||
|
||||
parity:
|
||||
name: scope-resolution parity
|
||||
needs: discover
|
||||
if: needs.discover.outputs.has-any == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
|
||||
- name: Run parity for all migrated languages
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
run: npx tsx scripts/run-parity.ts
|
||||
95
.github/workflows/ci-tests.yml
vendored
95
.github/workflows/ci-tests.yml
vendored
|
|
@ -12,7 +12,13 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
# persist-credentials: false — this job runs tests and uploads a
|
||||
# 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
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
|
|
@ -72,7 +78,11 @@ jobs:
|
|||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
# persist-credentials: false — runs tests only, never pushes (zizmor
|
||||
# credential-persistence / artipacked audit).
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
|
|
@ -80,6 +90,34 @@ jobs:
|
|||
run: npx tsx scripts/run-cross-platform.ts
|
||||
working-directory: gitnexus
|
||||
|
||||
# Tree-sitter ABI gate (#1922). Two halves, both blocking:
|
||||
# 1. Static, offline: assert every grammar's compiled ABI loads on the
|
||||
# pinned runtime (check-tree-sitter-upgrade-readiness.py --assert-current).
|
||||
# 2. Dynamic: run the parser-loader ABI load-smoke on the OS matrix so an
|
||||
# ABI-incompatible prebuilt (esp. the binary-only Swift vendor, which the
|
||||
# static check can't introspect) fails on the platform it ships to.
|
||||
abi-assert:
|
||||
name: tree-sitter ABI (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
|
||||
- name: Assert installed + vendored grammar ABIs (static)
|
||||
shell: bash
|
||||
run: python3 .github/scripts/check-tree-sitter-upgrade-readiness.py --assert-current
|
||||
|
||||
- name: Run parser-loader ABI load-smoke (dynamic)
|
||||
run: npx vitest run test/unit/parser-loader-abi.test.ts
|
||||
working-directory: gitnexus
|
||||
|
||||
# End-to-end smoke test for the #1728 packaging fix: pack the published
|
||||
# tarball, install it globally into a temp prefix, and assert no junction
|
||||
# creation (the EPERM root cause) plus working CLI plus vendor cleanliness
|
||||
|
|
@ -181,3 +219,60 @@ jobs:
|
|||
else
|
||||
"$PREFIX/bin/gitnexus" --version
|
||||
fi
|
||||
|
||||
# ── Dedicated benchmark gate ─────────────────────────────────────
|
||||
# The cross-language `*-pipeline-benchmark.test.ts` suites are gated behind
|
||||
# GITNEXUS_BENCH (they generate synthetic codebases at scale), so the main
|
||||
# coverage job above SKIPS them — their O(n^2) scaling guards never ran in CI.
|
||||
# Run them here with GITNEXUS_BENCH=1, alongside the Python scope-capture and
|
||||
# import-resolution fingerprint + scaling guards (PR #1918 P2a).
|
||||
#
|
||||
# `--no-file-parallelism` is REQUIRED: these suites measure wall-clock and peak
|
||||
# heap, so parallel forks both skew the timings and OOM the worker pool — they
|
||||
# must run one file at a time.
|
||||
#
|
||||
# go-pipeline-benchmark.test.ts is deliberately NOT included: its
|
||||
# worker-pool (#1848) suite spins a real worker pool that exits unexpectedly
|
||||
# under vitest's fork pool (reproduced in validation), which would make this
|
||||
# gate flaky. Go is already guarded by its non-gated O(n^2) tripwire (runs in
|
||||
# the main coverage job) plus its golden capture-parity test.
|
||||
benchmarks:
|
||||
name: benchmarks (GITNEXUS_BENCH)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
# persist-credentials: false — this job only runs npm + vitest benchmarks
|
||||
# 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
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
|
||||
- name: Python scope-capture + import-resolution fingerprint / scaling guards
|
||||
run: |
|
||||
node --import tsx bench/python-scope/measure.mjs --check
|
||||
node --import tsx bench/python-scope/import-target-fingerprint.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Cross-language scope-capture fingerprint + scaling guards
|
||||
# Build-free: asserts emit<Lang>ScopeCaptures output is unchanged
|
||||
# (fingerprint) and stays linear (scaling < 1.5) for go/csharp/rust/php/
|
||||
# ruby/cobol. Catches an O(n^2) re-regression without the worker pool.
|
||||
run: node --import tsx bench/scope-capture/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial)
|
||||
env:
|
||||
GITNEXUS_BENCH: '1'
|
||||
run: >-
|
||||
npx vitest run --no-file-parallelism
|
||||
test/integration/cobol-pipeline-benchmark.test.ts
|
||||
test/integration/csharp-pipeline-benchmark.test.ts
|
||||
test/integration/rust-pipeline-benchmark.test.ts
|
||||
test/integration/php-pipeline-benchmark.test.ts
|
||||
test/integration/ruby-pipeline-benchmark.test.ts
|
||||
working-directory: gitnexus
|
||||
|
|
|
|||
40
.github/workflows/ci.yml
vendored
40
.github/workflows/ci.yml
vendored
|
|
@ -27,9 +27,8 @@ concurrency:
|
|||
# Each concern lives in its own workflow file for maintainability:
|
||||
# ci-quality.yml — typecheck (tsc --noEmit)
|
||||
# ci-tests.yml — unit + integration tests with coverage + cross-platform
|
||||
# (includes the scope-resolution resolver tests)
|
||||
# ci-e2e.yml — E2E tests (only when gitnexus-web/ changes)
|
||||
# ci-scope-parity.yml — RFC #909 Ring 3 parity gate: legacy DAG + registry-primary
|
||||
# both pass, per migrated language in the JSON registry
|
||||
#
|
||||
# Shared setup is DRY via .github/actions/setup-gitnexus composite action.
|
||||
|
||||
|
|
@ -49,11 +48,6 @@ jobs:
|
|||
permissions:
|
||||
contents: read
|
||||
|
||||
scope-parity:
|
||||
uses: ./.github/workflows/ci-scope-parity.yml
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# ── Save PR metadata for the reporting workflow ─────────────────
|
||||
# The ci-report.yml workflow (triggered by workflow_run) needs the
|
||||
# PR number and job results to post a comment. We save them as an
|
||||
|
|
@ -62,7 +56,7 @@ jobs:
|
|||
save-pr-meta:
|
||||
name: Save PR Metadata
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
needs: [quality, tests, e2e, scope-parity]
|
||||
needs: [quality, tests, e2e]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
|
|
@ -73,14 +67,12 @@ jobs:
|
|||
QUALITY: ${{ needs.quality.result }}
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
E2E: ${{ needs.e2e.result }}
|
||||
SCOPE_PARITY: ${{ needs.scope-parity.result }}
|
||||
run: |
|
||||
mkdir -p pr-meta
|
||||
echo "$PR_NUMBER" > pr-meta/pr_number
|
||||
echo "$QUALITY" > pr-meta/quality_result
|
||||
echo "$TESTS" > pr-meta/tests_result
|
||||
echo "$E2E" > pr-meta/e2e_result
|
||||
echo "$SCOPE_PARITY" > pr-meta/scope_parity_result
|
||||
# TODO(post-merge): remove backward-compat copies once ci-report.yml
|
||||
# on main reads underscore names.
|
||||
# Backward-compat: ci-report.yml on main still reads hyphenated
|
||||
|
|
@ -103,7 +95,7 @@ jobs:
|
|||
# Single required check for branch protection.
|
||||
ci-status:
|
||||
name: CI Gate
|
||||
needs: [quality, tests, e2e, scope-parity]
|
||||
needs: [quality, tests, e2e]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
|
@ -112,31 +104,29 @@ jobs:
|
|||
shell: bash
|
||||
env:
|
||||
QUALITY: ${{ needs.quality.result }}
|
||||
# The tree-sitter ABI gate (#1922) runs as the `abi-assert` job
|
||||
# inside the `tests` reusable workflow. A failed job fails the
|
||||
# reusable workflow, so `needs.tests.result` below blocks the merge
|
||||
# on an ABI mismatch. (`jobs.<id>.result` cannot be exposed as a
|
||||
# workflow_call output, so the gate is enforced transitively here.)
|
||||
# The scope-resolution resolver tests also run inside the `tests`
|
||||
# workflow (RING4-1 #942 removed the separate scope-parity gate),
|
||||
# so a resolver regression makes TESTS != success and blocks here.
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
E2E: ${{ needs.e2e.result }}
|
||||
SCOPE_PARITY: ${{ needs.scope-parity.result }}
|
||||
run: |
|
||||
echo "Quality: $QUALITY"
|
||||
echo "Tests: $TESTS"
|
||||
echo "E2E: $E2E"
|
||||
echo "Scope parity: $SCOPE_PARITY"
|
||||
# A failed `abi-assert` job (#1922) inside the tests reusable
|
||||
# workflow makes TESTS != success, so this clause also blocks the
|
||||
# merge on a tree-sitter ABI mismatch.
|
||||
if [[ "$QUALITY" != "success" ]] ||
|
||||
[[ "$TESTS" != "success" ]]; then
|
||||
echo "::error::Quality or test jobs failed"
|
||||
echo "::error::Quality or test jobs failed (includes the tree-sitter ABI gate, #1922)"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$E2E" != "success" && "$E2E" != "skipped" ]]; then
|
||||
echo "::error::E2E job failed"
|
||||
exit 1
|
||||
fi
|
||||
# scope-parity is a reusable workflow. With an empty migrated-
|
||||
# languages list, its parity matrix is skipped and the outer
|
||||
# workflow still reports `success`. If any entry's legacy-DAG or
|
||||
# registry-primary run fails, the workflow reports `failure`.
|
||||
# Accept only `success`; `skipped` would mean the entire
|
||||
# discover job was skipped too (upstream failure), which should
|
||||
# still block.
|
||||
if [[ "$SCOPE_PARITY" != "success" ]]; then
|
||||
echo "::error::Scope-resolution parity gate failed (RFC #909 Ring 3)"
|
||||
exit 1
|
||||
fi
|
||||
|
|
|
|||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
|
|
@ -48,7 +48,7 @@ jobs:
|
|||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
|
|
@ -69,6 +69,6 @@ jobs:
|
|||
- '**/test/fixtures/**'
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
|
|
|
|||
8
.github/workflows/docker.yml
vendored
8
.github/workflows/docker.yml
vendored
|
|
@ -141,14 +141,14 @@ jobs:
|
|||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
|
@ -163,7 +163,7 @@ jobs:
|
|||
# `akonlabs/gitnexus` and `akonlabs/gitnexus-web` repos.
|
||||
- name: Log in to Docker Hub
|
||||
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
|
@ -183,7 +183,7 @@ jobs:
|
|||
# `github.event_name` would still be "push", not "workflow_call".
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
# Dual-registry publish. metadata-action expands the same tag set
|
||||
# against every image ref listed here, and build-push-action pushes
|
||||
|
|
|
|||
12
.github/workflows/gitleaks.yml
vendored
12
.github/workflows/gitleaks.yml
vendored
|
|
@ -38,6 +38,18 @@ jobs:
|
|||
# steps (and Gitleaks itself) don't need it for repo operations.
|
||||
persist-credentials: false
|
||||
|
||||
# gitleaks-action builds `base^..head` for pull_request events; both SHAs
|
||||
# must exist locally (fork PRs and shallow checkouts otherwise fail with
|
||||
# "unknown revision" — see gitleaks/gitleaks-action#199).
|
||||
- name: Fetch PR refs for gitleaks range
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
git fetch --no-tags origin "$BASE_SHA"
|
||||
git fetch --no-tags origin "$HEAD_SHA"
|
||||
|
||||
# No GITLEAKS_LICENSE secret is required for OSS / public-repo usage.
|
||||
# If this repo becomes private, the action will require a license key.
|
||||
- name: Gitleaks
|
||||
|
|
|
|||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
|
|
@ -257,7 +257,7 @@ jobs:
|
|||
# ── Phase 3: reusable CI gate ──────────────────────────────────────────────
|
||||
# Runs for both rc (when guard says go) and stable. No `secrets:` passed —
|
||||
# ci.yml and its entire reusable-workflow chain (ci-quality, ci-tests,
|
||||
# ci-e2e, ci-scope-parity, ci-report) reference zero `secrets.*` values;
|
||||
# ci-e2e, ci-report) reference zero `secrets.*` values;
|
||||
# passing any would be unused surface. GITHUB_TOKEN is implicit.
|
||||
ci:
|
||||
needs: [route, rc-guard]
|
||||
|
|
|
|||
2
.github/workflows/scorecard.yml
vendored
2
.github/workflows/scorecard.yml
vendored
|
|
@ -53,6 +53,6 @@ jobs:
|
|||
retention-days: 5
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
|
|
|||
6
.github/workflows/trivy.yml
vendored
6
.github/workflows/trivy.yml
vendored
|
|
@ -50,10 +50,10 @@ jobs:
|
|||
persist-credentials: false
|
||||
|
||||
- name: Setup Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Build image (load locally for scan)
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.image.dockerfile }}
|
||||
|
|
@ -76,7 +76,7 @@ jobs:
|
|||
exit-code: '0'
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
sarif_file: trivy-${{ matrix.image.name }}.sarif
|
||||
category: trivy-${{ matrix.image.name }}
|
||||
|
|
|
|||
2
.github/workflows/workflow-lint.yml
vendored
2
.github/workflows/workflow-lint.yml
vendored
|
|
@ -76,7 +76,7 @@ jobs:
|
|||
continue-on-error: true
|
||||
|
||||
- name: Upload SARIF
|
||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
sarif_file: zizmor.sarif
|
||||
category: zizmor
|
||||
|
|
|
|||
12
.gitleaks.toml
Normal file
12
.gitleaks.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
title = "GitNexus"
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
# Fake embedding API keys in unit tests (current probe + historical placeholder).
|
||||
[allowlist]
|
||||
description = "fake embedding API keys in http-embedder unit tests"
|
||||
regexes = [
|
||||
'''secret-key-12345''',
|
||||
'''test-api-key-redaction-check''',
|
||||
]
|
||||
|
|
@ -39,8 +39,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.
|
|||
## Reference docs
|
||||
|
||||
- **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)**
|
||||
- **Call-resolution DAG (legacy path):** See ARCHITECTURE.md § Call-Resolution DAG. Typed 6-stage DAG inside the `parse` phase; language-specific behavior behind `inferImplicitReceiver` / `selectDispatch` hooks on `LanguageProvider`. Shared code in `gitnexus/src/core/ingestion/` must not name languages. Types: `gitnexus/src/core/ingestion/call-types.ts`.
|
||||
- **Scope-resolution pipeline (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. Replaces the legacy DAG for languages in `MIGRATED_LANGUAGES` (see `registry-primary-flag.ts`). A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. CI parity gate runs BOTH paths per migrated language on every PR.
|
||||
- **Call & inheritance resolution (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. All languages resolve calls and inheritance through the scope-resolution pipeline (`Registry.lookup`, `preEmitInheritanceEdges`, `emitHeritageEdges`, `buildMro` → `MethodDispatchIndex`). **Shared code in `gitnexus/src/core/ingestion/` must not name languages** — plug language behavior in via `LanguageProvider` / `ScopeResolver` hooks. A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. (The legacy call-resolution DAG + `@heritage` capture path were removed in RING4-1 #942.)
|
||||
- **Cursor:** `.cursor/index.mdc` (always-on); `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` deprecated.
|
||||
- **GitNexus:** skills in `.claude/skills/gitnexus/`; MCP rules in `gitnexus:start` block below.
|
||||
|
||||
|
|
@ -77,7 +76,7 @@ commits, or posts.
|
|||
|
||||
This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
|
||||
|
||||
## Always Do
|
||||
|
||||
|
|
|
|||
120
ARCHITECTURE.md
120
ARCHITECTURE.md
|
|
@ -65,7 +65,7 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
|
|||
| Wiki generation | `src/core/wiki/` |
|
||||
| Language support | `src/core/ingestion/languages/` + `tree-sitter-queries.ts` + `gitnexus-shared/src/languages.ts` |
|
||||
| Import resolution | `src/core/ingestion/import-processor.ts` + `import-resolvers/configs/` + `model/resolution-context.ts` |
|
||||
| Call resolution/MRO | `src/core/ingestion/call-processor.ts` + `model/resolve.ts` |
|
||||
| Call resolution/inheritance/MRO | `src/core/ingestion/scope-resolution/` (pipeline, passes, graph-bridge) |
|
||||
| Type extraction | `src/core/ingestion/type-extractors/` |
|
||||
| Worker pool | `src/core/ingestion/workers/` |
|
||||
| Web UI | `gitnexus-web/src/` |
|
||||
|
|
@ -147,105 +147,18 @@ export const myPhase: PipelinePhase<MyPhaseOutput> = {
|
|||
|
||||
---
|
||||
|
||||
## Call-Resolution DAG
|
||||
## Semantic model
|
||||
|
||||
Typed 6-stage pipeline in `call-processor.ts` (inside the `parse` phase) that resolves method/function calls and emits CALLS edges. Language behavior plugs in at two `LanguageProvider` hook points (stages 3–4); shared code names no languages. Scope: call resolution only — import resolution, type extraction, heritage, and symbol-table population live in other phases.
|
||||
`SemanticModel` (`gitnexus/src/core/ingestion/model/semantic-model.ts`) is the authoritative store for every symbol-indexed lookup (by `nodeId`, `simpleName`, `qualifiedName`, or `filePath`). The scope-resolution pipeline reads from here: `findOwnedMember`, `pickOverload`, and `findExportedDefByName` all consult `model.methods` / `model.fields` / `model.symbols`.
|
||||
|
||||
### Stages
|
||||
|
||||
```
|
||||
extract-call ──▶ classify-form ──▶ infer-receiver ──▶ select-dispatch ──▶ resolve-target ──▶ emit-edge
|
||||
(1) (2) (3) [hook] (4) [hook] (5) (6)
|
||||
```
|
||||
|
||||
| Stage | Produces | Location |
|
||||
|-------|----------|----------|
|
||||
| **extract-call** | `ExtractedCallSite` (name, form, receiver, argCount) | `call-extractors/` (per-language); runs in worker |
|
||||
| **classify-form** | callForm (`free`/`member`/`constructor`) + arity | `call-analysis.ts` → `inferCallForm`; shared, runs in worker |
|
||||
| **infer-receiver** | `ReceiverEnriched` (receiver type finalized) | `call-processor.ts`; shared default chain, then `inferImplicitReceiver` hook |
|
||||
| **select-dispatch** | `DispatchDecision` (primary, fallback, ancestryView) | `selectDispatch` hook, falls back to shared default |
|
||||
| **resolve-target** | `TieredCandidates` | `model/resolve.ts` → `lookupMethodByOwnerWithMRO` (MRO walk) |
|
||||
| **emit-edge** | CALLS edge in graph | `call-processor.ts`; writes edge with confidence tier |
|
||||
|
||||
### Provider hooks
|
||||
|
||||
Both hooks are optional on `LanguageProvider`. Ruby is the only current implementer.
|
||||
|
||||
**`inferImplicitReceiver`** — called after shared infer-receiver defaults. Returns `ImplicitReceiverOverride | null`.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Inputs | `calledName`, `callForm`, `receiverName`, `receiverTypeName`, `callNode` (AST), `filePath` |
|
||||
| Non-null fields | `callForm`, `receiverName`, `receiverTypeName` (required); `receiverSource: 'implicit-self'` (fixed); `hint?` (opaque, passed to `selectDispatch`) |
|
||||
| Null | Keep existing `ReceiverEnriched` state |
|
||||
|
||||
**`selectDispatch`** — called after infer-receiver (including hook). Returns `DispatchDecision | null`; null uses shared default (constructor → `primary:'constructor'`; typed receiver → `primary:'owner-scoped'`; else → `primary:'free'`).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Inputs | `calledName`, `callForm`, `receiverName`, `receiverTypeName`, `receiverSource`, `hint` |
|
||||
| Non-null fields | `primary: 'owner-scoped' \| 'free' \| 'constructor'`; `fallback?: 'free-arity-narrowed'`; `ancestryView?: 'instance' \| 'singleton'`; `hint?` |
|
||||
|
||||
**`DispatchDecision` field semantics:**
|
||||
- `primary: 'owner-scoped'` — MRO walk from receiver's type; used when receiver type is known.
|
||||
- `fallback: 'free-arity-narrowed'` — after owner-scoped miss, search free-call candidates by arity only (Ruby uses this for implicit-self calls that miss their owner's MRO).
|
||||
- `ancestryView: 'singleton'` — walk singleton/class ancestry instead of instance ancestry (Ruby `def self.foo` bodies, so `extend`-ed methods are found).
|
||||
|
||||
### Adding language behavior
|
||||
|
||||
1. **Implicit receivers** — implement `inferImplicitReceiver`: return null if call already has a receiver; otherwise use `findEnclosingClassInfo` (`ast-helpers.ts`) to find the enclosing context, return `ImplicitReceiverOverride` with `receiverSource: 'implicit-self'`, and optionally set `hint` for `selectDispatch`.
|
||||
2. **Custom dispatch** — implement `selectDispatch`: inspect `receiverSource` and `hint`, return `DispatchDecision` with `primary`, optional `fallback`, optional `ancestryView`; return null to keep shared defaults.
|
||||
3. **MRO strategy** — confirm `mroStrategy` is `'first-wins'`, `'c3'`, `'ruby-mixin'`, or `'none'`; consumed by `lookupMethodByOwnerWithMRO`.
|
||||
|
||||
**Ruby example** (`languages/ruby.ts` + `utils/ruby-self-call.ts`): `inferImplicitReceiver` rewrites bare-identifier calls to `self.method` and sets `hint` to `'instance'`/`'singleton'`; `selectDispatch` uses hint for `ancestryView` and adds `fallback: 'free-arity-narrowed'` for implicit-self calls.
|
||||
|
||||
### Code references
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `core/ingestion/call-types.ts` | DAG types: `ReceiverEnriched`, `DispatchDecision`, `ImplicitReceiverOverride` |
|
||||
| `core/ingestion/language-provider.ts` | Hook signatures: `inferImplicitReceiver`, `selectDispatch` |
|
||||
| `core/ingestion/call-processor.ts` | `processCalls`: stages 3–6 |
|
||||
| `core/ingestion/model/resolve.ts` | `lookupMethodByOwnerWithMRO`: stage 5 MRO walk |
|
||||
| `core/ingestion/languages/ruby.ts` | Both hooks + `mroStrategy: 'ruby-mixin'` |
|
||||
| `core/ingestion/utils/ruby-self-call.ts` | Bare-call rewrite for `inferImplicitReceiver` |
|
||||
|
||||
### Coexistence with the scope-resolution pipeline
|
||||
|
||||
The Call-Resolution DAG is the **legacy path**. RFC #909 Ring 3 introduces a parallel **scope-resolution pipeline** (next section) that replaces stages 1–6 with a scope-indexed registry lookup. Both paths ship side-by-side and are gated per-language via `MIGRATED_LANGUAGES` + the `REGISTRY_PRIMARY_<LANG>` env var.
|
||||
|
||||
- **Unmigrated language** → Call-Resolution DAG runs; scope-resolution phase is a no-op.
|
||||
- **Migrated language** (currently: Python, C#) → scope-resolution owns CALLS/ACCESSES/USES emission; the legacy DAG gates off for that language via `isRegistryPrimary(lang)` checks in `call-processor.ts` and `import-processor.ts`.
|
||||
- `import-processor` still populates `importMap` for migrated languages — heritage's `ctx.resolve` reads it to disambiguate parent classes. Only edge emission is gated.
|
||||
- CI runs BOTH paths for every migrated language on every PR (`.github/workflows/ci-scope-parity.yml`); both must pass.
|
||||
|
||||
#### Same-graph guarantee
|
||||
|
||||
Edges emitted by the scope-resolution pipeline and edges emitted by the legacy DAG are indistinguishable to downstream consumers (MCP tools, HTTP API, embeddings, group bridge):
|
||||
|
||||
- **Node identity** — both paths use `generateId(...)` from `lib/utils.ts`, the same qualified-name keyspace, and the same node labels (`File`, `Folder`, `Class`, `Method`, `Function`, …). Overload disambiguation suffixes `parameterTypes` into the id consistently — see `scope-resolution/graph-bridge/ids.ts` and the legacy emitter in `call-processor.ts`.
|
||||
- **Edge vocabulary** — both paths emit the same reasons: `'import-resolved' | 'global' | 'local-call' | 'same-file' | 'interface-dispatch' | 'read' | 'write'`. Migrating a language must not change which reasons consumers see for previously-resolved edges.
|
||||
- **Confidence tier** — both paths attach a numeric `confidence` to each edge using the same scale.
|
||||
|
||||
The CI parity workflow (`.github/workflows/ci-scope-parity.yml`) runs both paths against every migrated language's fixture corpus and fails on any divergence.
|
||||
|
||||
#### Semantic-model source of truth
|
||||
|
||||
Two independent invariants.
|
||||
|
||||
**ParsedFile = the AST-level truth.** `ParsedFile` (`gitnexus-shared/src/scope-resolution/parsed-file.ts`) is the single per-file artifact both resolution paths consume. Scope-resolution passes MUST NOT build a parallel parse representation. If a per-language hook needs AST-level facts that `ParsedFile` doesn't expose, it should reuse the orchestrator's `treeCache` (`RunScopeResolutionInput.treeCache`) rather than re-invoking `parser.parse(...)` on its own — the C# `populateNamespaceSiblings` hook is the reference implementation of this pattern.
|
||||
|
||||
**SemanticModel = the symbol-level truth.** `SemanticModel` (`gitnexus/src/core/ingestion/model/semantic-model.ts`) is the authoritative store for every symbol-indexed lookup (by `nodeId`, `simpleName`, `qualifiedName`, or `filePath`). Both paths read from here:
|
||||
|
||||
- Legacy Call-Resolution DAG → `call-processor` Tier 1/2/3 via `model.symbols.lookupExactAll`, `model.methods.lookupMethodByName`, `model.types.lookupClassByName`, `lookupMethodByOwnerWithMRO`.
|
||||
- Scope-resolution pipeline → `findOwnedMember`, `pickOverload`, `findExportedDefByName` all consult `model.methods` / `model.fields` / `model.symbols`.
|
||||
`ParsedFile` (`gitnexus-shared/src/scope-resolution/parsed-file.ts`) is the single per-file artifact the scope-resolution pipeline consumes. Scope-resolution passes MUST NOT build a parallel parse representation. If a per-language hook needs AST-level facts that `ParsedFile` doesn't expose, it should reuse the orchestrator's `treeCache` (`RunScopeResolutionInput.treeCache`) rather than re-invoking `parser.parse(...)` on its own — the C# `populateNamespaceSiblings` hook is the reference implementation of this pattern.
|
||||
|
||||
The scope-resolution pipeline additionally carries `WorkspaceResolutionIndex` for `Scope`-valued lookups (`classScopeByDefId`, `moduleScopeByFile`) that `SemanticModel` structurally cannot hold. No symbol-indexed duplicates exist outside `SemanticModel`.
|
||||
|
||||
**Write / read phase contract.** The model is mutable during three ordered phases and read-only afterward:
|
||||
|
||||
```
|
||||
Phase 1: legacy parse ──► symbolTable.add fans into types/methods/fields
|
||||
Phase 1: parse ──► symbolTable.add fans into types/methods/fields
|
||||
Phase 2: scope-resolution ──► reconcileOwnership() registers corrected ownerIds
|
||||
Phase 3: finalize ──► model.attachScopeIndexes(bundle) — one-shot freeze
|
||||
─────────────────────────── phase boundary ───────────────────────────
|
||||
|
|
@ -255,7 +168,7 @@ The scope-resolution pipeline additionally carries `WorkspaceResolutionIndex` fo
|
|||
|
||||
`runScopeResolution` narrows `MutableSemanticModel` → `SemanticModel` at the phase boundary so downstream passes physically cannot mutate the model even accidentally.
|
||||
|
||||
**Transitional: reconciliation pass.** `reconcileOwnership` (`scope-resolution/pipeline/reconcile-ownership.ts`) is a shim for languages whose legacy extractor doesn't resolve `enclosingClassId` at parse time (Python class-body methods are the canonical case). It walks `parsed.localDefs[i].ownerId` after `populateOwners` and registers any missed methods/fields into the model. Idempotent — safe to re-run, safe alongside languages whose legacy extractor already carries `ownerId` (C#).
|
||||
**Reconciliation pass.** `reconcileOwnership` (`scope-resolution/pipeline/reconcile-ownership.ts`) is a shim for languages whose parse-time extractor doesn't resolve `enclosingClassId` at parse time (Python class-body methods are the canonical case). It walks `parsed.localDefs[i].ownerId` after `populateOwners` and registers any missed methods/fields into the model. Idempotent — safe to re-run, safe alongside languages whose extractor already carries `ownerId` (C#).
|
||||
|
||||
The architectural end state is for every language's parse-time extractor to emit the correct `ownerId` directly, making reconciliation a no-op (tracked as a follow-up refactor). The dev-mode validator `validateOwnershipParity` surfaces any drift via `onWarn` under `NODE_ENV !== 'production' && VALIDATE_SEMANTIC_MODEL !== '0'`.
|
||||
|
||||
|
|
@ -265,7 +178,7 @@ References: `semantic-model.ts` file-head (full write/read contract); `contract/
|
|||
|
||||
## Scope-Resolution Pipeline (RFC #909 Ring 3)
|
||||
|
||||
Language-agnostic registry-primary resolver. Replaces the Call-Resolution DAG for migrated languages. Adding a language is one interface implementation (`ScopeResolver`) plus two registrations — no changes to shared code, no new pipeline phase.
|
||||
Language-agnostic scope-resolution resolver. This is the resolution path for every language — it owns CALLS/ACCESSES/USES emission and inheritance edges. Adding a language is one interface implementation (`ScopeResolver`) plus one registration in the `SCOPE_RESOLVERS` map — no changes to shared code, no new pipeline phase. (RING4-1 #942 removed the legacy call-resolution DAG and the per-language `MIGRATED_LANGUAGES` flag, so `SCOPE_RESOLVERS` registration is all that's needed.)
|
||||
|
||||
### Pipeline stages
|
||||
|
||||
|
|
@ -286,7 +199,7 @@ Language-agnostic registry-primary resolver. Replaces the Call-Resolution DAG fo
|
|||
```
|
||||
|
||||
Orchestrator: `runScopeResolution(input, provider)` in `scope-resolution/pipeline/run.ts`.
|
||||
Pipeline phase: `scopeResolutionPhase` in `scope-resolution/pipeline/phase.ts` — iterates `SCOPE_RESOLVERS ∩ MIGRATED_LANGUAGES`, reads per-file Trees from the parse phase's `scopeTreeCache`, disposes the cache at the end.
|
||||
Pipeline phase: `scopeResolutionPhase` in `scope-resolution/pipeline/phase.ts` — iterates the registered `SCOPE_RESOLVERS`, reads per-file Trees from the parse phase's `scopeTreeCache`, disposes the cache at the end.
|
||||
|
||||
### `ScopeResolver` contract
|
||||
|
||||
|
|
@ -312,7 +225,6 @@ Single interface a language implements to plug into the pipeline. Contract fully
|
|||
|
||||
1. Implement `ScopeResolver` in `languages/<lang>/scope-resolver.ts`.
|
||||
2. Add entry to `SCOPE_RESOLVERS` in `scope-resolution/pipeline/registry.ts`.
|
||||
3. Add the language to `MIGRATED_LANGUAGES` in `registry-primary-flag.ts` when the shadow-harness corpus parity ≥ 99% fixtures / ≥ 98% corpus.
|
||||
|
||||
CI auto-discovers the set via `tsx`. No workflow edit required.
|
||||
|
||||
|
|
@ -328,7 +240,6 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
|
|||
| `scope-resolution/graph-bridge/*.ts` | CLI-local translation from resolved references → `KnowledgeGraph` edges |
|
||||
| `scope-resolution/scope/*.ts` | Generic scope-chain walkers + namespace targets |
|
||||
| `scope-resolution/workspace-index.ts` | Build-once O(1) lookup index |
|
||||
| `registry-primary-flag.ts` | `MIGRATED_LANGUAGES` set + `isRegistryPrimary(lang)` |
|
||||
| `languages/python/index.ts` | Python `ScopeResolver` hooks + known-limitation docs |
|
||||
| `languages/python/captures.ts` | `emitPythonScopeCaptures` (honors cross-phase Tree cache) |
|
||||
| `languages/csharp/index.ts` | C# `ScopeResolver` hooks + known-limitation docs |
|
||||
|
|
@ -351,7 +262,7 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
|
|||
```
|
||||
Unified Graph Schema (44 node types, 21 relationship types)
|
||||
↑
|
||||
Unified Resolution (3-tier name lookup + MRO walk)
|
||||
Scope-Resolution Pipeline (registry lookup + 3-tier import resolution + MRO)
|
||||
↑
|
||||
Language Providers (import semantics, type config, export checker, MRO strategy)
|
||||
↑
|
||||
|
|
@ -376,7 +287,7 @@ Each language implements `LanguageProvider` (`language-provider.ts`). Key fields
|
|||
|
||||
### Unified capture tags
|
||||
|
||||
Per-language tree-sitter queries use different AST node names but produce the **same semantic capture tags**: `@definition.class`, `@definition.function`, `@call.name`, `@import.source`, `@heritage.extends`. Downstream extraction needs no language branching. Defined in `tree-sitter-queries.ts`.
|
||||
Per-language tree-sitter queries use different AST node names but produce the **same semantic capture tags**: `@definition.class`, `@definition.function`, `@call.name`, `@import.source`, `@reference.inherits`. Downstream extraction needs no language branching. Defined in `tree-sitter-queries.ts`.
|
||||
|
||||
### Import resolution
|
||||
|
||||
|
|
@ -403,20 +314,21 @@ Unified 3-tier algorithm (`model/resolution-context.ts`), per-language `importSe
|
|||
1. Worker pool dispatches files (or sequential fallback via `skipWorkers`)
|
||||
2. Each worker: detect language → load grammar → run queries → return unified `ParseWorkerResult`
|
||||
3. Synthesize wildcard bindings (`wildcard-synthesis.ts`)
|
||||
4. Resolve imports and heritage
|
||||
4. Resolve imports
|
||||
5. Collect `BindingAccumulator` entries for cross-file propagation
|
||||
|
||||
Inheritance edges are emitted later, by the scope-resolution phase (`preEmitInheritanceEdges` + `emitHeritageEdges`), not during `parse`.
|
||||
|
||||
Workers: `workers/worker-pool.ts`, `workers/parse-worker.ts`.
|
||||
|
||||
### Heritage and MRO
|
||||
### Inheritance and MRO
|
||||
|
||||
All languages emit unified `ExtractedHeritage` (child, parent, `EXTENDS`/`IMPLEMENTS`). MRO phase walks the heritage graph using per-language strategy:
|
||||
Inheritance is captured by the `@reference.inherits` tag and emitted by the scope-resolution phase: `preEmitInheritanceEdges` resolves each base in scope, then `emitHeritageEdges` writes the `EXTENDS`/`IMPLEMENTS` edges. The phase then computes method resolution order via each `ScopeResolver`'s `buildMro` hook, feeding a `MethodDispatchIndex` used for owner-scoped lookups. Per-language strategy:
|
||||
- **`first-wins`** — Java, C#, C++, TS, Ruby, Go
|
||||
- **`c3`** — Python (C3 linearization)
|
||||
- **`ruby-mixin`** — Ruby (mixin-aware linearization)
|
||||
- **`none`** — single-inheritance languages
|
||||
|
||||
Unified walk: `lookupMethodByOwnerWithMRO()` in `model/resolve.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Full analysis flow
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g
|
|||
## Reference Documentation
|
||||
|
||||
- **This repository:** [AGENTS.md](AGENTS.md) (Cursor + monorepo notes), [ARCHITECTURE.md](ARCHITECTURE.md), [CONTRIBUTING.md](CONTRIBUTING.md), [GUARDRAILS.md](GUARDRAILS.md).
|
||||
- **Call-resolution DAG:** See ARCHITECTURE.md § Call-Resolution DAG. Shared pipeline code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` hooks instead (see AGENTS.md).
|
||||
- **Call & inheritance resolution:** See ARCHITECTURE.md § Scope-Resolution Pipeline. Shared pipeline code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` / `ScopeResolver` hooks instead (see AGENTS.md). (The legacy call-resolution DAG was removed in #942.)
|
||||
- **GitNexus:** `.claude/skills/gitnexus/`; MCP and indexed-repo rules live only in [AGENTS.md](AGENTS.md) (`gitnexus:start` … `gitnexus:end`). See **GitNexus rules** below.
|
||||
|
||||
## Changelog
|
||||
|
|
@ -58,7 +58,7 @@ See the `<!-- gitnexus:start --> … <!-- gitnexus:end -->` block in **[AGENTS.m
|
|||
|
||||
This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
|
||||
|
||||
## Always Do
|
||||
|
||||
|
|
|
|||
|
|
@ -13,11 +13,17 @@ This project uses the [PolyForm Noncommercial License 1.0.0](https://polyformpro
|
|||
|
||||
## Development setup
|
||||
|
||||
**Prerequisites:** Node.js — `gitnexus/` requires `>=22.0.0` and `gitnexus-web/` requires `^20.19.0 || >=22.12.0` (enforced via the `engines` field in each package). Use `nvm install` to match the local version.
|
||||
|
||||
1. Clone the repository.
|
||||
2. **CLI / MCP package:** `cd gitnexus && npm install && npm run build`
|
||||
3. **Web UI (if needed):** `cd gitnexus-web && npm install`
|
||||
4. Run tests as described in [TESTING.md](TESTING.md).
|
||||
|
||||
### Containerized development (optional)
|
||||
|
||||
If you prefer an isolated environment with Claude Code, OpenAI Codex CLI, and Cursor CLI pre-installed, open the repo in VS Code with the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) and run **Dev Containers: Reopen in Container**. See [`.devcontainer/README.md`](.devcontainer/README.md) for first-time auth flows and Windows WSL2 setup.
|
||||
|
||||
## Branch and pull requests
|
||||
|
||||
- Use short-lived branches off the default branch of the repo you are targeting.
|
||||
|
|
|
|||
70
README.md
70
README.md
|
|
@ -22,6 +22,9 @@
|
|||
<a href="https://securityscorecards.dev/viewer/?uri=github.com/abhigyanpatwari/GitNexus">
|
||||
<img src="https://api.securityscorecards.dev/projects/github.com/abhigyanpatwari/GitNexus/badge" alt="OpenSSF Scorecard"/>
|
||||
</a>
|
||||
<a href="https://github.com/abhigyanpatwari/GitNexus/actions/workflows/ci.yml">
|
||||
<img src="https://github.com/abhigyanpatwari/GitNexus/actions/workflows/ci.yml/badge.svg" alt="CI Workflows"/>
|
||||
</a>
|
||||
|
||||
<p><strong>Enterprise (SaaS & Self-hosted)</strong> - <a href="https://akonlabs.com">akonlabs.com</a></p>
|
||||
|
||||
|
|
@ -104,6 +107,14 @@ npx gitnexus analyze
|
|||
|
||||
That's it. This indexes the codebase, installs agent skills, registers Claude Code hooks, and creates `AGENTS.md` / `CLAUDE.md` context files — all in one command.
|
||||
|
||||
> **On npm 11.x?** `npx` can crash during install with `Cannot destructure property 'package' of 'node.target'` (an npm/arborist bug, before GitNexus runs). Use pnpm instead — it builds the native deps explicitly:
|
||||
>
|
||||
> ```bash
|
||||
> pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze
|
||||
> ```
|
||||
>
|
||||
> Or install globally (`npm install -g gitnexus@latest`) and run `gitnexus analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
|
||||
|
||||
To configure MCP for your editor, run `npx gitnexus setup` once — or set it up manually below.
|
||||
|
||||
> **Faster install (no C++ toolchain needed):** set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip vendored grammar materialize/build (`tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`). Dart/Proto/Swift files won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild.
|
||||
|
|
@ -218,8 +229,10 @@ gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS
|
|||
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
|
||||
gitnexus analyze --skip-embeddings # Skip embedding generation (faster)
|
||||
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
|
||||
gitnexus analyze --skip-skills # Skip installing .claude/skills/gitnexus/ skill files
|
||||
gitnexus analyze --default-branch develop # Branch used in the generated regression-compare example (base_ref)
|
||||
gitnexus analyze --skip-git # Index folders that are not Git repositories
|
||||
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
|
||||
gitnexus analyze --embeddings [limit] # Enable embedding generation (slower, better search)
|
||||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
|
||||
gitnexus analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
|
||||
|
|
@ -248,6 +261,53 @@ gitnexus group status <name> # Check staleness of repos in a group
|
|||
|
||||
If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `gitnexus analyze --worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget.
|
||||
|
||||
#### Embeddings node limit
|
||||
|
||||
`gitnexus analyze --embeddings` generates semantic search vectors with a default 50,000-node safety cap to protect memory on large repositories. Override the cap when you know the host has enough memory for a larger graph, or disable it entirely for a one-off full embeddings run.
|
||||
|
||||
```bash
|
||||
# Generate embeddings with the default 50,000 node safety cap
|
||||
gitnexus analyze --embeddings
|
||||
|
||||
# Disable the safety cap entirely
|
||||
gitnexus analyze --embeddings 0
|
||||
|
||||
# Use a custom cap
|
||||
gitnexus analyze --embeddings 100000
|
||||
```
|
||||
|
||||
If embeddings are skipped on a large repository, the indexed graph likely exceeds the default safety cap. Re-run with `gitnexus analyze --embeddings 0` to remove the cap, or `gitnexus analyze --embeddings <n>` to choose a higher limit while still keeping memory bounded.
|
||||
|
||||
#### Project config (`.gitnexusrc`)
|
||||
|
||||
Commit a `.gitnexusrc` JSON file at the repo root to preconfigure recurring `analyze` options per project, instead of re-passing the same flags every run. It is read from the resolved repo root (not `.gitnexus/`, which is gitignored index storage). **CLI flags always override `.gitnexusrc`.**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
// Default branch used in the generated regression-compare example (base_ref).
|
||||
// Use this so a project on `develop`/`master` doesn't get "main" rewritten
|
||||
// over its fix on every analyze. (Alias: "branch".)
|
||||
"defaultBranch": "develop",
|
||||
"skipContextFiles": true, // alias of skipAgentsMd: keep your own AGENTS.md/CLAUDE.md
|
||||
"skipSkills": true, // don't install .claude/skills/gitnexus/
|
||||
"embeddings": true, // generate embeddings by default
|
||||
"workerTimeout": 60
|
||||
}
|
||||
```
|
||||
|
||||
A nested `analyze` block is also accepted (and overrides flat keys for the same option):
|
||||
|
||||
```json
|
||||
{ "analyze": { "defaultBranch": "develop", "skipSkills": true } }
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- The default branch is resolved as: `--default-branch` > `.gitnexusrc` `defaultBranch`/`branch` > auto-detected `origin/HEAD` > `main`.
|
||||
- `skipContextFiles` / `skipAiContext` are aliases for `skipAgentsMd` — they skip the `AGENTS.md` / `CLAUDE.md` block only. They do **not** imply `skipSkills`. `indexOnly` is the stronger option that skips all file injection.
|
||||
- Supported keys: `defaultBranch` (`branch`), `skipAgentsMd` (`skipContextFiles`, `skipAiContext`), `skipSkills`, `indexOnly`, `stats`/`noStats`, `embeddings`, `dropEmbeddings`, `name`, `allowDuplicateName`, `maxFileSize`, `workerTimeout`, `walCheckpointThreshold`, `workers`, `embeddingThreads`, `embeddingBatchSize`, `embeddingSubBatchSize`, `embeddingDevice`.
|
||||
- The file is JSON only. Unknown keys and invalid values fail fast with an actionable error before analysis starts.
|
||||
|
||||
#### Environment variables
|
||||
|
||||
Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max-file-size`, `--verbose`). Use the env-var form when you'd otherwise repeat the same flag every run, or when invoking GitNexus from a long-running host (MCP server, eval-server, CI shell) that already manages its own environment. CLI flags take precedence over env vars; env vars take precedence over built-in defaults.
|
||||
|
|
@ -660,6 +720,14 @@ UPSTREAM (what depends on this):
|
|||
|
||||
Options: `maxDepth`, `minConfidence`, `relationTypes` (`CALLS`, `IMPORTS`, `EXTENDS`, `IMPLEMENTS`), `includeTests`, `limit` (max symbols per depth, default 100), `offset` (pagination start per depth), `summaryOnly` (counts and risk only, omits symbol list)
|
||||
|
||||
**Disambiguation** — when several symbols share the target name, `impact` returns a ranked `ambiguous` candidate list instead of guessing. Narrow it with `target_uid` (exact, zero-ambiguity), `file_path`, or `kind` (`Function`, `Class`, `Method`, …). From the CLI these are `--uid`, `--file`, and `--kind`, matching `gitnexus context`:
|
||||
|
||||
```bash
|
||||
gitnexus impact get_embeddings # → ambiguous: lists ranked candidates
|
||||
gitnexus impact get_embeddings --file src/embed.py # → resolves to the one in that file
|
||||
gitnexus impact get_embeddings --uid "Function:src/embed.py:get_embeddings" # exact
|
||||
```
|
||||
|
||||
### Process-Grouped Search
|
||||
|
||||
```
|
||||
|
|
|
|||
74
TESTING.md
74
TESTING.md
|
|
@ -4,11 +4,11 @@ How we structure tests and which commands to run locally and in CI.
|
|||
|
||||
## Packages
|
||||
|
||||
| Package | Path | Runner | Notes |
|
||||
| -------------- | -------------- | -------- | ------------------------------ |
|
||||
| CLI + MCP core | `gitnexus/` | Vitest | Primary test surface in CI |
|
||||
| Web UI | `gitnexus-web/`| Vitest | Unit/component tests |
|
||||
| Web UI E2E | `gitnexus-web/`| Playwright | Run when changing UI flows |
|
||||
| Package | Path | Runner | Notes |
|
||||
| -------------- | --------------- | ---------- | -------------------------- |
|
||||
| CLI + MCP core | `gitnexus/` | Vitest | Primary test surface in CI |
|
||||
| Web UI | `gitnexus-web/` | Vitest | Unit/component tests |
|
||||
| Web UI E2E | `gitnexus-web/` | Playwright | Run when changing UI flows |
|
||||
|
||||
## Test lanes
|
||||
|
||||
|
|
@ -16,25 +16,25 @@ How we structure tests and which commands to run locally and in CI.
|
|||
|
||||
From `gitnexus/`:
|
||||
|
||||
| Command | What it runs | When to use |
|
||||
| ------------------------ | ---------------------------------------------------- | ------------------------------- |
|
||||
| `npm test` | Full suite (all 3 vitest projects) | Before opening a PR |
|
||||
| `npm run test:unit` | Unit tests only (`test/unit/`) | Tight development loop |
|
||||
| `npm run test:integration` | Integration tests (`test/integration/`) | After changing pipelines, DB, workers |
|
||||
| `npm run test:coverage` | Full suite + v8 coverage with thresholds | Checking coverage impact |
|
||||
| `npm run test:parity` | Scope-resolution parity for all migrated languages | After changing resolver or scope code |
|
||||
| `npm run test:cross-platform` | Platform-sensitive subset only | Debugging a Windows/macOS issue |
|
||||
| `npm run test:watch` | Vitest in watch mode | Active development |
|
||||
| Command | What it runs | When to use |
|
||||
| ----------------------------- | -------------------------------------------------- | ------------------------------------- |
|
||||
| `npm test` | Full suite (all 3 vitest projects) | Before opening a PR |
|
||||
| `npm run test:unit` | Unit tests only (`test/unit/`) | Tight development loop |
|
||||
| `npm run test:integration` | Integration tests (`test/integration/`) | After changing pipelines, DB, workers |
|
||||
| `npm run test:coverage` | Full suite + v8 coverage with thresholds | Checking coverage impact |
|
||||
| `npm run test:parity` | Scope-resolution parity for all migrated languages | After changing resolver or scope code |
|
||||
| `npm run test:cross-platform` | Platform-sensitive subset only | Debugging a Windows/macOS issue |
|
||||
| `npm run test:watch` | Vitest in watch mode | Active development |
|
||||
|
||||
### `gitnexus-web/` commands
|
||||
|
||||
From `gitnexus-web/`:
|
||||
|
||||
| Command | What it runs | When to use |
|
||||
| ---------------------- | --------------------------------- | ------------------------------ |
|
||||
| `npm test` | Unit/component tests (vitest) | After changing web code |
|
||||
| `npm run test:coverage`| Unit tests + coverage | Checking coverage impact |
|
||||
| `npm run test:e2e` | Playwright browser tests | After changing UI flows (requires `gitnexus serve` + `npm run dev`) |
|
||||
| Command | What it runs | When to use |
|
||||
| ----------------------- | ----------------------------- | ------------------------------------------------------------------- |
|
||||
| `npm test` | Unit/component tests (vitest) | After changing web code |
|
||||
| `npm run test:coverage` | Unit tests + coverage | Checking coverage impact |
|
||||
| `npm run test:e2e` | Playwright browser tests | After changing UI flows (requires `gitnexus serve` + `npm run dev`) |
|
||||
|
||||
### Before opening a PR
|
||||
|
||||
|
|
@ -59,11 +59,11 @@ Skip with `git commit --no-verify` (use sparingly).
|
|||
|
||||
`gitnexus/vitest.config.ts` defines three projects for safety isolation:
|
||||
|
||||
| Project | Files | Parallelism | Purpose |
|
||||
| ---------- | ----------------------------- | ----------- | ---------------------------------------------- |
|
||||
| `lbug-db` | Native LadybugDB integration tests (explicit list) | Sequential | Prevents file-lock conflicts from native mmap addon |
|
||||
| `cli-e2e` | `skills-e2e.test.ts` | Sequential | CLI process spawning requires serial execution |
|
||||
| `default` | Everything else | Parallel | Fast execution for pure logic and parser tests |
|
||||
| Project | Files | Parallelism | Purpose |
|
||||
| --------- | -------------------------------------------------- | ----------- | --------------------------------------------------- |
|
||||
| `lbug-db` | Native LadybugDB integration tests (explicit list) | Sequential | Prevents file-lock conflicts from native mmap addon |
|
||||
| `cli-e2e` | `skills-e2e.test.ts` | Sequential | CLI process spawning requires serial execution |
|
||||
| `default` | Everything else | Parallel | Fast execution for pure logic and parser tests |
|
||||
|
||||
When adding a new test that uses native LadybugDB (`@ladybugdb/core`), add it to the `lbug-db` project's explicit include list and the `default` project's exclude list.
|
||||
|
||||
|
|
@ -74,21 +74,11 @@ When adding a new test that uses native LadybugDB (`@ladybugdb/core`), add it to
|
|||
- **Resolver / parity** — Language-specific call-resolution tests in `test/integration/resolvers/`.
|
||||
- **E2E (web)** — Critical user paths only; prefer `data-testid` attributes for stable selectors. Tests run against real backend (`gitnexus serve`) and Vite dev server.
|
||||
|
||||
## Scope-resolution parity
|
||||
## Scope-resolution tests
|
||||
|
||||
Migrated languages (listed in `MIGRATED_LANGUAGES` in `src/core/ingestion/registry-primary-flag.ts`) are tested in both legacy and registry-primary modes on every PR.
|
||||
Every language resolves calls and inheritance through the scope-resolution pipeline — the legacy call-resolution DAG and the per-language `REGISTRY_PRIMARY_<LANG>` flag were removed in RING4-1 (#942). Each language's resolver test lives at `test/integration/resolvers/<slug>.test.ts` and runs once, on the single scope-resolution path, as part of the normal `tests` job (`vitest test/**/*.test.ts`).
|
||||
|
||||
For each migrated language, CI runs the resolver test file twice:
|
||||
1. `REGISTRY_PRIMARY_<LANG>=0` — legacy DAG path
|
||||
2. `REGISTRY_PRIMARY_<LANG>=1` — registry-primary path
|
||||
|
||||
Both must pass. Known legacy gaps are listed in `LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES` in `test/integration/resolvers/helpers.ts` and are automatically skipped in legacy mode.
|
||||
|
||||
Adding a language to `MIGRATED_LANGUAGES` automatically enrolls it in parity — no workflow or config edit needed. The test file must exist at `test/integration/resolvers/<slug>.test.ts`.
|
||||
|
||||
Run parity locally: `cd gitnexus && npm run test:parity`
|
||||
|
||||
Run for a single language: `cd gitnexus && npx tsx scripts/run-parity.ts --language python`
|
||||
Adding a language: register its `ScopeResolver` in `scope-resolution/pipeline/registry.ts` (`SCOPE_RESOLVERS`) and add the resolver test file — no workflow or config edit needed.
|
||||
|
||||
## Cross-platform testing
|
||||
|
||||
|
|
@ -120,12 +110,12 @@ To check the cross-platform list is up to date, run `npm run test:cross-platform
|
|||
|
||||
GitHub Actions (`.github/workflows/ci.yml`) orchestrate:
|
||||
|
||||
| Workflow | Jobs | Purpose |
|
||||
| --------------------- | ------------------------------ | ------------------------------------------------ |
|
||||
| `ci-quality.yml` | format, lint, typecheck, typecheck-web, workflow-convention | Code quality gates |
|
||||
| Workflow | Jobs | Purpose |
|
||||
| --------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| `ci-quality.yml` | format, lint, typecheck, typecheck-web, workflow-convention | Code quality gates |
|
||||
| `ci-tests.yml` | ubuntu/coverage, cross-platform (Win/Mac), packaged-install-smoke | Full suite + coverage on Ubuntu; platform-sensitive subset on Win/Mac |
|
||||
| `ci-scope-parity.yml` | discover, parity | Scope-resolution parity for all migrated languages |
|
||||
| `ci-e2e.yml` | e2e (chromium) | Playwright E2E, gated on `gitnexus-web/**` changes |
|
||||
| `ci-scope-parity.yml` | discover, parity | Scope-resolution parity for all migrated languages |
|
||||
| `ci-e2e.yml` | e2e (chromium) | Playwright E2E, gated on `gitnexus-web/**` changes |
|
||||
|
||||
The `CI Gate` job in `ci.yml` is the single required check for branch protection. It requires quality, tests, e2e, and scope-parity to all pass.
|
||||
|
||||
|
|
|
|||
196
eval/uv.lock
generated
196
eval/uv.lock
generated
|
|
@ -21,7 +21,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.13.4"
|
||||
version = "3.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohappyeyeballs" },
|
||||
|
|
@ -30,95 +30,111 @@ dependencies = [
|
|||
{ name = "frozenlist" },
|
||||
{ name = "multidict" },
|
||||
{ name = "propcache" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/ab/93ce242f899b68c51b0578c027aafa791ab3614cb9345fa5d37b5f5c8e3e/aiohttp-3.14.0.tar.gz", hash = "sha256:2882de819734c715fd1b9c11c97e09fa020d14438203d1d354d8ed1702791c9b", size = 7940674, upload-time = "2026-06-01T19:41:02.763Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/7e/cb94129302d78c46662b47f9897d642fd0b33bdfef4b73b20c6ced35aa4c/aiohttp-3.13.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8ea0c64d1bcbf201b285c2246c51a0c035ba3bbd306640007bc5844a3b4658c1", size = 760027, upload-time = "2026-03-28T17:15:33.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/cd/2db3c9397c3bd24216b203dd739945b04f8b87bb036c640da7ddb63c75ef/aiohttp-3.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6f742e1fa45c0ed522b00ede565e18f97e4cf8d1883a712ac42d0339dfb0cce7", size = 508325, upload-time = "2026-03-28T17:15:34.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/a3/d28b2722ec13107f2e37a86b8a169897308bab6a3b9e071ecead9d67bd9b/aiohttp-3.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dcfb50ee25b3b7a1222a9123be1f9f89e56e67636b561441f0b304e25aaef8f", size = 502402, upload-time = "2026-03-28T17:15:36.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/d6/acd47b5f17c4430e555590990a4746efbcb2079909bb865516892bf85f37/aiohttp-3.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3262386c4ff370849863ea93b9ea60fd59c6cf56bf8f93beac625cf4d677c04d", size = 1771224, upload-time = "2026-03-28T17:15:38.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/af/af6e20113ba6a48fd1cd9e5832c4851e7613ef50c7619acdaee6ec5f1aff/aiohttp-3.13.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:473bb5aa4218dd254e9ae4834f20e31f5a0083064ac0136a01a62ddbae2eaa42", size = 1731530, upload-time = "2026-03-28T17:15:39.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/16/78a2f5d9c124ad05d5ce59a9af94214b6466c3491a25fb70760e98e9f762/aiohttp-3.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56423766399b4c77b965f6aaab6c9546617b8994a956821cc507d00b91d978c", size = 1827925, upload-time = "2026-03-28T17:15:41.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/1f/79acf0974ced805e0e70027389fccbb7d728e6f30fcac725fb1071e63075/aiohttp-3.13.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8af249343fafd5ad90366a16d230fc265cf1149f26075dc9fe93cfd7c7173942", size = 1923579, upload-time = "2026-03-28T17:15:44.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/53/29f9e2054ea6900413f3b4c3eb9d8331f60678ec855f13ba8714c47fd48d/aiohttp-3.13.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc0a5cf4f10ef5a2c94fdde488734b582a3a7a000b131263e27c9295bd682d9", size = 1767655, upload-time = "2026-03-28T17:15:45.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/57/462fe1d3da08109ba4aa8590e7aed57c059af2a7e80ec21f4bac5cfe1094/aiohttp-3.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c7ff1028e3c9fc5123a865ce17df1cb6424d180c503b8517afbe89aa566e6be", size = 1630439, upload-time = "2026-03-28T17:15:48.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/4b/4813344aacdb8127263e3eec343d24e973421143826364fa9fc847f6283f/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ba5cf98b5dcb9bddd857da6713a503fa6d341043258ca823f0f5ab7ab4a94ee8", size = 1745557, upload-time = "2026-03-28T17:15:50.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/01/1ef1adae1454341ec50a789f03cfafe4c4ac9c003f6a64515ecd32fe4210/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d85965d3ba21ee4999e83e992fecb86c4614d6920e40705501c0a1f80a583c12", size = 1741796, upload-time = "2026-03-28T17:15:52.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/04/8cdd99af988d2aa6922714d957d21383c559835cbd43fbf5a47ddf2e0f05/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:49f0b18a9b05d79f6f37ddd567695943fcefb834ef480f17a4211987302b2dc7", size = 1805312, upload-time = "2026-03-28T17:15:54.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/7f/b48d5577338d4b25bbdbae35c75dbfd0493cb8886dc586fbfb2e90862239/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7f78cb080c86fbf765920e5f1ef35af3f24ec4314d6675d0a21eaf41f6f2679c", size = 1621751, upload-time = "2026-03-28T17:15:56.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/89/4eecad8c1858e6d0893c05929e22343e0ebe3aec29a8a399c65c3cc38311/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67a3ec705534a614b68bbf1c70efa777a21c3da3895d1c44510a41f5a7ae0453", size = 1826073, upload-time = "2026-03-28T17:15:58.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/5c/9dc8293ed31b46c39c9c513ac7ca152b3c3d38e0ea111a530ad12001b827/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6630ec917e85c5356b2295744c8a97d40f007f96a1c76bf1928dc2e27465393", size = 1760083, upload-time = "2026-03-28T17:16:00.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/19/8bbf6a4994205d96831f97b7d21a0feed120136e6267b5b22d229c6dc4dc/aiohttp-3.13.4-cp311-cp311-win32.whl", hash = "sha256:54049021bc626f53a5394c29e8c444f726ee5a14b6e89e0ad118315b1f90f5e3", size = 439690, upload-time = "2026-03-28T17:16:02.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/f5/ac409ecd1007528d15c3e8c3a57d34f334c70d76cfb7128a28cffdebd4c1/aiohttp-3.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:c033f2bc964156030772d31cbf7e5defea181238ce1f87b9455b786de7d30145", size = 463824, upload-time = "2026-03-28T17:16:05.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/bd/ede278648914cabbabfdf95e436679b5d4156e417896a9b9f4587169e376/aiohttp-3.13.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360", size = 752158, upload-time = "2026-03-28T17:16:06.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/de/581c053253c07b480b03785196ca5335e3c606a37dc73e95f6527f1591fe/aiohttp-3.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d", size = 501037, upload-time = "2026-03-28T17:16:08.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/f9/a5ede193c08f13cc42c0a5b50d1e246ecee9115e4cf6e900d8dbd8fd6acb/aiohttp-3.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c", size = 501556, upload-time = "2026-03-28T17:16:10.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/10/88ff67cd48a6ec36335b63a640abe86135791544863e0cfe1f065d6cef7a/aiohttp-3.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97", size = 1757314, upload-time = "2026-03-28T17:16:12.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/15/fdb90a5cf5a1f52845c276e76298c75fbbcc0ac2b4a86551906d54529965/aiohttp-3.13.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576", size = 1731819, upload-time = "2026-03-28T17:16:14.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/df/28146785a007f7820416be05d4f28cc207493efd1e8c6c1068e9bdc29198/aiohttp-3.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab", size = 1793279, upload-time = "2026-03-28T17:16:16.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/47/689c743abf62ea7a77774d5722f220e2c912a77d65d368b884d9779ef41b/aiohttp-3.13.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d", size = 1891082, upload-time = "2026-03-28T17:16:18.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/b6/f7f4f318c7e58c23b761c9b13b9a3c9b394e0f9d5d76fbc6622fa98509f6/aiohttp-3.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e", size = 1773938, upload-time = "2026-03-28T17:16:21.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/06/f207cb3121852c989586a6fc16ff854c4fcc8651b86c5d3bd1fc83057650/aiohttp-3.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3", size = 1579548, upload-time = "2026-03-28T17:16:23.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/58/e1289661a32161e24c1fe479711d783067210d266842523752869cc1d9c2/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83", size = 1714669, upload-time = "2026-03-28T17:16:25.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/0a/3e86d039438a74a86e6a948a9119b22540bae037d6ba317a042ae3c22711/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763", size = 1754175, upload-time = "2026-03-28T17:16:28.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/30/e717fc5df83133ba467a560b6d8ef20197037b4bb5d7075b90037de1018e/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9", size = 1762049, upload-time = "2026-03-28T17:16:30.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/28/8f7a2d4492e336e40005151bdd94baf344880a4707573378579f833a64c1/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758", size = 1570861, upload-time = "2026-03-28T17:16:32.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/45/12e1a3d0645968b1c38de4b23fdf270b8637735ea057d4f84482ff918ad9/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9", size = 1790003, upload-time = "2026-03-28T17:16:35.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/0f/60374e18d590de16dcb39d6ff62f39c096c1b958e6f37727b5870026ea30/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d", size = 1737289, upload-time = "2026-03-28T17:16:38.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/bf/535e58d886cfbc40a8b0013c974afad24ef7632d645bca0b678b70033a60/aiohttp-3.13.4-cp312-cp312-win32.whl", hash = "sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791", size = 434185, upload-time = "2026-03-28T17:16:40.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/1a/d92e3325134ebfff6f4069f270d3aac770d63320bd1fcd0eca023e74d9a8/aiohttp-3.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77", size = 461285, upload-time = "2026-03-28T17:16:42.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/47/7727bfe8db93f8835a001bd4359d8480cc68d1259b8bce334668f8be97bd/aiohttp-3.14.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:54bf3522d6f7351e55f89a62d5c2bf138ad557b031670266c5df604ae88e0b5a", size = 759147, upload-time = "2026-06-01T19:37:12.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/f2/cd3fedff6fade73d71df9ec908c210cec518ef90fd00289250684b90aecf/aiohttp-3.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0746d9fb0ac4fdef643a84494efe3f06d50335dd8c7a530228b86448aae0a803", size = 513705, upload-time = "2026-06-01T19:37:14.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/fe/49746b6b610144a06323bebd8e1211a390310d8c69b98dd6d52df341bc3e/aiohttp-3.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f3a96b6d39a4872222beee72e1df41d2ff886ae96152cf3e757ef8c5673ef0e", size = 509627, upload-time = "2026-06-01T19:37:16.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/3f/28f2f6cf3d5c0e7b01b27140d0e7873fd11fb341169ad3ce78ad04aba628/aiohttp-3.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d336820adbb914debbc90a1d8c1bfc4bea55996aecf64866a989d35d1f9fd903", size = 1769293, upload-time = "2026-06-01T19:37:18.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/6f/2e5f1b525d5474b12b3c60abf733a755845f3bceff21542081ada515f837/aiohttp-3.14.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:71b2604c9bfc1b115547d63a094d5244b3f02799833513a99a68aaa7b167c4cb", size = 1732363, upload-time = "2026-06-01T19:37:20.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/ce/596120faa85ca7b19cd061e3f2f3be23aa8f11a0aedf9191db9e0da1bd76/aiohttp-3.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:610d68800435903e303ca0542b9d3e4eb72a12ff33a6d471a070c1d81eebd3c2", size = 1840375, upload-time = "2026-06-01T19:37:22.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/3c/a7ffe05a757a4a7867643da69357ec41f506879fbd1b231d2ed90af246b2/aiohttp-3.14.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:514db9a79337068981ee2137310283a07b4b885c584991097a91a4da419bcb81", size = 1921484, upload-time = "2026-06-01T19:37:24.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/fa/2c861170bbd4a491de93a69e081db1d971092569e0d593a98ef62c384dc1/aiohttp-3.14.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c452d17eeb95d563fc8b936f3050301dbd1d268126c4632d8b70ede9696202ee", size = 1774153, upload-time = "2026-06-01T19:37:26.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/da/1d2f5a165f47ec9b1f69d37b8b977fdc4d501aa72ffb7930db27bb9e49ea/aiohttp-3.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed94a81506e3d1bdbad5108f497a58f2a2354aedb4ca314d5326f07d1fd1ac2d", size = 1632569, upload-time = "2026-06-01T19:37:28.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/1d/7a6e295c4257252f70f69e90864fdad74b6a1293054fb3f9e65a15de6d63/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1394dce36e0f0d260ac0b555a654de19cb989f3c1b8bdd24f505314dfea18a00", size = 1740325, upload-time = "2026-06-01T19:37:30.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/7e/e1899b1ca3ec62f1eab2a5cbde14039b97493f7f53eb88d9b668562ffa8d/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d1467d1e7b48a73ca7237e0ee4335f3d02b923dbc27b82fd254bc301c97d4026", size = 1748691, upload-time = "2026-06-01T19:37:32.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/54/4e6b61c1fe7d3433f82bcc6bd7e4d7c683a742a10c9b12a025fd3695c047/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6a5f3532125233c261cf61f32df4059cfcf482eb793c7d3db8452e3142028b86", size = 1814477, upload-time = "2026-06-01T19:37:34.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/38/86fd51be2e08d8e45c83d879d255f10391903cd9fe2a16512f7591a15873/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3ea81eb518a2ecb319d8ec6d1424a37c773f6634bd87d6985eb606b2faac419f", size = 1623393, upload-time = "2026-06-01T19:37:36.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/49/466e947a42a88ee23c486d036e7e5d1b097f1bafd8084ad9c9a0a92f0f43/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:32e735c3182de7b64f6941a4ede48b38c7f47d9437bd615dd30b5bda8fa1bc93", size = 1824097, upload-time = "2026-06-01T19:37:38.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/89/35f3410bc284682338a1be6b6ea0c5abfa05f063942cfaa9256608440434/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c21ca9a1c63d4509158f478aeb9d02914dcc52adc68d1bc9dee2452284ee5996", size = 1764790, upload-time = "2026-06-01T19:37:40.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/80/2d4291bd5724d3d17e5951aff5a3e02281483fb47295f0788276ee66cd73/aiohttp-3.14.0-cp311-cp311-win32.whl", hash = "sha256:19ca5fc84130675ba11c6ca5c7da5cb65f7bf8a32cdd2b616bf49cd334688aae", size = 454176, upload-time = "2026-06-01T19:37:42.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/ed/41d0ad4f6ececffc32bdf1f7b494e5498f7ca5c849ea2e3cc9bbd1668251/aiohttp-3.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:d488e6e9d3bb8ba5ae7066d5be885ae9670eba021b8c6ccb9a3a568e6b19d6e5", size = 479334, upload-time = "2026-06-01T19:37:44.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/86/c0b5e305c770053f8c3d069bb52b8196917ba91949d1962d52eb307fb0d2/aiohttp-3.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:8b93618102caf12801638a01a2b478a55410ddd71bd41cfaf6f707953a49ac43", size = 450262, upload-time = "2026-06-01T19:37:46.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/97/2b6889bfb6b6847520d50d95eb8c4307a45e28aaca39faf4a9454b3d1b2f/aiohttp-3.14.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b29518c9c2ec7e373e68259206a137c7f4f5439c58baaec4b5ab3ab799850a4e", size = 750194, upload-time = "2026-06-01T19:37:48.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/e2/62634b7fff918ed98c3c6b2f0e70d520f7f28846cb412d451b04354c6459/aiohttp-3.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbec68ce61b64cb73cab4d33df9433427b1713c8bcccb181dce695c1b6f8e87c", size = 506966, upload-time = "2026-06-01T19:37:50.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/fb/5ce075150828c797a5106f1c2fb26034e709d4289b9d2bf8b07f1e59fac6/aiohttp-3.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cdf534aa455593e589302990c5097aa5c92c06c4262a20da22934f9186a5fff", size = 507527, upload-time = "2026-06-01T19:37:51.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/d5/405a0ae4e6b081754a3609c1c97c63a950e000a2def16046f1e736933a0e/aiohttp-3.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb6c657104393b5fbff01a5f59b2023db74058a8077d94475d6c25d03882a108", size = 1762420, upload-time = "2026-06-01T19:37:53.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/1d/e05a7c896b15a6bc6fb8fc5319eb437861c2c49c34559ef928add6590315/aiohttp-3.14.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:46fbbec4e4fab7428d4396a3823f9320e4560aa3113b89eeebce712c27c9ed5a", size = 1733672, upload-time = "2026-06-01T19:37:55.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/22/a72f7c459e195fa41bf4f7abd1f925b91fe91f8097e51c654229ba144a33/aiohttp-3.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c2c7e05dd5335b298085abf45ddf98673934c3ee1c083d0b9ea13d4186ad500", size = 1805064, upload-time = "2026-06-01T19:37:57.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/50/e85bdaba0be59ca4838005ebfef4048fcdd5f35a02b07057a9a123394440/aiohttp-3.14.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c7139100fbaae76515b73051d8f0aa3a3ff02e415eec8a8eee8e2223d9ba955", size = 1902125, upload-time = "2026-06-01T19:38:00.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/d8/51de5c6b971c27bb1ef620293b8d1ca611ec78736b34b3f6ccf68e4c8785/aiohttp-3.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d6f9286a629ce52728430afe18f8ed2b6c39a1fddb3802d7244b9983910ad2", size = 1783112, upload-time = "2026-06-01T19:38:02.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/ae/b4402bfde77e43dfb1b6ccff83c7b7ab63ed06b50c4754f0c5423fb374fe/aiohttp-3.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3c3e12cdaeb92d7dcf13db00e9f6b1956b910e47256e696df1cfa946d02159", size = 1586356, upload-time = "2026-06-01T19:38:04.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/05/750a3265ca4dc54a460bd0cb1121a8f2ce9171fce4a135fb47ea7fd594d2/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4d6a998191f5ebe3b8c28463ff72bc030250008b3193c402464efadd08b5ca02", size = 1723119, upload-time = "2026-06-01T19:38:06.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/01/8c0812c50b3b1b1c37b323bf170d6be8847a8f234060485b7d1e71953f60/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0fc2b75ae8d169d853be2862d960be8550da6c5c65711d5476407eb3fdb006bd", size = 1757216, upload-time = "2026-06-01T19:38:08.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/2a/50fb98028a26887cbe48dcc1df92a90825615bc73b5584301304090cded8/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:16eee56bcc72d04600bc56c1759982c2385ec0b41d3fd3521f836bf64a0957ef", size = 1770500, upload-time = "2026-06-01T19:38:11.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/32/0ffd598a2fa2b9a423daf242e700cfdabda35d6e602394ad9ae58972c1c7/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5a2e7ca615c3ddc15b82687e05a624e5f5cba3f1d6c20cb81172d70ea498451e", size = 1576224, upload-time = "2026-06-01T19:38:13.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/f9/b9fc381dd9b66afb33f2634c40e229d106467be0afcabe79648631ab6712/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f0b7b8bbbec3ce9467ee0ebe334622fd90624f593edd3136c567811453fc4fae", size = 1794252, upload-time = "2026-06-01T19:38:15.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/fb/05d9214c975f23225a8cd5c439325e338c7c377b315480ef3871db51f54e/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ba10966d4f03dd96a14365be4b8e37c327c76f11c3ca867116966cdd9f98066", size = 1760193, upload-time = "2026-06-01T19:38:17.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/4b/02992fc4fb9e1b6673ee3f888a8e587a6447afda1f6f4aca776c148c2876/aiohttp-3.14.0-cp312-cp312-win32.whl", hash = "sha256:101df7779c80c0636014a6b2c6642acd3efb5b355d48347c9d7dfb720aee9430", size = 448650, upload-time = "2026-06-01T19:38:19.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/e9/246532214c3abda518477cbaaf16d420295ad8effa5233844cbb38f299ab/aiohttp-3.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:b0a5747586d4467efd1f932710b269131c9717a872dce082cd92a00c1c13123a", size = 476145, upload-time = "2026-06-01T19:38:21.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/c3/63f8c20090048915711598b0adf475b149216d736157961de06480a45b15/aiohttp-3.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:5f1c5be60add78fabb4aacd13c5a348ae79d2fcbfc7fa78da8f1eb192273b370", size = 444250, upload-time = "2026-06-01T19:38:24.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/61/d11f7d9a3144bffe825247d6367cd93053666da50b94707c9129c78868d5/aiohttp-3.14.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:25400d710641a8040bf022a8a99f579e581ffa1c5bd42c33255d7d6f3957c127", size = 502399, upload-time = "2026-06-01T19:38:25.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/9b/a7e317625d36356844f8bb022cabd305b541f968856cc3c2e0b58e53ee6e/aiohttp-3.14.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:c5492b9929826e07cc3fcb9739ae87aab05dff6b5e67a9b73fd1700c6d008981", size = 510068, upload-time = "2026-06-01T19:38:27.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/41/cc2d2cfbfbdc3126ba258f3cd27d1ac8a33492ae3c35a4583ee21f0ba7f1/aiohttp-3.14.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3366751d68d237c621264233a32f3078bbc21b7904ab90a77e03d21390c742c6", size = 481670, upload-time = "2026-06-01T19:38:29.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/07/381f4023c3b08cb616e520f566d8c58957abad54e56441d41fe67cfb0195/aiohttp-3.14.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:57ea07d28695a7a40304d42251892a8df765e5588c10ee32afeddcd5df33c0a2", size = 487591, upload-time = "2026-06-01T19:38:31.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/4d/4506fdb7a022bdf70011a3bbb4ca00c5c570026ef6a3c5bd7bc70c39089c/aiohttp-3.14.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:076cb014191ae2e65d949e1ad01f1dcfe33e32789b5172510f3e79c79fc04d50", size = 496503, upload-time = "2026-06-01T19:38:33.6Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/7d/c814111e04894a45d9e2defc94443879a6f118d9633d5fedfe6e2e8af5f0/aiohttp-3.14.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2f3fc37054564dee64a855b5b092d87ec35dcddfaabf7dacb1c8a2b1f83dc0a9", size = 745870, upload-time = "2026-06-01T19:38:36.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/ee/80eee0efddfe187e7cd05027086b7ce1c0e492e82a4eda58f5c5543a44a0/aiohttp-3.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8fcaef74d2ab0f607d7ff85a0d15e21bb5a258c4a58df1908396eb50d7f4ed3c", size = 505588, upload-time = "2026-06-01T19:38:38.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f8/0f28f04eef75d52fc9c715dde7ce9c0abb810fd20cfeb0fea7afd2ab1e98/aiohttp-3.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e4c01b0bfc6209590960e68eac083cd22d5d87c21f974dd6208cafa5d3542bc8", size = 504492, upload-time = "2026-06-01T19:38:40.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/db/44c755232085545065c94378dfce38641b1aee647f4939fcd32f5b32e719/aiohttp-3.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f12eb7896e81caf403a2b18c9406426f1207361e7239c057ab29c076d4257e83", size = 1752111, upload-time = "2026-06-01T19:38:42.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/6a/42e030a46743841414402a3b00cd3d78419055e86c66fb5822c14b5abfc6/aiohttp-3.14.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6c79a044cacf360ec46738d863d2f41c9300d2a06ef4a7402ea0df306a350e61", size = 1729674, upload-time = "2026-06-01T19:38:44.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/26/3199beb415202e3108e7b83ecebe10914d806d33fb9860c3e4aa60a19be3/aiohttp-3.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85e0675f47be4eff0636bf88c02140ea89168ae0df3ff1f3f464e9de9610d277", size = 1798808, upload-time = "2026-06-01T19:38:47.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/94/b9b6fcf0ee17c21d0d19fb8c22bf83ad18f82e702a9c3bd901a868f5e446/aiohttp-3.14.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b33e751cab03fdc960095b1e326cb5a03f5ee577d6ded59f3d1c100f8668882", size = 1891921, upload-time = "2026-06-01T19:38:49.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/a3/3800dbd095cb2bb165a7ea5d94d790914677e27f45638c7d80e3f34c8945/aiohttp-3.14.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26d9224c6dd7f5c749aba4f61315a894601448b28d94d12f4dea0903e26d2096", size = 1777241, upload-time = "2026-06-01T19:38:52.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/2a/45be91ad1b860508557448d4cc2e165a2ee68dd865657b73bf66cc5a00fb/aiohttp-3.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6281aecdf2732940f4fe06bd6adec5ae4d59b78b080b8e3a6b81467301010988", size = 1579554, upload-time = "2026-06-01T19:38:54.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/3d/dc94df99ed1511fdf28314f722643ed334112643cab00223577085e788c4/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:23e8314e7aed8576fbe33314d218bd81447a3adbc91dc36f1163bf583cd3084c", size = 1714864, upload-time = "2026-06-01T19:38:56.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/e4/1f1c8acbb3acd5c8f795473b92c9c3d44eb60a5692c6104256c8a1c83a0c/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3b54fbff46127aeafdd764cecd0d99fa2f24a0e37ea5c18a7c3a4ac450df1db3", size = 1749803, upload-time = "2026-06-01T19:38:59.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/c8/c45ea6e7ed84cebba939b9c334498a045ba19d79c61b0110df5f21580de3/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b27d89af91a555f58e08e4902dbcbc48862fd40095720ca705990476bd93b7ac", size = 1765023, upload-time = "2026-06-01T19:39:01.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/a1/a932941784432962fe390e1066823aaef64b4e5ac9fa595df57b5fe472a9/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:25d2326a4967bf705a9f9913a13005e93b6020ad8a9f6bd6bd78850d5171332e", size = 1571671, upload-time = "2026-06-01T19:39:04.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/01/e1280feac522597a4d46eb67a0cdfa053cfae263033030b761ab146f29fb/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a1d209375c503472b3c0a340cdf3c55fcd82e84b46dda7caeaced59faba373ec", size = 1789904, upload-time = "2026-06-01T19:39:06.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/10/ab28818262f4d26bdb47ed5f1fc7999b69e2fc6e0370b02d0f49011f45ea/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:666c7c5036df57b693026398b69b41874a1931ac5b3485fd910e57bfac253869", size = 1754516, upload-time = "2026-06-01T19:39:08.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/cc/c122eabd7a1b7e0c9bbdd6be60e4715905b858399145d9df872bb94f1427/aiohttp-3.14.0-cp313-cp313-win32.whl", hash = "sha256:23f094a1ef64823fd35854ddf5c7a80a078162f37f9d2f7c6142b51a6affa456", size = 448656, upload-time = "2026-06-01T19:39:11.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/a5/bab07d79848a00eedd8ed979ccb302aaea3ac6eb9fa16bd0ed87135869b4/aiohttp-3.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:e03abdaa17d553f17e1d1d06bb266b3970106c78051d06795723e748d8e49d11", size = 475803, upload-time = "2026-06-01T19:39:13.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a0/f03ade8566c153666a3871afccbedf6d99911da006325e1fc6cf72a2de99/aiohttp-3.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:acdb400538cf4769543548bb5d1eb23d39bed4f96554a6078cb728c7cb2c268b", size = 443889, upload-time = "2026-06-01T19:39:15.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/03/5f36ab196a88ba5e9648ae5643e6531e67a3a8c0e96f9c6510ff41540fec/aiohttp-3.14.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:363ef9e91014e7891679bfb2ac0a7c6ea93435dbbfd10ecf41b9f06fcf506c5f", size = 503330, upload-time = "2026-06-01T19:39:18.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ce/8b49ec2f30f68e02f314f4832186cd45e583360a5a386058be36855d23b6/aiohttp-3.14.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:884a4edbdad77be9d0ef36142c8b504351b170df0bf62b51e784fadabf311c42", size = 509822, upload-time = "2026-06-01T19:39:20.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/fe/6edbf5d39bf29322b6816365b17ed8ede4dace164a3aea1abcd30110eb78/aiohttp-3.14.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:70ea956f6cc4a37620966b56c2e205d88ca3e6d85ec063277e414b1035cddad3", size = 483329, upload-time = "2026-06-01T19:39:22.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/5a/fae531bdbc6456fb6241f46b7b81e4d8a0dd3fc09118a0055dc7141ac1ec/aiohttp-3.14.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:ea3b9806c89f61da22fddf1f12dd524fb368e5e28f1261fbdafe5c3cd8ce893b", size = 489502, upload-time = "2026-06-01T19:39:24.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/f4/48a7b0414db7fed77a03d5dde34508c026afd83510ab6bca08c313855776/aiohttp-3.14.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a071be341c2bd9b0188e62d173509f024e0a35b1c342c53c50f8daaeda8c3bd8", size = 497357, upload-time = "2026-06-01T19:39:27.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/75/e85a13a370acc007fca5feb1fd1b88ac2d8426e6dadd625479b7cadd55a3/aiohttp-3.14.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:198cfe61bf253b19da1fb3e0fa122249dc4f14c12709493fed8054aa0411cc76", size = 750898, upload-time = "2026-06-01T19:39:29.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/e4/3d637f800c724eff0e2bed64df72557444482366fd0a35b0cec0e6968f6c/aiohttp-3.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc203d6ce6b9106d54e2a93f41dfdfebfbca2d99962ba503bfd3e5921a6549e", size = 506986, upload-time = "2026-06-01T19:39:31.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/df/35161f3598bf7501d2b2a805b41ab4f45a2e34150c421bcb4ef8c0d281a7/aiohttp-3.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e19d17ab02bf16832a2c8c0d55a486792c5b1645665652ee9531aebcc30cb72", size = 508033, upload-time = "2026-06-01T19:39:34.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/39/b36e5d3d31e850fb4691dd3e941684ac490a2559249f6fa634b6b0fdf020/aiohttp-3.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d925fba0c14d5b498a8028b0107beebdfd16c5d48d702ff54f879cb017aaaca3", size = 1746213, upload-time = "2026-06-01T19:39:36.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/28/24e1409e605a9aa5d84abe0e2acb365354b70ae56d40948101cabe3341ab/aiohttp-3.14.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d33e61021222ce7f9792bcac870d6f58d8adfceda33ab857b01264f4560f2c5f", size = 1705862, upload-time = "2026-06-01T19:39:38.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/d0/e5eb3ff1daeaf644c7e36a957517672494122628e067c38b263fa04eda77/aiohttp-3.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:44eca38755d0105bb32f47d085f5dd449846a449e1245fc105889e3279dcf8e3", size = 1798909, upload-time = "2026-06-01T19:39:41.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/ba/8943f906f0570342886ababb9a722a44e360f786a028c5e0b0e29e3f735b/aiohttp-3.14.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f13087e06f68fea4941c21a0c541c00553aa16e4f8fd7bbe2b198df761e964d6", size = 1868892, upload-time = "2026-06-01T19:39:43.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/05/27df32c844b2156e1675a8d8ec22d963e3c8ba469ed7ceb1863320c7b521/aiohttp-3.14.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff82be7f1ef73634cb77890a770743239bc3d487b848669be1c599889336dc0a", size = 1751659, upload-time = "2026-06-01T19:39:46.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/62/da182e5910ab912b2e88aa919b61a16046a37a95714a5795b02eb57b2d18/aiohttp-3.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a150c0875ac8fd87f1c398650841308a30d65facf7416b12dbdb9cfdcbe5a48c", size = 1578775, upload-time = "2026-06-01T19:39:48.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/e3/53c67097e8a5ce98625e91e3fa7f43c9c6940de680345d03b3509a72a078/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:edc01ea4e1ec5a1649a28866262bf24195889ff7b27bdd947029a6086741de9b", size = 1710090, upload-time = "2026-06-01T19:39:51.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/55/0e2732ca598c7a4dfe8a775662376d0ca2977cb1030e48386d4da5d9a456/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:540632bf882ff8fc88f2e1697be0761578e89e0d79fb4a8a6d65dc5da7e729d4", size = 1715016, upload-time = "2026-06-01T19:39:53.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/96/f0b73730798c9ca525afc30b39f1f81bbe24e245d9654c54d3b39d63212d/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:860a86bc2c80237f5dff52edcf427e10a8d8352271fd84845429a3e60199e02c", size = 1763810, upload-time = "2026-06-01T19:39:56.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/cc/11acb6c4518f448323405a7312b6f255d0f974a34373ad1db7633c4aadc8/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5cbd50e6a50d6b99283a826b18cbdebf65b0797689a7535cb0e9dd37be0f63c3", size = 1573064, upload-time = "2026-06-01T19:39:58.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/2d/28c31dde0a7dc98c0ee7d0da2ddcec3f7688c4fc131e5989e278d0c03c0a/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:20144819e99db593e22bbd2f3f2691a5e149f879142d6b8670254708853ff4fb", size = 1775765, upload-time = "2026-06-01T19:40:01.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/69/155c4ef3aec96417d47024800472b33b16c5d8a665371dcd044c2afdf25d/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:26b6d79aa54cb4ed50cc7d41ed14e99e0f1fc8e7c2d42f2e05b37aea897b2b52", size = 1733716, upload-time = "2026-06-01T19:40:03.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/44/6126116fd8a316b712bb615660b855c78466bb67ba1bb1742427eafcf7ac/aiohttp-3.14.0-cp314-cp314-win32.whl", hash = "sha256:106ed074a856f3e21d186b8579e2c8afb6da598e267cdaab01059e13db2fc44d", size = 453684, upload-time = "2026-06-01T19:40:06.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/d7/eff4c58a88c5cac5e38b55f44fb8a6d3929c3cbd77356e383e094d3220bd/aiohttp-3.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f770846edae8f00ecc57af825bce811f787f87a7dcf0e90d191790efe5b31f7", size = 481758, upload-time = "2026-06-01T19:40:08.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ed/17b5bd9fbcb46e688f02e572f517754a9a75831e7b54702f027761dc4fa5/aiohttp-3.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:acf1581c4f21ed4b80a2dded504d87b055a071a84d5737ea966435f768275ac6", size = 450557, upload-time = "2026-06-01T19:40:11.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/34/6180103ce9aabc8ebff3f7bb55a1228ffe60f61042823031d9692cb7b101/aiohttp-3.14.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6aa1a40f9cbb3da9f80714c5966b8946c21e6a2530d809b9498b33161e3c8733", size = 787878, upload-time = "2026-06-01T19:40:13.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/e9/08954a40e8b7baa3d8beadd2b074b186e9b1e9c8ddabc288678a6265de50/aiohttp-3.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b62af5a8cc96a194eaa01a9ed7b34a3ffa58d3d8daaa1a0d7a749353ad12d228", size = 524400, upload-time = "2026-06-01T19:40:15.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/6a/b5965a634ac4d5ba99a463314cf4ab214ca073fcdc38a15e0294273701fc/aiohttp-3.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6eb63b1417efaf7d1002a6ad034a40d44376afcc16508a57f8e74b49ad26a095", size = 527904, upload-time = "2026-06-01T19:40:18.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/b4/932bcdd850c354d9bcca30f360e475d7852e30413fbbd44b182782ed5432/aiohttp-3.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c20b9ad156a79eb97be5cf9e069eec01d2f0dc8472ffbd75299a8b2d4c2cbbde", size = 1912162, upload-time = "2026-06-01T19:40:20.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/85/ce79bab0310d2e3fd2d7bc7e44412abeff7c8338f8a21dd0f2f1714989e5/aiohttp-3.14.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:40ae7b0642c25632c7eabc4a04754012691864d2a1b93becf7cddb76027b838a", size = 1778813, upload-time = "2026-06-01T19:40:23.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/54/ba62ac2d1bc87e010aad23751e383b8794e45d931df67677313a2da78823/aiohttp-3.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95f5217e76a046b9f228a101717ef8d42b1eb3d9d196d15202db5bf41df88936", size = 1899969, upload-time = "2026-06-01T19:40:26.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/82/7cc7907725d83a19f31551334061e1ab8e108b1d7ac52632a2a844a4acb5/aiohttp-3.14.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1a4a9f17e85b80878c176695c1998c790e83731d8271881e5d356488652a1f9e", size = 1991771, upload-time = "2026-06-01T19:40:29.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/1c/a57de71a4508c93a830b77c28af3d08cd97f606dedfc6b94275347744508/aiohttp-3.14.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:145262119b07d7f95abc1839add35ba2bfc84551d4b4660ca11542c0b215455b", size = 1868606, upload-time = "2026-06-01T19:40:31.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/ae/3839726cd49150a53ed340cc24ce5ba09d4c2117020ef9d45542bec5eb2f/aiohttp-3.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:49a33ded29b0b2fa7a367a02cf0fb89af602bb87542a16177ec8ce1c9c51d12a", size = 1665437, upload-time = "2026-06-01T19:40:35.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/1e/c237923232c7da7f0392ea25d89fc5e60c0e93f685f4ebca8e7bcdd5271c/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cc736a9c9fc2bc4dd71fd404815741b6573df27c3f985948ec4076989ac57de", size = 1834090, upload-time = "2026-06-01T19:40:37.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/02/a5a7a2524f92d3911761b405a7c067c751891942144adc13e2ad79611e39/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4141a3e5342ee3053a9cab54d25b64ed28289c1041e4c54b3d99839314d90ce", size = 1816907, upload-time = "2026-06-01T19:40:40.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/76/a8b9f0d09234d516af9f2d7dd715557f33b5da3b0b56ead41d1170e86e3c/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e30871b2d58996cb81aac52d2b1d15ac05257131ef0f90f18c2115a380fbfe7c", size = 1840382, upload-time = "2026-06-01T19:40:43.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/8e/140e715a0a4bbc211979ea30ec8396ad2ed5bf90ab87d8058fc4668b1923/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:667b881d083ccae3900ea5a241e17e5007ca78844c53ed389bb63d48f729d9c7", size = 1659497, upload-time = "2026-06-01T19:40:46.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/c7/7ba5de8af9650b9767b063c675427b8685f43fa7ce563673a7bc3af60f08/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:b584dfe615d151e9b8f0a8ecb3aee6147f2927ec5b95ba25fe621f5377510928", size = 1870829, upload-time = "2026-06-01T19:40:49.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/bc/2aaab2f85cadb26ea59c091fa2b8e370d625154b5c14b478f1b489d07551/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6199707cc40e0e9cd39c36fbc97bec416c704e1d0ddce03412bb3b3e6a90ccd0", size = 1832281, upload-time = "2026-06-01T19:40:52.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/98/31b9ad9fbc01f0075ee7221002df5fd2d10b647f451ca5f30edc802d9dd6/aiohttp-3.14.0-cp314-cp314t-win32.whl", hash = "sha256:a8d93334d4961c9d566b1f046c81dee475b7c21eb730728d38237bfa70d1c8e6", size = 490597, upload-time = "2026-06-01T19:40:54.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/1f/299b21441c8de42ff70fddc7cfe65e92f810abcf740739a09b56f7835364/aiohttp-3.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d2ffe9b614f50f069068b3b52e73414e4107fc10b7efc939a76acff9251fdd2", size = 525789, upload-time = "2026-06-01T19:40:57.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/11/7f83fcba9ee05d4c54d61b3f8104da0d43a59adac44dd28effc0c9a10422/aiohttp-3.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7a3fc4358e65826c515350f199c210de747cf669998211b1ee6c2e46de364b24", size = 467399, upload-time = "2026-06-01T19:40:59.993Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -926,7 +942,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.83.14"
|
||||
version = "1.87.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
|
@ -942,9 +958,9 @@ dependencies = [
|
|||
{ name = "tiktoken" },
|
||||
{ name = "tokenizers" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/7c/c095649380adc96c8630273c1768c2ad1e74aa2ee1dd8dd05d218a60569f/litellm-1.83.14.tar.gz", hash = "sha256:24aef9b47cdc424c833e32f3727f411741c690832cd1fe4405e0077144fe09c9", size = 14836599, upload-time = "2026-04-26T03:16:10.176Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/0d/ccdf682ccfd7f18bf0e179c39d85616b8f8ef05a798588285310412db13d/litellm-1.87.0.tar.gz", hash = "sha256:cafc1882cb0cbab8374c41180af86e4a067796e4524e15f59e99f6e689cd1bd8", size = 15453755, upload-time = "2026-06-02T03:53:29.076Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/5c/1b5691575420135e90578543b2bf219497caa33cfd0af64cb38f30288450/litellm-1.83.14-py3-none-any.whl", hash = "sha256:92b11ba2a32cf80707ddf388d18526696c7999a21b418c5e3b6eda1243d2cfdb", size = 16457054, upload-time = "2026-04-26T03:16:05.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/20/88a372fa7e50fc2c33458c6eef94a79afcf7bdfa43610079531b82b484a3/litellm-1.87.0-py3-none-any.whl", hash = "sha256:fbbba7e47ae29b55f878fe1acc80effb92761bc168f6236bd81a0cb6e147d855", size = 17103948, upload-time = "2026-06-02T03:53:25.677Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const path = require('path');
|
|||
const { spawnSync } = require('child_process');
|
||||
const { acquireHookSlot } = require('./hook-lock.js');
|
||||
const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
|
||||
const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
|
||||
|
||||
/**
|
||||
* Read JSON input from stdin synchronously.
|
||||
|
|
@ -345,7 +346,7 @@ function handlePostToolUse(input) {
|
|||
// If HEAD matches last indexed commit, no reindex needed
|
||||
if (currentHead && currentHead === lastCommit) return;
|
||||
|
||||
const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`;
|
||||
const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings });
|
||||
sendHookResponse(
|
||||
'PostToolUse',
|
||||
`GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` +
|
||||
|
|
|
|||
323
gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs
Normal file
323
gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
/**
|
||||
* Single source of truth for how docs, hooks, and warnings invoke gitnexus.
|
||||
*
|
||||
* Automatically selects a working invocation path:
|
||||
* 1. Global `gitnexus` on PATH (best — no install step)
|
||||
* 2. npm 11+ with pnpm on PATH → `pnpm --allow-build=… dlx` (avoids the npx
|
||||
* arborist crash *and* pnpm 10+ ignored-build-script failures, #1939)
|
||||
* 3. npm < 11 with npm on PATH → `npx` (works; simpler than pnpm dlx)
|
||||
* 4. pnpm-only → `pnpm --allow-build=… dlx`
|
||||
* 5. Last resort → `npx` (warned on npm 11+ from analyze.ts)
|
||||
*
|
||||
* The `--allow-build` flags MUST precede the `dlx` token. pnpm < 10.14 keeps
|
||||
* `dlx` in its argv escape list, so flags placed *after* `dlx` are parsed as
|
||||
* package specs (ERR_PNPM_SPEC_NOT_SUPPORTED). The pre-`dlx` position parses
|
||||
* into dlx's allow-build option and has been honored since pnpm 10.2.0 (#1939).
|
||||
*
|
||||
* This stays self-contained CJS because the Claude/Antigravity hooks run as
|
||||
* standalone files copied into the user's hook dir, where no package import is
|
||||
* available. The CLI reuses this module from src/cli/resolve-invocation.ts via
|
||||
* createRequire rather than re-implementing it. Two committed copies must stay
|
||||
* byte-identical (enforced by resolve-invocation.test.ts) — edit both together:
|
||||
* gitnexus/hooks/claude/ (the canonical copy the CLI and `gitnexus setup` read)
|
||||
* and gitnexus-claude-plugin/hooks/. A THIRD copy is written at runtime to
|
||||
* `<repo>/.gitnexus/run.cjs` by `gitnexus analyze` (ai-context.ts) so docs can
|
||||
* reference it directly via the `require.main === module` exec tail below; that
|
||||
* copy is gitignored and refreshed on every analyze, so it cannot drift for long.
|
||||
*/
|
||||
|
||||
const { execFileSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const NPX_REF = 'gitnexus@latest';
|
||||
|
||||
// Native packages whose postinstall must run under pnpm 10+ (blocked by default).
|
||||
const PNPM_ALLOW_BUILD_BASE = ['@ladybugdb/core', 'gitnexus', 'tree-sitter'];
|
||||
const PNPM_ALLOW_BUILD_EMBEDDINGS = ['onnxruntime-node'];
|
||||
|
||||
// Version-probe timeout, kept under Claude Code's 10s hook budget. PATH presence
|
||||
// detection is now spawn-free (resolveOnPath scans PATH directly), so the only
|
||||
// subprocesses left are the version probes: in a linked worktree the stale-index
|
||||
// hook first runs `git rev-parse --git-common-dir` (~2s) and `git rev-parse HEAD`
|
||||
// (~3s); the pnpm path then adds up to two 1s `--version` probes (npm, pnpm), so
|
||||
// the worst case is ~7s — within budget. A healthy `--version` returns in well
|
||||
// under a second, so the realistic cost is far lower.
|
||||
const PROBE_TIMEOUT_MS = 1000;
|
||||
|
||||
/**
|
||||
* Absolute path to `command` on PATH, or null — a pure-Node, spawn-free lookup
|
||||
* that mirrors how a shell resolves a bare command name: each PATH dir × the
|
||||
* platform's executable extensions (PATHEXT on Windows; the bare name + X_OK on
|
||||
* POSIX). This replaces the former `where`/`which` subprocess (#1938 "Option A"):
|
||||
* it is byte-for-byte identical on every OS, with no dependency on the probe
|
||||
* binary being reachable (a sanitized PATH that drops System32 / `/usr/bin` no
|
||||
* longer defeats detection), no shell-spawn surface (CVE-2024-27980), and no
|
||||
* spawn timeout to tune. On Windows it matches PATHEXT extensions ONLY — exactly
|
||||
* what `where`/cmd.exe resolve — so neither an un-spawnable `.ps1`-only shim (not
|
||||
* in default PATHEXT) nor a bare extensionless file (which the shell cannot launch
|
||||
* as `command`) is a false positive. `preferExecExt` returns a recognized
|
||||
* `.cmd`/`.bat`/`.exe` shim ahead of an exotic PATHEXT hit (e.g. `.COM`) when both
|
||||
* match, matching what a user would actually launch. Pure (platform/env injectable)
|
||||
* so it is unit-testable without touching the host PATH.
|
||||
*/
|
||||
function resolveOnPath(
|
||||
command,
|
||||
preferExecExt = false,
|
||||
{ platform = process.platform, env = process.env } = {},
|
||||
) {
|
||||
const pathValue = env.PATH || env.Path || env.path || '';
|
||||
if (!pathValue) return null;
|
||||
const isWin = platform === 'win32';
|
||||
const exts = isWin
|
||||
? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD')
|
||||
.split(';')
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean)
|
||||
.map((e) => (e.startsWith('.') ? e : `.${e}`))
|
||||
: [''];
|
||||
let weakHit = null;
|
||||
// Split on the host's PATH delimiter. `platform` is injected only to choose the
|
||||
// extension/exec-bit rules; the PATH string is always host-format, so it must
|
||||
// split on the host delimiter (`path.delimiter`) — in production `platform` IS
|
||||
// the host, so they coincide. (Deriving the delimiter from an injected platform
|
||||
// would split a Windows drive-letter path `C:\…` at its colon under a POSIX
|
||||
// injection.)
|
||||
for (const dir of pathValue.split(path.delimiter).filter(Boolean)) {
|
||||
for (const ext of exts) {
|
||||
const candidate = path.join(dir, `${command}${ext}`);
|
||||
try {
|
||||
if (!fs.statSync(candidate).isFile()) continue;
|
||||
if (!isWin) fs.accessSync(candidate, fs.constants.X_OK);
|
||||
// Prefer a runnable .cmd/.bat/.exe shim; remember an exotic PATHEXT hit
|
||||
// (e.g. .COM) only as a last resort if nothing better turns up.
|
||||
if (isWin && preferExecExt && !/\.(cmd|bat|exe)$/i.test(ext)) {
|
||||
weakHit = weakHit || candidate;
|
||||
continue;
|
||||
}
|
||||
return candidate;
|
||||
} catch {
|
||||
/* not a runnable file here — try the next candidate */
|
||||
}
|
||||
}
|
||||
}
|
||||
return weakHit;
|
||||
}
|
||||
|
||||
// One spawn of `<command> --version` → { major, minor } (each null when
|
||||
// unreadable). Version injection happens at the resolver seam (getNpmMajorVersion
|
||||
// / formatPnpmAllowBuildArgs), so this stays a pure real-process probe.
|
||||
function probeVersion(command) {
|
||||
try {
|
||||
const output = execFileSync(command, ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
// On Windows, npm/pnpm resolve to `.cmd` shims; execFileSync does no
|
||||
// PATHEXT resolution and Node refuses to spawn `.cmd`/`.bat` without a
|
||||
// shell (CVE-2024-27980), so a bare `<command> --version` ENOENTs and the
|
||||
// probe would wrongly report a present tool as absent. A shell lets the OS
|
||||
// resolve the shim. POSIX needs no shell (direct PATH lookup works).
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
// Find the first line that starts with a version token (`MAJOR.MINOR`,
|
||||
// optional `v` prefix) rather than splitting the whole output — pnpm/npm
|
||||
// under Corepack or with an update notice can print a banner line on stdout
|
||||
// before the version (stderr is already dropped via the stdio config).
|
||||
const versionLine = output
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => /^v?\d+\.\d+/.test(l));
|
||||
const match = versionLine ? versionLine.match(/^v?(\d+)\.(\d+)/) : null;
|
||||
return {
|
||||
major: match ? Number(match[1]) : null,
|
||||
minor: match ? Number(match[2]) : null,
|
||||
};
|
||||
} catch {
|
||||
return { major: null, minor: null };
|
||||
}
|
||||
}
|
||||
|
||||
// `deps` is the single injection seam: an explicitly provided key — including a
|
||||
// `null` value, detected via `in` — is honored as-is so tests can simulate an
|
||||
// absent tool without spawning; an absent key falls through to the real probe.
|
||||
function getNpmMajorVersion(deps = {}) {
|
||||
return 'npmMajor' in deps ? deps.npmMajor : probeVersion('npm').major;
|
||||
}
|
||||
|
||||
/**
|
||||
* `--allow-build` flags for the pre-`dlx` position. Emitted for pnpm >= 10.2
|
||||
* (where the flag exists, and pnpm 10+ blocks build scripts by default). Omitted
|
||||
* below 10.2: pnpm < 10 runs build scripts anyway, and pnpm 10.0/10.1 lack the
|
||||
* flag (it would be rejected as an unknown option). `alwaysAllowBuild` forces the
|
||||
* flags for committed documentation, which cannot probe the reader's pnpm.
|
||||
*/
|
||||
function formatPnpmAllowBuildArgs(options = {}, deps = {}) {
|
||||
if (!options.alwaysAllowBuild) {
|
||||
const { major, minor } =
|
||||
'pnpmMajor' in deps
|
||||
? { major: deps.pnpmMajor, minor: 'pnpmMinor' in deps ? deps.pnpmMinor : null }
|
||||
: probeVersion('pnpm');
|
||||
const lacksAllowBuild =
|
||||
major !== null && (major < 10 || (major === 10 && minor !== null && minor < 2));
|
||||
if (lacksAllowBuild) return [];
|
||||
}
|
||||
const pkgs = [...PNPM_ALLOW_BUILD_BASE];
|
||||
if (options.embeddings) pkgs.push(...PNPM_ALLOW_BUILD_EMBEDDINGS);
|
||||
return pkgs.map((p) => `--allow-build=${p}`);
|
||||
}
|
||||
|
||||
/** Fixed install-free command for committed AGENTS.md / SKILL.md (pnpm >= 10.2). */
|
||||
function formatDocumentationDlxCommand(gitnexusArgs, options = {}) {
|
||||
const flags = formatPnpmAllowBuildArgs({ ...options, alwaysAllowBuild: true }).join(' ');
|
||||
const prefix = flags ? `${flags} ` : '';
|
||||
return `pnpm ${prefix}dlx ${NPX_REF} ${gitnexusArgs}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `gitnexus` | `pnpm` | `npx`. `GITNEXUS_INVOCATION` forces a mode
|
||||
* (test/escape hatch). `probe` is injectable so the preference order can be
|
||||
* unit-tested without spawning; it defaults to the real PATH probe. `deps` can
|
||||
* inject `{ npmMajor, pnpmMajor }` for tests.
|
||||
*/
|
||||
function resolveInvocationMode(probe = resolveOnPath, deps = {}) {
|
||||
const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase();
|
||||
if (forced === 'gitnexus' || forced === 'pnpm' || forced === 'npx') {
|
||||
return forced;
|
||||
}
|
||||
if (probe('gitnexus', true)) return 'gitnexus';
|
||||
|
||||
const npmMajor = getNpmMajorVersion(deps);
|
||||
// pnpm presence: prefer an explicit `pnpmPresent` flag (set by
|
||||
// formatAnalyzeCommand, which falls back to a PATH probe when the version is
|
||||
// unreadable) so a present-but-unparseable pnpm — slow probe, Corepack
|
||||
// banner — still selects pnpm instead of the npx crash path. Otherwise an
|
||||
// injected version (a successful `pnpm --version` proves presence)
|
||||
// short-circuits the `which pnpm` probe; failing both, fall back to PATH.
|
||||
const hasPnpm =
|
||||
'pnpmPresent' in deps
|
||||
? deps.pnpmPresent
|
||||
: 'pnpmMajor' in deps
|
||||
? deps.pnpmMajor !== null
|
||||
: Boolean(probe('pnpm'));
|
||||
|
||||
// npm 11+ npx install crash (#1939) — prefer pnpm dlx when available.
|
||||
if (hasPnpm && npmMajor !== null && npmMajor >= 11) return 'pnpm';
|
||||
// npm 10 and earlier: npx works; prefer it over pnpm dlx when npm is present.
|
||||
if (npmMajor !== null && npmMajor < 11) return 'npx';
|
||||
// npm absent or unreadable — use pnpm if present (with allow-build flags).
|
||||
if (hasPnpm) return 'pnpm';
|
||||
|
||||
return 'npx';
|
||||
}
|
||||
|
||||
function formatPnpmDlxCommand(gitnexusArgs, options = {}, deps = {}) {
|
||||
const flags = formatPnpmAllowBuildArgs(options, deps).join(' ');
|
||||
const prefix = flags ? `${flags} ` : '';
|
||||
return `pnpm ${prefix}dlx ${NPX_REF} ${gitnexusArgs}`;
|
||||
}
|
||||
|
||||
function formatAnalyzeCommand(options = {}, deps = {}) {
|
||||
const suffix = options.embeddings ? ' --embeddings' : '';
|
||||
// Keep the stale-index hook budget tight by querying each tool at most once.
|
||||
// The memoized `probe` is a spawn-free PATH scan (resolveOnPath) shared with
|
||||
// resolveInvocationMode, so `gitnexus` is scanned only once and no subprocess
|
||||
// is spawned for presence. pnpm's *version* is still captured by a single
|
||||
// `pnpm --version` (the allow-build gate needs the number), which also proves
|
||||
// presence; the memoized scan only re-checks pnpm when that version is
|
||||
// unreadable. Injected deps (tests) and forced/global modes skip the pnpm probe.
|
||||
const cache = new Map();
|
||||
const probe = (command, gitnexusWrapper) => {
|
||||
const key = `${command}:${gitnexusWrapper ? 1 : 0}`;
|
||||
if (!cache.has(key)) cache.set(key, resolveOnPath(command, gitnexusWrapper));
|
||||
return cache.get(key);
|
||||
};
|
||||
let resolved = deps;
|
||||
if (!('pnpmMajor' in deps)) {
|
||||
const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase();
|
||||
// pnpm is only consulted when no non-pnpm mode is already certain: forced
|
||||
// gitnexus/npx never use pnpm, and a present global gitnexus wins outright.
|
||||
const mightUsePnpm = forced === 'pnpm' || (forced !== 'gitnexus' && forced !== 'npx');
|
||||
if (mightUsePnpm && (forced === 'pnpm' || !probe('gitnexus', true))) {
|
||||
const { major, minor } = probeVersion('pnpm');
|
||||
// Carry presence separately from version: when the version probe fails
|
||||
// (timeout, Corepack banner) but pnpm is on PATH, still treat it as
|
||||
// present so mode resolution picks pnpm over the npx crash path. The
|
||||
// PATH probe is memoized and only runs when the version is unreadable.
|
||||
const pnpmPresent = major !== null || Boolean(probe('pnpm'));
|
||||
resolved = { ...deps, pnpmMajor: major, pnpmMinor: minor, pnpmPresent };
|
||||
}
|
||||
}
|
||||
const mode = resolveInvocationMode(probe, resolved);
|
||||
if (mode === 'gitnexus') return `gitnexus analyze${suffix}`;
|
||||
if (mode === 'pnpm') return `${formatPnpmDlxCommand(`analyze${suffix}`, options, resolved)}`;
|
||||
return `npx ${NPX_REF} analyze${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `mode` into a concrete { program, args } pair for a set of gitnexus
|
||||
* subcommand arguments. Shared by the direct-exec entrypoint below; pure (no
|
||||
* spawn) so it is unit-testable. `--embeddings` widens the pnpm allow-build set.
|
||||
*/
|
||||
function buildRunnerArgv(mode, gitnexusArgs, deps = {}) {
|
||||
// Match both the space form (`--embeddings`) and the equals form
|
||||
// (`--embeddings=5000`) Commander accepts, so the pnpm allow-build set still
|
||||
// widens to onnxruntime-node when a user hand-types the equals form.
|
||||
const embeddings = gitnexusArgs.some(
|
||||
(a) => a === '--embeddings' || a.startsWith('--embeddings='),
|
||||
);
|
||||
if (mode === 'gitnexus') return { program: 'gitnexus', args: [...gitnexusArgs] };
|
||||
if (mode === 'pnpm') {
|
||||
return {
|
||||
program: 'pnpm',
|
||||
args: [...formatPnpmAllowBuildArgs({ embeddings }, deps), 'dlx', NPX_REF, ...gitnexusArgs],
|
||||
};
|
||||
}
|
||||
return { program: 'npx', args: [NPX_REF, ...gitnexusArgs] };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
formatAnalyzeCommand,
|
||||
formatDocumentationDlxCommand,
|
||||
formatPnpmAllowBuildArgs,
|
||||
formatPnpmDlxCommand,
|
||||
resolveInvocationMode,
|
||||
buildRunnerArgv,
|
||||
resolveOnPath,
|
||||
getNpmMajorVersion,
|
||||
NPX_REF,
|
||||
PNPM_ALLOW_BUILD_BASE,
|
||||
};
|
||||
|
||||
// Direct-exec entrypoint (#1945): `node run.cjs <gitnexus args…>` resolves the
|
||||
// best available runner (global `gitnexus` → `pnpm dlx` → `npx`) at call time and
|
||||
// runs it, inheriting stdio and propagating the child's exit code. This lets the
|
||||
// committed skills and generated AGENTS.md/CLAUDE.md reference ONE stable,
|
||||
// CLI-neutral command without baking in a package-manager assumption. `gitnexus
|
||||
// analyze` drops a copy of this file at `.gitnexus/run.cjs`. Skipped on require()
|
||||
// (the CLI and tests reuse the exports above), so it runs only when invoked as a
|
||||
// script.
|
||||
if (require.main === module) {
|
||||
const gitnexusArgs = process.argv.slice(2);
|
||||
const { program, args } = buildRunnerArgv(resolveInvocationMode(), gitnexusArgs);
|
||||
try {
|
||||
execFileSync(program, args, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
// On Windows, `npx`/`pnpm`/`gitnexus` resolve to `.cmd`/`.ps1`/`.exe`
|
||||
// shims (npm, Volta, Corepack, scoop). execFileSync does not do PATHEXT
|
||||
// resolution and Node refuses to spawn `.cmd`/`.bat` without a shell
|
||||
// (CVE-2024-27980), so a bare program name ENOENTs. A shell lets the OS
|
||||
// resolve the shim; POSIX needs no shell (direct PATH lookup works).
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
} catch (err) {
|
||||
// Make spawn failures (resolved program absent from PATH) self-explanatory
|
||||
// instead of a silent exit 1, then propagate the runner's own exit code.
|
||||
if (typeof err.status !== 'number') {
|
||||
process.stderr.write(`gitnexus runner: could not launch \`${program}\` — ${err.message}\n`);
|
||||
}
|
||||
process.exit(typeof err.status === 'number' ? err.status : 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,14 +5,16 @@ description: "Use when the user needs to run GitNexus CLI commands like analyze/
|
|||
|
||||
# GitNexus CLI Commands
|
||||
|
||||
All commands work via `npx` — no global install required.
|
||||
Commands below use `node .gitnexus/run.cjs <command>` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required.
|
||||
|
||||
> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
|
||||
|
||||
## Commands
|
||||
|
||||
### analyze — Build or refresh the index
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
node .gitnexus/run.cjs analyze
|
||||
```
|
||||
|
||||
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
|
||||
|
|
@ -28,7 +30,7 @@ Run from the project root. This parses all source files, builds the knowledge gr
|
|||
### status — Check index freshness
|
||||
|
||||
```bash
|
||||
npx gitnexus status
|
||||
node .gitnexus/run.cjs status
|
||||
```
|
||||
|
||||
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
|
||||
|
|
@ -36,7 +38,7 @@ Shows whether the current repo has a GitNexus index, when it was last updated, a
|
|||
### clean — Delete the index
|
||||
|
||||
```bash
|
||||
npx gitnexus clean
|
||||
node .gitnexus/run.cjs clean
|
||||
```
|
||||
|
||||
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
|
||||
|
|
@ -49,7 +51,7 @@ Deletes the `.gitnexus/` directory and unregisters the repo from the global regi
|
|||
### wiki — Generate documentation from the graph
|
||||
|
||||
```bash
|
||||
npx gitnexus wiki
|
||||
node .gitnexus/run.cjs wiki
|
||||
```
|
||||
|
||||
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
|
||||
|
|
@ -68,7 +70,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir
|
|||
### list — Show all indexed repos
|
||||
|
||||
```bash
|
||||
npx gitnexus list
|
||||
node .gitnexus/run.cjs list
|
||||
```
|
||||
|
||||
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
|||
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ description: "Use when the user asks how code works, wants to understand archite
|
|||
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
|
||||
```
|
||||
|
||||
> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
|
|||
2. **Match your task to a skill below** and **read that skill file**
|
||||
3. **Follow the skill's workflow and checklist**
|
||||
|
||||
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
|
||||
> If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first.
|
||||
|
||||
## Skills
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ description: "Use when the user wants to know what will break if they change som
|
|||
4. Assess risk and report to user
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ description: "Use when the user wants to review a pull request, understand what
|
|||
6. Summarize findings with risk assessment
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing.
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal before reviewing.
|
||||
|
||||
## Checklist
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
|||
4. Plan update order: interfaces → implementations → callers → tests
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
|
||||
## Checklists
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ If you already have a `.cursor/hooks.json`, merge the `hooks.postToolUse` array
|
|||
|
||||
### Verify
|
||||
|
||||
1. Index the project: `npx gitnexus analyze`
|
||||
1. Index the project: `npx gitnexus analyze` (on npm 11.x, `npx` can crash during install — use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze` instead; see [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939))
|
||||
2. Reload the Cursor window so it picks up the new hook config.
|
||||
3. Ask the agent something that triggers `Read` / `Grep` / `Shell rg`. You should see a `[GitNexus]` block appended to the tool result.
|
||||
4. Diagnose silent no-ops by setting `GITNEXUS_DEBUG=1` in your shell environment — the hook will write Cursor's raw event payload to stderr so you can verify field names.
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ description: Trace bugs through call chains using knowledge graph
|
|||
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
|
|||
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
|
||||
```
|
||||
|
||||
> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ description: Analyze blast radius before making code changes
|
|||
4. Assess risk and report to user
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ description: "Use when the user wants to review a pull request, understand what
|
|||
6. Summarize findings with risk assessment
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing.
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal before reviewing.
|
||||
|
||||
## Checklist
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ description: Plan safe refactors using blast radius and dependency mapping
|
|||
4. Plan update order: interfaces → implementations → callers → tests
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
|
||||
## Checklists
|
||||
|
||||
|
|
|
|||
|
|
@ -115,7 +115,23 @@ export type RelationshipType =
|
|||
| 'HANDLES_TOOL'
|
||||
| 'ENTRY_POINT_OF'
|
||||
| 'WRAPS'
|
||||
| 'QUERIES';
|
||||
| 'QUERIES'
|
||||
/** Vue component event system: a handler function in a parent component is
|
||||
* bound to an event emitted by a child component (`@event="handlerFn"`).
|
||||
* Source = handler Function/Method node in the parent.
|
||||
* Target = the child component's File node.
|
||||
* `reason` encodes the event name: `vue-event: @<eventName>`.
|
||||
* Complements `EMITS_EVENT`; together they enable Cypher queries that
|
||||
* trace which handlers receive which component's emitted events. */
|
||||
| 'BINDS_EVENT_HANDLER'
|
||||
/** Vue component event system: a component calls `emit('eventName', ...)`
|
||||
* or `this.$emit('eventName', ...)`, advertising that it can emit that event.
|
||||
* Source = the component's own File node (self-referential annotation).
|
||||
* Target = the same File node.
|
||||
* `reason` encodes the event name: `vue-emit: <eventName>`.
|
||||
* Complements `BINDS_EVENT_HANDLER`; a Cypher query joining on the
|
||||
* component File node reveals all (emitter, handler) pairs. */
|
||||
| 'EMITS_EVENT';
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -116,6 +116,8 @@ export type {
|
|||
FieldRegistry,
|
||||
FieldLookupOptions,
|
||||
} from './scope-resolution/registries/field-registry.js';
|
||||
export { buildMacroRegistry } from './scope-resolution/registries/macro-registry.js';
|
||||
export type { MacroRegistry } from './scope-resolution/registries/macro-registry.js';
|
||||
export { lookupCore } from './scope-resolution/registries/lookup-core.js';
|
||||
export type { CoreLookupParams } from './scope-resolution/registries/lookup-core.js';
|
||||
export { lookupQualified } from './scope-resolution/registries/lookup-qualified.js';
|
||||
|
|
@ -127,7 +129,12 @@ export {
|
|||
CONFIDENCE_EPSILON,
|
||||
} from './scope-resolution/registries/tie-breaks.js';
|
||||
export type { TieBreakKey } from './scope-resolution/registries/tie-breaks.js';
|
||||
export { CLASS_KINDS, METHOD_KINDS, FIELD_KINDS } from './scope-resolution/registries/context.js';
|
||||
export {
|
||||
CLASS_KINDS,
|
||||
METHOD_KINDS,
|
||||
FIELD_KINDS,
|
||||
MACRO_KINDS,
|
||||
} from './scope-resolution/registries/context.js';
|
||||
export type {
|
||||
RegistryContext,
|
||||
RegistryProviders,
|
||||
|
|
|
|||
|
|
@ -37,7 +37,12 @@ export type ReferenceKind =
|
|||
| 'write'
|
||||
| 'type-reference'
|
||||
| 'inherits'
|
||||
| 'import-use';
|
||||
| 'import-use'
|
||||
// A macro invocation (`log!(...)` / `vec![...]`). Resolved against
|
||||
// `Macro`-labeled definitions ONLY (see `MacroRegistry`) so a macro
|
||||
// never aliases a same-named free function — macros and functions are
|
||||
// disjoint namespaces. Emitted as a `USES` edge, not `CALLS`.
|
||||
| 'macro';
|
||||
|
||||
/**
|
||||
* How a call site binds its target. Informs `Registry.lookup` Step 2
|
||||
|
|
@ -54,6 +59,18 @@ export type CallForm = 'free' | 'member' | 'constructor' | 'index';
|
|||
export interface ReferenceSite {
|
||||
/** The name being referenced (e.g., `'save'`, `'User'`, `'count'`). */
|
||||
readonly name: string;
|
||||
/**
|
||||
* Optional raw, qualified form of the referenced name when the source wrote
|
||||
* a qualified path (e.g. a C++ base `struct D : Other::Inner` yields
|
||||
* `'Other::Inner'`). `name` keeps the simple tail (`'Inner'`) for the existing
|
||||
* scope-chain contract; resolution normalizes this via `normalizeQualifiedName`
|
||||
* and resolves it against the full-path `QualifiedNameIndex` BEFORE the
|
||||
* simple-tail walk, so a same-tail nested base resolves to the correct
|
||||
* sibling instead of the first-inserted one (issue #1982). Populated only by
|
||||
* per-language captures that emit `@reference.qualified-name`; absent
|
||||
* otherwise, in which case resolution is unchanged.
|
||||
*/
|
||||
readonly rawQualifiedName?: string;
|
||||
/** Source-text range of this reference. */
|
||||
readonly atRange: Range;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -162,3 +162,10 @@ export const FIELD_KINDS: readonly NodeLabel[] = Object.freeze([
|
|||
'Const',
|
||||
'Static',
|
||||
]);
|
||||
|
||||
// Macros occupy a namespace disjoint from functions/methods: a `log!`
|
||||
// invocation must resolve ONLY to a `macro_rules! log` definition, never
|
||||
// to a same-named `fn log`. `MACRO_KINDS` is therefore a singleton
|
||||
// (`['Macro']`) and is NOT merged into METHOD_KINDS — keeping the two
|
||||
// keyspaces separate is what prevents the cross-namespace false-edge.
|
||||
export const MACRO_KINDS: readonly NodeLabel[] = Object.freeze(['Macro']);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* `MacroRegistry` — scope-aware lookup for macro definitions
|
||||
* (`macro_rules!` in Rust; `#define` in C/C++) referenced from a macro
|
||||
* invocation site.
|
||||
*
|
||||
* Thin wrapper over `lookupCore`, specialized for the macro namespace:
|
||||
*
|
||||
* - `acceptedKinds` = `MACRO_KINDS` (`['Macro']` only). Crucially this
|
||||
* does NOT include `Function`/`Method`, so a `log!(…)` invocation can
|
||||
* never resolve to a same-named free function `fn log` — macros and
|
||||
* functions are disjoint namespaces (the false-`CALLS`-edge class the
|
||||
* #1934 review flagged).
|
||||
* - `useReceiverTypeBinding` is **false** — a macro invocation has no
|
||||
* receiver; resolution is name-through-the-lexical-chain + the global
|
||||
* qualified fallback, exactly like `ClassRegistry`.
|
||||
* - Arity is not applied — macros are variadic by nature.
|
||||
*/
|
||||
|
||||
import type { Resolution, ScopeId } from '../types.js';
|
||||
import { lookupCore, type CoreLookupParams } from './lookup-core.js';
|
||||
import { MACRO_KINDS, type RegistryContext } from './context.js';
|
||||
|
||||
export interface MacroRegistry {
|
||||
/**
|
||||
* Look up a macro definition by simple or scoped name anchored at
|
||||
* `scope`. Returns a confidence-ranked `Resolution[]`; consume `[0]`
|
||||
* for the best answer.
|
||||
*/
|
||||
lookup(name: string, scope: ScopeId): readonly Resolution[];
|
||||
}
|
||||
|
||||
export function buildMacroRegistry(ctx: RegistryContext): MacroRegistry {
|
||||
const params: CoreLookupParams = {
|
||||
acceptedKinds: MACRO_KINDS,
|
||||
useReceiverTypeBinding: false,
|
||||
ownerScopedContributor: null,
|
||||
};
|
||||
return {
|
||||
lookup(name: string, scope: ScopeId) {
|
||||
return lookupCore(name, scope, params, ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -59,4 +59,13 @@ export interface SymbolDefinition {
|
|||
isExplicit?: 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`)
|
||||
* tagged during the C++ resolution phase. Lets the graph bridge retry a
|
||||
* namespace-prefixed node-lookup key and lets the qualified-base resolver
|
||||
* break same-tail cross-namespace inheritance ties. A deliberate sidecar,
|
||||
* separate from `qualifiedName`: it does NOT participate in graph node
|
||||
* identity (node keys derive from filePath/type/qualifiedName) and leaves the
|
||||
* qualifiedName-keyed resolution index untouched. Absent for the common case
|
||||
* (non-namespace-nested defs and all non-C++ languages). */
|
||||
namespacePrefix?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -439,7 +439,7 @@ export interface Reference {
|
|||
readonly toDef: DefId;
|
||||
/** Location of the reference in source. */
|
||||
readonly atRange: Range;
|
||||
readonly kind: 'call' | 'read' | 'write' | 'type-reference' | 'inherits' | 'import-use';
|
||||
readonly kind: 'call' | 'read' | 'write' | 'type-reference' | 'inherits' | 'import-use' | 'macro';
|
||||
readonly confidence: number;
|
||||
readonly evidence: readonly ResolutionEvidence[];
|
||||
}
|
||||
|
|
|
|||
134
gitnexus-web/package-lock.json
generated
134
gitnexus-web/package-lock.json
generated
|
|
@ -18,7 +18,7 @@
|
|||
"@tailwindcss/vite": "^4.3.0",
|
||||
"axios": "^1.16.1",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.3",
|
||||
"dompurify": "^3.4.7",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
|
|
@ -26,11 +26,11 @@
|
|||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-layout-noverlap": "^0.4.2",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"i18next": "^26.2.0",
|
||||
"i18next": "^26.3.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"langchain": "^1.3.5",
|
||||
"langchain": "^1.4.2",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^1.14.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
|
|
@ -57,7 +57,7 @@
|
|||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.8.2",
|
||||
"@vercel/node": "^5.8.8",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"jsdom": "^29.1.1",
|
||||
|
|
@ -1357,16 +1357,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/core": {
|
||||
"version": "1.1.45",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.45.tgz",
|
||||
"integrity": "sha512-Y/wvuglLTMKJahkl4QD9dBIdF/z/CxZJWdTfHJF/q2jtlJtoFf6Mb5JpGxZfsi3mBY6NSG941FSLTcqhCKrhBA==",
|
||||
"version": "1.1.48",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.48.tgz",
|
||||
"integrity": "sha512-fQU6Guyb1pwc2fEplmA8FPbKfOMAofjnyJzExevro0FxEiuGHE18Ov/ZHmT9trWCDTZRI9eW1VIc6aChxV8pAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cfworker/json-schema": "^4.0.2",
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"camelcase": "6",
|
||||
"decamelize": "1.2.0",
|
||||
"js-tiktoken": "^1.0.12",
|
||||
"langsmith": ">=0.5.0 <1.0.0",
|
||||
"mustache": "^4.2.0",
|
||||
|
|
@ -1965,9 +1962,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
|
||||
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz",
|
||||
"integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2430,9 +2427,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
||||
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2933,9 +2930,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vercel/build-utils": {
|
||||
"version": "13.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.25.0.tgz",
|
||||
"integrity": "sha512-p2wqxi2I95T+g/+uP+Dc/uq2PApW7F9RbE/Vvwp28JY8SoCGHNUs2pxigttgaNDNF6IlUEMOTz+eJvsXToV/1w==",
|
||||
"version": "13.26.4",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.26.4.tgz",
|
||||
"integrity": "sha512-0g3ZxtZUJZbt4y0Vu4pkHtu1UN58FbVF9cqGT8T6jHp0EHdLGFj5TVCiME8ALeK4tjPImSmxnKZvvB5yb2hqEw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
|
@ -2959,9 +2956,9 @@
|
|||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@vercel/nft": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-1.5.0.tgz",
|
||||
"integrity": "sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-1.10.0.tgz",
|
||||
"integrity": "sha512-iLOW4fcsgkipfOh2Bw3wB38YDfxTlxr7+j4uFeui2OswkNT28jIitS/aMce7tS0mef1YPQ8zLIDYr3a0aahNrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2986,9 +2983,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vercel/node": {
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.8.2.tgz",
|
||||
"integrity": "sha512-Wt6KBr0LoIhUuzeH7E8S+1HRlS6oSA+98FJH/59wY2tYoxsHXbx5uiNkSBqBsM0nIwlJWR3B86tm9q9Xi22XdQ==",
|
||||
"version": "5.8.8",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.8.8.tgz",
|
||||
"integrity": "sha512-+uRT9evnGWUE6klrJJED4fCvlSxNShbIc/UY4FeUzt2sdcy5a5b1IoYlo94RJd7tAY9Jg2lR2cVfGfsnWH81ZA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
|
@ -2996,10 +2993,10 @@
|
|||
"@edge-runtime/primitives": "4.1.0",
|
||||
"@edge-runtime/vm": "3.2.0",
|
||||
"@types/node": "20.11.0",
|
||||
"@vercel/build-utils": "13.25.0",
|
||||
"@vercel/build-utils": "13.26.4",
|
||||
"@vercel/error-utils": "2.1.0",
|
||||
"@vercel/nft": "1.5.0",
|
||||
"@vercel/static-config": "3.3.0",
|
||||
"@vercel/nft": "1.10.0",
|
||||
"@vercel/static-config": "3.4.0",
|
||||
"async-listen": "3.0.0",
|
||||
"cjs-module-lexer": "1.2.3",
|
||||
"edge-runtime": "2.5.9",
|
||||
|
|
@ -3060,9 +3057,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vercel/static-config": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/static-config/-/static-config-3.3.0.tgz",
|
||||
"integrity": "sha512-GpS3tPwUeDJCkrKbMNtS2XLRFgfxTlN7YNUL+Bo23+fGolrDw6Oq79R3yvxTYgqRaJMGSEqC7iMw6mj6I5loxg==",
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/static-config/-/static-config-3.4.0.tgz",
|
||||
"integrity": "sha512-wCq90CMUB//ggnFh77NQO1xaLFsS4LigQIqKrH6ohnr9Br/KI1FhlErx62WfCOuueWaW+LVsbLOqNXIUjK8t6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
|
@ -3331,7 +3328,9 @@
|
|||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
|
|
@ -3598,18 +3597,6 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
|
||||
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001764",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz",
|
||||
|
|
@ -4396,15 +4383,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
|
|
@ -4483,9 +4461,9 @@
|
|||
"peer": true
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.3.tgz",
|
||||
"integrity": "sha512-VVwJidIJcp1hpg2OMXML3ZVRPYSZiq4aX7qBh83BSIpOaRDqI+qxhXjjIWnpzkOXhmp0L81lnoME1mnCc9H48A==",
|
||||
"version": "3.4.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
|
||||
"integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
|
|
@ -5029,22 +5007,6 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
|
|
@ -5334,9 +5296,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.2.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.2.0.tgz",
|
||||
"integrity": "sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==",
|
||||
"version": "26.3.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz",
|
||||
"integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
|
@ -5790,12 +5752,12 @@
|
|||
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
|
||||
},
|
||||
"node_modules/langchain": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.3.5.tgz",
|
||||
"integrity": "sha512-QSB8TEo6G1tWupgNt1Osm8ylLLoOMq1lLw5NeijnIwRPI5BqdBjUn4/U8usbjEJJcQnbXSK2qTKgTx7zvblnBw==",
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.4.2.tgz",
|
||||
"integrity": "sha512-SLGipy0r4nqQD0aiUOBYLMeGFfB/QiYnMndfZ8sGN89vXDCIXbYqcE7G/4QDDX3nZsM7/emQpoScmlxEX6sDnQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/langgraph": "^1.2.9",
|
||||
"@langchain/langgraph": "^1.3.2",
|
||||
"@langchain/langgraph-checkpoint": "^1.0.1",
|
||||
"langsmith": ">=0.5.0 <1.0.0",
|
||||
"zod": "^3.25.76 || ^4"
|
||||
|
|
@ -5804,7 +5766,7 @@
|
|||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.1.42"
|
||||
"@langchain/core": "^1.1.48"
|
||||
}
|
||||
},
|
||||
"node_modules/langsmith": {
|
||||
|
|
@ -6142,9 +6104,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
|
||||
"integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz",
|
||||
"integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
|
|
@ -8350,9 +8312,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.15",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
|
||||
"integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
|
||||
"version": "7.5.16",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz",
|
||||
"integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
"@tailwindcss/vite": "^4.3.0",
|
||||
"axios": "^1.16.1",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.3",
|
||||
"dompurify": "^3.4.7",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
|
|
@ -36,11 +36,11 @@
|
|||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-layout-noverlap": "^0.4.2",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"i18next": "^26.2.0",
|
||||
"i18next": "^26.3.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"langchain": "^1.3.5",
|
||||
"langchain": "^1.4.2",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^1.14.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
|
|
@ -67,7 +67,7 @@
|
|||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.8.2",
|
||||
"@vercel/node": "^5.8.8",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"jsdom": "^29.1.1",
|
||||
|
|
|
|||
|
|
@ -65,66 +65,90 @@ export const BASE_SYSTEM_PROMPT = `You are Nexus, a Code Analysis Agent with acc
|
|||
|
||||
## ⚠️ MANDATORY: GROUNDING
|
||||
Every factual claim MUST include a citation.
|
||||
- File refs: [[src/auth.ts:45-60]] (line range with hyphen)
|
||||
- File refs: [[src/auth.ts:45-60]] (repo-relative path, line range with hyphen)
|
||||
- Symbol refs: [[Function:validateUser]] or [[Class:AuthService]]
|
||||
- Do NOT wrap citations in backticks or code blocks — keep them as plain text
|
||||
- NO citation = NO claim. Say "I didn't find evidence" instead of guessing.
|
||||
|
||||
## ⚠️ MANDATORY: VALIDATION
|
||||
Every output MUST be validated.
|
||||
- Use cypher to validate the results and confirm completeness of context before final output.
|
||||
- NO validation = NO claim. Say "I didn't find evidence" instead of guessing.
|
||||
- Do not blindly trust readme or single source of truth. Always validate and cross-reference. Never be lazy.
|
||||
## 🧠 CORE PROTOCOL (Iterative Loop)
|
||||
You are an investigator, not a one-shot query engine. For each question:
|
||||
1. **Plan** — Briefly state what you are looking for and why.
|
||||
2. **Execute** — Run tools to gather evidence.
|
||||
3. **Analyze & pivot** — Did the output fully answer the question?
|
||||
- Yes → proceed to grounding.
|
||||
- Revealed new files/functions → loop back and investigate them immediately.
|
||||
- Tool failed → fix the input and retry. Never stop after one error.
|
||||
4. **Trace** — Use cypher, explore, or impact to follow graph connections.
|
||||
5. **Read** — Use read to verify logic. Do not guess behavior from names alone.
|
||||
6. **Validate** — Cross-check findings with cypher before final output. README/docs are summaries, not proof.
|
||||
7. **Ground** — Cite every finding with [[path:START-END]] or [[Type:Name]].
|
||||
|
||||
## 🧠 CORE PROTOCOL
|
||||
You are an investigator. For each question:
|
||||
1. **Search** → Use cypher, search or grep to find relevant code
|
||||
2. **Read** → Use read to see the actual source
|
||||
3. **Trace** → Use cypher to follow connections in the graph
|
||||
4. **Cite** → Ground every finding with [[file:line]] or [[Type:Name]]
|
||||
5. **Validate** → Use cypher to validate the results and confirm completeness of context before final output. ( MUST DO )
|
||||
Before EVERY tool call, briefly state what you are doing and why. Keep narration to one line per step.
|
||||
|
||||
## 🛠️ TOOLS
|
||||
- **\`search\`** — Hybrid search. Results grouped by process with cluster context.
|
||||
- **\`cypher\`** — Cypher queries against the graph. Use \`{{QUERY_VECTOR}}\` for vector search.
|
||||
- **\`grep\`** — Regex search. Best for exact strings, TODOs, error codes.
|
||||
- **\`read\`** — Read file content. Always use after search/grep to see full code.
|
||||
- **\`explore\`** — Deep dive on a symbol, cluster, or process. Shows membership, participation, connections.
|
||||
## BE DIRECT
|
||||
- No pleasantries. No "Great question!" or "I'd be happy to help."
|
||||
- Don't repeat advice already given in this conversation.
|
||||
- Match response length to query complexity.
|
||||
- Don't pad with generic "let me know if you need more" — users will ask.
|
||||
|
||||
## 🛠️ TOOLS (exact names — use these only)
|
||||
- **\`search\`** — Hybrid keyword + semantic search. Results grouped by process with cluster context. Start here for discovery.
|
||||
- **\`cypher\`** — Cypher queries against the graph. Use \`{{QUERY_VECTOR}}\` placeholder for vector search.
|
||||
- **\`grep\`** — Regex search across files. Best for exact strings, TODOs, error codes.
|
||||
- **\`read\`** — Read file content. Always use after search/grep to see full source.
|
||||
- **\`explore\`** — Deep dive on a symbol, cluster, or process.
|
||||
- **\`overview\`** — Codebase map showing all clusters and processes.
|
||||
- **\`impact\`** — Impact analysis. Shows affected processes, clusters, and risk level.
|
||||
|
||||
**Tool strategy:**
|
||||
- Discovery → \`search\` or \`overview\`
|
||||
- Structure → \`cypher\`, \`explore\`, or \`impact\`
|
||||
- Verification → \`read\` (required before concluding)
|
||||
- Exact patterns → \`grep\`
|
||||
|
||||
## 📊 GRAPH SCHEMA
|
||||
Nodes: File, Folder, Function, Class, Interface, Method, Community, Process
|
||||
Relations: \`CodeRelation\` with \`type\` property: CONTAINS, DEFINES, IMPORTS, CALLS, EXTENDS, IMPLEMENTS, MEMBER_OF, STEP_IN_PROCESS
|
||||
Typed node labels: File, Folder, Function, Class, Interface, Method, CodeElement, Community, Process
|
||||
Single relation table: \`CodeRelation\` with \`type\` property: CONTAINS, DEFINES, IMPORTS, CALLS, EXTENDS, IMPLEMENTS, MEMBER_OF, STEP_IN_PROCESS
|
||||
|
||||
## 📐 GRAPH SEMANTICS (Important!)
|
||||
**Edge Types:**
|
||||
- \`CALLS\`: Method invocation OR constructor injection. If A receives B as parameter and uses it, A→B is CALLS. This is intentional simplification.
|
||||
- \`IMPORTS\`: File-level import/include statement.
|
||||
- \`EXTENDS/IMPLEMENTS\`: Class inheritance.
|
||||
|
||||
**Process Nodes:**
|
||||
- Process labels use format: "EntryPoint → Terminal" (e.g., "onCreate → showToast")
|
||||
- These are heuristic names from tracing execution flow, NOT application-defined names
|
||||
- Entry points are detected via export status, naming patterns, and framework conventions
|
||||
✅ \`MATCH (f:Function) RETURN f.name LIMIT 10\`
|
||||
✅ \`MATCH (a)-[r:CodeRelation {type: 'CALLS'}]->(b:Function) RETURN a.name, b.name\`
|
||||
❌ \`MATCH ()-[:CALLS]->()\` — WRONG, no such relationship label
|
||||
|
||||
Cypher examples:
|
||||
- \`MATCH (f:Function) RETURN f.name LIMIT 10\`
|
||||
- \`MATCH (f:File)-[:CodeRelation {type: 'IMPORTS'}]->(g:File) RETURN f.name, g.name\`
|
||||
- Find callers: \`MATCH (caller:Function)-[:CodeRelation {type: 'CALLS'}]->(fn:Function {name: 'validate'}) RETURN caller.name, caller.filePath\`
|
||||
- File imports: \`MATCH (f:File)-[:CodeRelation {type: 'IMPORTS'}]->(g:File) RETURN f.name, g.name\`
|
||||
- Semantic search: include \`{{QUERY_VECTOR}}\` in cypher and provide a \`query\` parameter
|
||||
|
||||
## 📝CRITICAL RULES
|
||||
- **impact output is trusted.** Do NOT re-validate with cypher. Optionally run the suggested grep commands for dynamic patterns.
|
||||
## 📐 GRAPH SEMANTICS
|
||||
- \`CALLS\`: Method invocation or constructor injection (intentional simplification).
|
||||
- \`IMPORTS\`: File-level import/include.
|
||||
- \`EXTENDS/IMPLEMENTS\`: Class inheritance.
|
||||
- Process labels use format "EntryPoint → Terminal" (heuristic, not app-defined names).
|
||||
|
||||
## 🎯 VISUAL GROUNDING (not a tool)
|
||||
The user sees a knowledge graph alongside this chat. Citations automatically highlight nodes in the graph UI.
|
||||
- Include [[path:START-END]] and [[Type:Name]] refs as you discover relevant code — the UI highlights them for the user.
|
||||
- Prefer 2-6 high-signal references over large dumps.
|
||||
- There is NO \`highlight_in_graph\` tool. Ground with citations; the UI handles visualization.
|
||||
|
||||
## 📝 CRITICAL RULES
|
||||
- **impact output is trusted.** Do NOT re-validate with cypher. Optionally run suggested grep for dynamic patterns.
|
||||
- **Cite or retract.** Never state something you can't ground.
|
||||
- **Read before concluding.** Don't guess from names alone.
|
||||
- **Retry on failure.** If a tool fails, fix the input and try again.
|
||||
- **Cyfer tool validation** prefer using cyfer tool in anything that requires graph connections.
|
||||
- **OUTPUT STYLE** Prefer using tables and mermaid diagrams instead of long explanations.
|
||||
- ALWAYS USE MERMAID FOR VISUALIZATION AND STRUCTURING THE OUTPUT.
|
||||
- **Iterative depth.** If Function A calls Function B, read Function B. Trace logic to the source.
|
||||
- **Prefer cypher** for anything requiring graph connections.
|
||||
|
||||
## ERROR RECOVERY
|
||||
If a tool call fails (Cypher syntax, file not found, invalid regex), do NOT stop.
|
||||
- Read the error, fix the input, and retry at least once.
|
||||
- For Cypher errors, verify typed node labels and \`CodeRelation {type: '...'}\` filters match the GRAPH SCHEMA section above.
|
||||
- If search returns nothing, try grep or a different query before concluding.
|
||||
|
||||
## 🎯 OUTPUT STYLE
|
||||
Think like a senior architect. Be concise—no fluff, short, precise and to the point.
|
||||
Think like a senior architect. Be concise — no fluff.
|
||||
- Use tables for comparisons/rankings
|
||||
- Use mermaid diagrams for flows/dependencies
|
||||
- Use mermaid diagrams for flows, architecture, and dependencies
|
||||
- Surface deep insights: patterns, coupling, design decisions
|
||||
- End with **TL;DR** (short summary of the response, summing up the response and the most critical parts)
|
||||
- End with **TL;DR**
|
||||
|
||||
## MERMAID RULES
|
||||
When generating diagrams:
|
||||
|
|
@ -132,6 +156,7 @@ When generating diagrams:
|
|||
- Wrap labels with spaces in quotes: A["My Label"]
|
||||
- Use simple IDs: A, B, C or auth, db, api
|
||||
- Flowchart: graph TD or graph LR (not flowchart)
|
||||
- Keep diagrams focused — 5-10 nodes max
|
||||
- Always test mentally: would this parse?
|
||||
|
||||
BAD: A[User's Data] --> B(Process & Save)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,20 @@ import { z } from 'zod';
|
|||
import { NODE_TABLES, REL_TYPES } from 'gitnexus-shared';
|
||||
import type { EnrichedSearchResult, GrepResult } from '../../services/backend-client';
|
||||
|
||||
/**
|
||||
* Tool names registered by createGraphRAGTools — kept in sync with each tool's `name`
|
||||
* field (enforced by agent-prompt.test.ts) and with BASE_SYSTEM_PROMPT in agent.ts.
|
||||
*/
|
||||
export const GRAPH_RAG_TOOL_NAMES = [
|
||||
'search',
|
||||
'cypher',
|
||||
'grep',
|
||||
'read',
|
||||
'overview',
|
||||
'explore',
|
||||
'impact',
|
||||
] as const;
|
||||
|
||||
const validLabel = (label: string): boolean => (NODE_TABLES as readonly string[]).includes(label);
|
||||
|
||||
const validRelType = (t: string): boolean => (REL_TYPES as readonly string[]).includes(t);
|
||||
|
|
|
|||
82
gitnexus-web/test/unit/agent-prompt.test.ts
Normal file
82
gitnexus-web/test/unit/agent-prompt.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { BASE_SYSTEM_PROMPT } from '../../src/core/llm/agent';
|
||||
import {
|
||||
createGraphRAGTools,
|
||||
GRAPH_RAG_TOOL_NAMES,
|
||||
type GraphRAGBackend,
|
||||
} from '../../src/core/llm/tools';
|
||||
import { NODE_REF_REGEX } from '../../src/lib/grounding-patterns';
|
||||
|
||||
/** Legacy or phantom tool names that must not appear in the system prompt. */
|
||||
const FORBIDDEN_TOOL_NAMES = [
|
||||
'hybrid_search',
|
||||
'semantic_search',
|
||||
'semantic_search_with_context',
|
||||
'execute_cypher',
|
||||
'execute_vector_cypher',
|
||||
'grep_code',
|
||||
'read_file',
|
||||
'get_graph_schema',
|
||||
'get_code_content',
|
||||
'get_codebase_stats',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* No-op backend. createGraphRAGTools only captures these methods inside each tool's
|
||||
* async execute closure — it never invokes them at construction time — so empty
|
||||
* implementations are enough to build the tools and read their registered names.
|
||||
*/
|
||||
const stubBackend: GraphRAGBackend = {
|
||||
executeQuery: async () => [],
|
||||
search: async () => [],
|
||||
grep: async () => [],
|
||||
readFile: async () => '',
|
||||
};
|
||||
|
||||
describe('BASE_SYSTEM_PROMPT tool parity', () => {
|
||||
it('documents every registered Graph RAG tool by exact name', () => {
|
||||
for (const name of GRAPH_RAG_TOOL_NAMES) {
|
||||
expect(BASE_SYSTEM_PROMPT).toContain(`\`${name}\``);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps GRAPH_RAG_TOOL_NAMES in sync with the tools createGraphRAGTools registers', () => {
|
||||
const registered = createGraphRAGTools(stubBackend).map((t) => t.name);
|
||||
expect(registered.sort()).toEqual([...GRAPH_RAG_TOOL_NAMES].sort());
|
||||
});
|
||||
|
||||
it('does not reference legacy or non-existent tool names', () => {
|
||||
for (const name of FORBIDDEN_TOOL_NAMES) {
|
||||
// Word-boundary match catches both backticked and bare-prose mentions.
|
||||
expect(BASE_SYSTEM_PROMPT).not.toMatch(new RegExp(`\\b${name}\\b`));
|
||||
}
|
||||
});
|
||||
|
||||
it('uses explicit file citation format expected by the UI parser', () => {
|
||||
expect(BASE_SYSTEM_PROMPT).toMatch(/\[\[src\/[^\]]+:\d+-\d+\]\]/);
|
||||
expect(BASE_SYSTEM_PROMPT).not.toContain('[[file:line]]');
|
||||
});
|
||||
|
||||
it('documents a parser-recognized symbol citation format', () => {
|
||||
// Use the UI parser's own allowlist (NODE_REF_REGEX) so this tracks the parser
|
||||
// instead of forking its label list. NODE_REF_REGEX is /g; use a non-global copy
|
||||
// so the match is stateless.
|
||||
expect(BASE_SYSTEM_PROMPT).toMatch(new RegExp(NODE_REF_REGEX.source));
|
||||
});
|
||||
|
||||
it('documents typed node labels, not polymorphic CodeNode', () => {
|
||||
expect(BASE_SYSTEM_PROMPT).toContain('MATCH (f:Function)');
|
||||
expect(BASE_SYSTEM_PROMPT).not.toContain('CodeNode');
|
||||
expect(BASE_SYSTEM_PROMPT).not.toContain('INHERITS');
|
||||
});
|
||||
|
||||
it('clarifies highlight_in_graph is not a callable tool', () => {
|
||||
// Reword-proof, registry-level guarantee: the load-bearing fact is that
|
||||
// highlight_in_graph is not a registered tool, regardless of prompt phrasing.
|
||||
expect(GRAPH_RAG_TOOL_NAMES).not.toContain('highlight_in_graph');
|
||||
// The prompt still addresses it explicitly...
|
||||
expect(BASE_SYSTEM_PROMPT).toContain('highlight_in_graph');
|
||||
// ...and must never instruct the model to call it (guards an affirmative reword).
|
||||
expect(BASE_SYSTEM_PROMPT).not.toMatch(/\b(?:use|call|invoke)\s+`?highlight_in_graph/i);
|
||||
});
|
||||
});
|
||||
|
|
@ -24,27 +24,35 @@ npx gitnexus analyze
|
|||
|
||||
That's it. This indexes the codebase, installs agent skills, registers Claude Code hooks, and creates `AGENTS.md` / `CLAUDE.md` context files — all in one command.
|
||||
|
||||
> **On npm 11.x?** `npx` can crash during install (`Cannot destructure property 'package' of 'node.target'`). Use the pnpm form instead:
|
||||
>
|
||||
> ```bash
|
||||
> pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze
|
||||
> ```
|
||||
>
|
||||
> See [Troubleshooting → `npx gitnexus` crashes with `node.target is null` (npm 11)](#cannot-destructure-property-package-of-nodetarget-as-it-is-null) for the full matrix (global install, npm downgrade).
|
||||
|
||||
To configure MCP for your editor, run `npx gitnexus setup` once — or set it up manually below.
|
||||
|
||||
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once.
|
||||
|
||||
### Editor Support
|
||||
|
||||
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
|
||||
|--------|-----|--------|---------------------|---------|
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse) | **Full** |
|
||||
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](../gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
|
||||
| **Antigravity** (Google) | Yes | Yes | Yes (AfterTool, [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/)) | **Full** |
|
||||
| **Codex** | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
|
||||
| ------------------------ | --- | ------ | ------------------------------------------------------------------------------------------ | ------------ |
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse) | **Full** |
|
||||
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](../gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
|
||||
| **Antigravity** (Google) | Yes | Yes | Yes (AfterTool, [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/)) | **Full** |
|
||||
| **Codex** | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
|
||||
> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that automatically enrich grep/glob/bash calls with knowledge graph context.
|
||||
|
||||
### Community Integrations
|
||||
|
||||
| Agent | Install | Source |
|
||||
|-------|---------|--------|
|
||||
| Agent | Install | Source |
|
||||
| -------------------- | ---------------------------- | ------------------------------------------------------- |
|
||||
| [pi](https://pi.dev) | `pi install npm:pi-gitnexus` | [pi-gitnexus](https://github.com/tintinweb/pi-gitnexus) |
|
||||
|
||||
## MCP Setup (manual)
|
||||
|
|
@ -116,36 +124,36 @@ The result is a **LadybugDB graph database** stored locally in `.gitnexus/` with
|
|||
|
||||
Your AI agent gets these tools automatically:
|
||||
|
||||
| Tool | What It Does | `repo` Param |
|
||||
|------|-------------|--------------|
|
||||
| `list_repos` | Discover all indexed repositories | — |
|
||||
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) | Optional |
|
||||
| `context` | 360-degree symbol view — categorized refs, process participation | Optional |
|
||||
| `impact` | Blast radius analysis with depth grouping and confidence | Optional |
|
||||
| `detect_changes` | Git-diff impact — maps changed lines to affected processes | Optional |
|
||||
| `rename` | Multi-file coordinated rename with graph + text search | Optional |
|
||||
| `cypher` | Raw Cypher graph queries | Optional |
|
||||
| Tool | What It Does | `repo` Param |
|
||||
| ---------------- | ---------------------------------------------------------------- | ------------ |
|
||||
| `list_repos` | Discover all indexed repositories | — |
|
||||
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) | Optional |
|
||||
| `context` | 360-degree symbol view — categorized refs, process participation | Optional |
|
||||
| `impact` | Blast radius analysis with depth grouping and confidence | Optional |
|
||||
| `detect_changes` | Git-diff impact — maps changed lines to affected processes | Optional |
|
||||
| `rename` | Multi-file coordinated rename with graph + text search | Optional |
|
||||
| `cypher` | Raw Cypher graph queries | Optional |
|
||||
|
||||
> With one indexed repo, the `repo` param is optional. With multiple, specify which: `query({query: "auth", repo: "my-app"})`.
|
||||
|
||||
## MCP Resources
|
||||
|
||||
| Resource | Purpose |
|
||||
|----------|---------|
|
||||
| `gitnexus://repos` | List all indexed repositories (read first) |
|
||||
| `gitnexus://repo/{name}/context` | Codebase stats, staleness check, and available tools |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional clusters with cohesion scores |
|
||||
| `gitnexus://repo/{name}/cluster/{name}` | Cluster members and details |
|
||||
| `gitnexus://repo/{name}/processes` | All execution flows |
|
||||
| `gitnexus://repo/{name}/process/{name}` | Full process trace with steps |
|
||||
| `gitnexus://repo/{name}/schema` | Graph schema for Cypher queries |
|
||||
| Resource | Purpose |
|
||||
| --------------------------------------- | ---------------------------------------------------- |
|
||||
| `gitnexus://repos` | List all indexed repositories (read first) |
|
||||
| `gitnexus://repo/{name}/context` | Codebase stats, staleness check, and available tools |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional clusters with cohesion scores |
|
||||
| `gitnexus://repo/{name}/cluster/{name}` | Cluster members and details |
|
||||
| `gitnexus://repo/{name}/processes` | All execution flows |
|
||||
| `gitnexus://repo/{name}/process/{name}` | Full process trace with steps |
|
||||
| `gitnexus://repo/{name}/schema` | Graph schema for Cypher queries |
|
||||
|
||||
## MCP Prompts
|
||||
|
||||
| Prompt | What It Does |
|
||||
|--------|-------------|
|
||||
| `detect_impact` | Pre-commit change analysis — scope, affected processes, risk level |
|
||||
| `generate_map` | Architecture documentation from the knowledge graph with mermaid diagrams |
|
||||
| Prompt | What It Does |
|
||||
| --------------- | ------------------------------------------------------------------------- |
|
||||
| `detect_impact` | Pre-commit change analysis — scope, affected processes, risk level |
|
||||
| `generate_map` | Architecture documentation from the knowledge graph with mermaid diagrams |
|
||||
|
||||
## CLI Commands
|
||||
|
||||
|
|
@ -170,6 +178,13 @@ gitnexus clean --all --force # Delete all indexes
|
|||
gitnexus wiki [path] # Generate LLM-powered docs from knowledge graph
|
||||
gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-mini)
|
||||
|
||||
# Direct graph queries — the same tools the MCP server exposes, no MCP daemon needed
|
||||
gitnexus query "<concept>" # Process-grouped hybrid search
|
||||
gitnexus context <symbol> [--uid <uid> | --file <path>] # 360° symbol view; flags disambiguate a shared name
|
||||
gitnexus impact <symbol> [--uid <uid> | --file <path> | --kind <kind>] # Blast radius; flags disambiguate a shared name
|
||||
gitnexus detect-changes # Map the working-tree diff to affected symbols and execution flows
|
||||
gitnexus cypher "<query>" # Run a raw Cypher query against the knowledge graph
|
||||
|
||||
# Repository groups (multi-repo / monorepo service tracking)
|
||||
gitnexus group create <name> # Create a repository group
|
||||
gitnexus group add <group> <groupPath> <registryName> # Add a repo to a group. <groupPath> is a hierarchy path (e.g. hr/hiring/backend); <registryName> is the repo's name from the registry (see `gitnexus list`)
|
||||
|
|
@ -205,21 +220,21 @@ TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift,
|
|||
|
||||
### Language Feature Matrix
|
||||
|
||||
| Language | Imports | Named Bindings | Exports | Heritage | Type Annotations | Constructor Inference | Config | Frameworks | Entry Points |
|
||||
|----------|---------|----------------|---------|----------|-----------------|---------------------|--------|------------|-------------|
|
||||
| TypeScript | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| JavaScript | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ |
|
||||
| Python | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Java | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| Kotlin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| C# | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Go | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| PHP | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Ruby | ✓ | — | ✓ | ✓ | — | ✓ | — | ✓ | ✓ |
|
||||
| Swift | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| Language | Imports | Named Bindings | Exports | Heritage | Type Annotations | Constructor Inference | Config | Frameworks | Entry Points |
|
||||
| ---------- | ------- | -------------- | ------- | -------- | ---------------- | --------------------- | ------ | ---------- | ------------ |
|
||||
| TypeScript | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| JavaScript | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ |
|
||||
| Python | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Java | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| Kotlin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| C# | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Go | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| PHP | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Ruby | ✓ | — | ✓ | ✓ | — | ✓ | — | ✓ | ✓ |
|
||||
| Swift | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
|
||||
**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
|
||||
|
||||
|
|
@ -266,22 +281,58 @@ for the full list; stable `latest` is unaffected.
|
|||
|
||||
### `Cannot destructure property 'package' of 'node.target' as it is null`
|
||||
|
||||
This crash was caused by a dependency URL format that is incompatible with
|
||||
certain npm/arborist versions ([npm/cli#8126](https://github.com/npm/cli/issues/8126)).
|
||||
It is fixed in **gitnexus v1.6.2+**. Upgrade to the latest version:
|
||||
This error comes from **npm 11.x's arborist** while installing gitnexus (often via `npx`), before gitnexus code runs. It is triggered by platform-filtered `optionalDependencies` in native packages such as `onnxruntime-node` / `@huggingface/transformers` (used when indexing with `--embeddings`). GitNexus cannot catch it at runtime — use one of these workarounds:
|
||||
|
||||
```bash
|
||||
npx gitnexus@latest analyze # always uses the newest release
|
||||
# — or —
|
||||
npm install -g gitnexus@latest # upgrade a global install
|
||||
pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze # auto-selected when pnpm + npm 11+
|
||||
npm install -g gitnexus@latest # global install avoids per-run npx reify
|
||||
gitnexus analyze # if already installed globally
|
||||
```
|
||||
|
||||
If you still hit npm install issues after upgrading, these generic workarounds
|
||||
may help:
|
||||
On **pnpm 10+**, lifecycle scripts are blocked unless explicitly allowed — the resolver adds `--allow-build` for `@ladybugdb/core`, `gitnexus`, and `tree-sitter` automatically when it picks `pnpm dlx`.
|
||||
|
||||
If you must stay on npm 11.x without pnpm, downgrade npm toolchain-wide (last resort):
|
||||
|
||||
```bash
|
||||
npm install -g npm@latest # update npm itself
|
||||
npm cache clean --force # clear a possibly corrupt cache
|
||||
npm install -g npm@10.9.0
|
||||
```
|
||||
|
||||
See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939) and the original [#819](https://github.com/abhigyanpatwari/GitNexus/issues/819) thread. An older variant of this crash (tree-sitter-dart tarball URL) was fixed in gitnexus v1.6.2+ ([#820](https://github.com/abhigyanpatwari/GitNexus/pull/820)); if you still see install failures after upgrading, clear cache:
|
||||
|
||||
```bash
|
||||
npm cache clean --force
|
||||
npx gitnexus@latest analyze
|
||||
```
|
||||
|
||||
### `ERR_DLOPEN_FAILED` / `lbugjs.node` missing (pnpm dlx, pnpx)
|
||||
|
||||
GitNexus depends on `@ladybugdb/core`, whose native database addon
|
||||
(`lbugjs.node`) is placed by a postinstall script. `pnpm dlx`, `pnpx`, and any
|
||||
install run with `--ignore-scripts` skip lifecycle scripts, so the addon is
|
||||
never put in place and the runtime crashes with `ERR_DLOPEN_FAILED`:
|
||||
|
||||
```
|
||||
Error: dlopen(.../@ladybugdb/core/lbugjs.node, ...): tried: '...' (no such file)
|
||||
code: 'ERR_DLOPEN_FAILED'
|
||||
```
|
||||
|
||||
Options that run install scripts:
|
||||
|
||||
```bash
|
||||
# pnpm dlx with explicit build permission (one-off, no global install required)
|
||||
pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter \
|
||||
dlx gitnexus@latest serve
|
||||
|
||||
# npm: global install (recommended on npm 11+; bare npx may crash — see section above)
|
||||
npm install -g gitnexus@latest
|
||||
gitnexus serve
|
||||
|
||||
# npx (npm < 11, or after upgrading npm)
|
||||
npx gitnexus@latest serve
|
||||
|
||||
# pnpm: global install with build scripts allowed (pnpm 10.2+; no approve-builds -g on pnpm 11+)
|
||||
pnpm add -g --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter gitnexus
|
||||
gitnexus serve
|
||||
```
|
||||
|
||||
### Installation fails with native module errors
|
||||
|
|
@ -305,11 +356,11 @@ GitNexus uses optional DuckDB extensions for BM25 and vector search. The `gitnex
|
|||
|
||||
Configure the behavior with two environment variables:
|
||||
|
||||
| Variable | Values | Default | Effect |
|
||||
|----------|--------|---------|--------|
|
||||
| `GITNEXUS_LBUG_EXTENSION_INSTALL` | `auto`, `load-only`, `never` | `auto` | `auto` runs one bounded INSTALL if LOAD fails. `load-only` only uses already-installed extensions (recommended for offline / firewalled environments). `never` skips optional extensions entirely. |
|
||||
| `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process `INSTALL` child before it is killed. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. |
|
||||
| Variable | Values | Default | Effect |
|
||||
| -------------------------------------------- | ---------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_LBUG_EXTENSION_INSTALL` | `auto`, `load-only`, `never` | `auto` | `auto` runs one bounded INSTALL if LOAD fails. `load-only` only uses already-installed extensions (recommended for offline / firewalled environments). `never` skips optional extensions entirely. |
|
||||
| `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process `INSTALL` child before it is killed. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. |
|
||||
|
||||
```bash
|
||||
# Offline/airgapped: never reach the network for extensions
|
||||
|
|
@ -366,11 +417,11 @@ For repositories with very large source files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BY
|
|||
|
||||
Three env vars expose the pool's resilience layers (respawn budget, cumulative-timeout cap, circuit breaker). Defaults are tuned for typical repos; bump them when an analyze legitimately needs more retries, or lower them to fail-fast on a known-bad shape.
|
||||
|
||||
| Variable | Default | Effect |
|
||||
| ------------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per slot before the slot is dropped from the active rotation. |
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Bounds exponentially-growing retry waits. |
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, dispatches require a fresh pool. |
|
||||
| Variable | Default | Effect |
|
||||
| ----------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per slot before the slot is dropped from the active rotation. |
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Bounds exponentially-growing retry waits. |
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, dispatches require a fresh pool. |
|
||||
|
||||
## Privacy
|
||||
|
||||
|
|
|
|||
1
gitnexus/bench/python-scope/baseline-fingerprint.txt
Normal file
1
gitnexus/bench/python-scope/baseline-fingerprint.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
c03f87cd8cd1ee716dea93cceb27109ee5b4584bc9fe426eea3f18fd6f9854cc
|
||||
|
|
@ -0,0 +1 @@
|
|||
d51ea9edd1902fc20dd888f3fc51907c8119a4692b92c32a427801ac7f41d451
|
||||
202
gitnexus/bench/python-scope/import-target-fingerprint.mjs
Normal file
202
gitnexus/bench/python-scope/import-target-fingerprint.mjs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
/**
|
||||
* Resolver-output correctness fingerprint for `resolvePythonImportTarget`
|
||||
* (ce-optimize: python-scope-capture, hypothesis H2).
|
||||
*
|
||||
* H2 replaces the per-import O(files) suffix scan + candidate scan in
|
||||
* import-target.ts with a memoized index. The index MUST reproduce the exact
|
||||
* resolution result (including the deterministic tie-break and the
|
||||
* false-positive gating) for every input. This harness pins that: it runs
|
||||
* resolvePythonImportTarget over an exhaustive branch matrix PLUS a large
|
||||
* deterministic fuzz (varied repo layouts that force collisions / multi-match
|
||||
* tie-breaks), and prints an order-independent sha256 over every
|
||||
* `fromFile | targetRaw | result` triple.
|
||||
*
|
||||
* Build-free via tsx (static .ts import). Run:
|
||||
* node --import tsx bench/python-scope/import-target-fingerprint.mjs
|
||||
*
|
||||
* The fingerprint before and after the H2 change MUST be identical.
|
||||
*/
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolvePythonImportTarget } from '../../src/core/ingestion/languages/python/import-target.ts';
|
||||
|
||||
function mkImport(targetRaw) {
|
||||
return { kind: 'absolute', targetRaw, isRelative: false, names: [] };
|
||||
}
|
||||
|
||||
function resolve(fromFile, files, targetRaw) {
|
||||
const ctx = { fromFile, allFilePaths: new Set(files) };
|
||||
return resolvePythonImportTarget(mkImport(targetRaw), ctx);
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
let nonNull = 0;
|
||||
function record(fromFile, files, targetRaw) {
|
||||
const r = resolve(fromFile, files, targetRaw);
|
||||
if (r !== null) nonNull++;
|
||||
lines.push(`${fromFile}\t${targetRaw}\t${r === null ? 'NULL' : r}`);
|
||||
}
|
||||
|
||||
// ---- 1. Exhaustive branch matrix ----------------------------------------
|
||||
|
||||
// direct root hit
|
||||
record('app/main.py', ['services/sync.py', 'services/__init__.py'], 'services.sync');
|
||||
// direct package (__init__) hit
|
||||
record('app/main.py', ['services/__init__.py'], 'services');
|
||||
// ancestor walk hit
|
||||
record('backend/routers/cron.py', ['backend/services/sync.py'], 'services.sync');
|
||||
// ancestor pkg hit
|
||||
record('backend/routers/cron.py', ['backend/services/__init__.py'], 'services');
|
||||
// suffix fallback single match (nested vendor layout)
|
||||
record('app/main.py', ['pkg/__init__.py', 'vendor/pkg/thing.py'], 'pkg.thing');
|
||||
// suffix fallback to __init__ (package)
|
||||
record('app/main.py', ['pkg/__init__.py', 'x/pkg/subpkg/__init__.py'], 'pkg.subpkg');
|
||||
// suffix multi-match tie-break: fewest segments wins
|
||||
record('app/main.py', ['pkg/__init__.py', 'a/pkg/models.py', 'b/c/pkg/models.py'], 'pkg.models');
|
||||
// suffix multi-match tie-break at SAME depth: lexicographic
|
||||
record('app/main.py', ['pkg/__init__.py', 'z/pkg/models.py', 'a/pkg/models.py'], 'pkg.models');
|
||||
// suffix file vs pkg same name, mixed — both candidate forms present
|
||||
record(
|
||||
'app/main.py',
|
||||
['pkg/__init__.py', 'q/pkg/models.py', 'r/pkg/models/__init__.py'],
|
||||
'pkg.models',
|
||||
);
|
||||
// hasRepoCandidate FALSE — external dotted import w/ colliding local basename (django.apps guard)
|
||||
record('app/main.py', ['accounts/apps.py'], 'django.apps');
|
||||
// hasRepoCandidate TRUE via top-level package, but no concrete file -> null
|
||||
record('app/main.py', ['pkg/__init__.py'], 'pkg.ghost');
|
||||
// hasRepoCandidate via nested ancestor namespace package
|
||||
record('backend/routers/cron.py', ['backend/services/sync.py'], 'services.helpers.util');
|
||||
// collision: accounts.models must NOT match billing/models.py
|
||||
record('app/main.py', ['accounts/__init__.py', 'billing/models.py'], 'accounts.models');
|
||||
// relative imports
|
||||
record('app/main.py', ['app/sibling.py'], '.sibling');
|
||||
record('app/pkg/mod.py', ['app/sibling.py'], '..sibling');
|
||||
record('app/main.py', ['app/sibling.py'], '...way.too.far');
|
||||
// single-segment bare import (no '/'): skips candidate gate
|
||||
record('app/main.py', ['mod.py'], 'mod');
|
||||
record('app/main.py', ['lib/mod.py'], 'mod');
|
||||
record('app/pkg/main.py', ['app/pkg/local.py'], 'local');
|
||||
// empty / dynamic
|
||||
record('app/main.py', ['a.py'], '');
|
||||
// windows-style backslash paths in the set
|
||||
record('app\\main.py', ['svc\\sync.py', 'svc\\__init__.py'], 'svc.sync');
|
||||
|
||||
// ---- 2. Deterministic fuzz ----------------------------------------------
|
||||
// LCG (no Math.random — deterministic + reproducible).
|
||||
let seed = 0x9e3779b9;
|
||||
function rnd() {
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
||||
return seed / 0x100000000;
|
||||
}
|
||||
function pick(arr) {
|
||||
return arr[Math.floor(rnd() * arr.length)];
|
||||
}
|
||||
|
||||
const DIRS = ['', 'a', 'b', 'a/b', 'b/c', 'x/y/z', 'vendor', 'src', 'src/app', 'pkg'];
|
||||
const SEGS = [
|
||||
'pkg',
|
||||
'services',
|
||||
'models',
|
||||
'sync',
|
||||
'util',
|
||||
'core',
|
||||
'apps',
|
||||
'sub',
|
||||
'thing',
|
||||
'helpers',
|
||||
];
|
||||
|
||||
function randPath() {
|
||||
const dir = pick(DIRS);
|
||||
const base = pick(SEGS);
|
||||
const isPkg = rnd() < 0.3;
|
||||
const file = isPkg ? `${base}/__init__.py` : `${base}.py`;
|
||||
return dir ? `${dir}/${file}` : file;
|
||||
}
|
||||
function randDotted() {
|
||||
const n = 1 + Math.floor(rnd() * 3);
|
||||
const parts = [];
|
||||
for (let i = 0; i < n; i++) parts.push(pick(SEGS));
|
||||
const rel = rnd() < 0.15 ? '.'.repeat(1 + Math.floor(rnd() * 2)) : '';
|
||||
return rel + parts.join('.');
|
||||
}
|
||||
|
||||
for (let repo = 0; repo < 400; repo++) {
|
||||
const fileCount = 3 + Math.floor(rnd() * 14);
|
||||
const files = [];
|
||||
for (let i = 0; i < fileCount; i++) files.push(randPath());
|
||||
const fromFile = randPath();
|
||||
for (let imp = 0; imp < 25; imp++) {
|
||||
record(fromFile, files, randDotted());
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 3. Absolute-path coverage (PR #1918 review P3a) --------------------
|
||||
// Production paths are repo-relative, but the index's prefix gating must
|
||||
// reproduce the old `f.startsWith(prefix)` semantics for absolute paths too.
|
||||
// The reviewer's exact case + a fuzz over leading-`/` file sets and absolute
|
||||
// importer paths lock the absolute-path behavior end to end.
|
||||
|
||||
// The flagged case: an absolute file under the importer's own root.
|
||||
record('/repo/app/main.py', ['/repo/svc/x.py'], 'svc.x');
|
||||
record('/repo/app/main.py', ['/repo/svc/__init__.py', '/repo/svc/x.py'], 'svc.x');
|
||||
// Absolute file NOT under the importer root — gate must not pass it.
|
||||
record('/repo/app/main.py', ['/other/svc/x.py'], 'svc.x');
|
||||
// Absolute vendored layout reachable only by suffix.
|
||||
record('/repo/app/main.py', ['/repo/pkg/__init__.py', '/repo/vendor/pkg/thing.py'], 'pkg.thing');
|
||||
// Absolute tie-break.
|
||||
record(
|
||||
'/repo/app/main.py',
|
||||
['/repo/pkg/__init__.py', '/a/pkg/models.py', '/b/c/pkg/models.py'],
|
||||
'pkg.models',
|
||||
);
|
||||
// Mixed absolute/relative file set.
|
||||
record('/repo/app/main.py', ['/repo/pkg/__init__.py', 'pkg/models.py'], 'pkg.models');
|
||||
|
||||
function randAbsPath() {
|
||||
// Reuse the relative generator under one of a few absolute roots.
|
||||
const root = pick(['/repo', '/srv/app', '/']);
|
||||
const rel = randPath();
|
||||
return root === '/' ? `/${rel}` : `${root}/${rel}`;
|
||||
}
|
||||
|
||||
for (let repo = 0; repo < 200; repo++) {
|
||||
const fileCount = 3 + Math.floor(rnd() * 12);
|
||||
const files = [];
|
||||
for (let i = 0; i < fileCount; i++) files.push(randAbsPath());
|
||||
const fromFile = randAbsPath();
|
||||
for (let imp = 0; imp < 20; imp++) {
|
||||
record(fromFile, files, randDotted());
|
||||
}
|
||||
}
|
||||
|
||||
const fingerprint = crypto
|
||||
.createHash('sha256')
|
||||
.update([...lines].sort().join('\n'))
|
||||
.digest('hex');
|
||||
const result = { fingerprint, cases: lines.length, non_null: nonNull };
|
||||
|
||||
if (!process.argv.includes('--check')) {
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
} else {
|
||||
// CI gate: resolver output unchanged (fingerprint == committed baseline).
|
||||
// Re-baseline a legitimate resolution change by running without --check and
|
||||
// committing the new baseline-import-target-fingerprint.txt deliberately.
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const baseline = fs
|
||||
.readFileSync(path.resolve(__dirname, 'baseline-import-target-fingerprint.txt'), 'utf8')
|
||||
.trim();
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
if (result.fingerprint !== baseline) {
|
||||
process.stderr.write(
|
||||
`[import-target-fingerprint --check] FAIL: resolver fingerprint drift: got ` +
|
||||
`${result.fingerprint}, expected ${baseline} (resolvePythonImportTarget output changed — ` +
|
||||
`re-baseline intentionally if expected)\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stderr.write('[import-target-fingerprint --check] PASS (resolver fingerprint)\n');
|
||||
}
|
||||
220
gitnexus/bench/python-scope/measure.mjs
Normal file
220
gitnexus/bench/python-scope/measure.mjs
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
/**
|
||||
* Build-free measurement harness for `emitPythonScopeCaptures`
|
||||
* (ce-optimize: python-scope-capture).
|
||||
*
|
||||
* This is Python's counterpart to `bench/scope-capture/measure.mjs` (which
|
||||
* covers go/csharp/rust/php/ruby/cobol). Python lives here, NOT in that unified
|
||||
* harness, because this one ALSO covers import resolution
|
||||
* (`import-target-fingerprint.mjs`). Python's capture-scaling guard therefore
|
||||
* runs via `python-scope/measure.mjs --check`, not the unified harness — don't
|
||||
* remove either thinking the other covers Python.
|
||||
*
|
||||
* Mirrors the Go scope-capture harness (#1848). Imports the `.ts` hotpath
|
||||
* directly through tsx (`node --import tsx bench/python-scope/measure.mjs`):
|
||||
* a static `.ts` import works; a top-level `await import()` breaks tsx's lexer.
|
||||
*
|
||||
* Emits ONE JSON object on stdout with:
|
||||
* - elapsed_ms_250 / elapsed_ms_800: median wall-clock (ms) of
|
||||
* emitPythonScopeCaptures over a synthetic DAO-style source at that many
|
||||
* top-level entities (warmed up first). 800/250 ~ 3.2x input; an O(n^2)
|
||||
* path scales ~quadratically, an O(n) path ~linearly.
|
||||
* - scaling_ratio: (t800/t250)/(800/250). ~3.2 = quadratic, ~1.0 = linear.
|
||||
* - capture_groups_250 / capture_groups_800: match counts (a fast-but-empty
|
||||
* regression can't pass — counts must stay > 0).
|
||||
* - fingerprint: order-independent sha256 over emitPythonScopeCaptures output
|
||||
* across the whole lang-resolution/python-* fixture corpus + a fixed
|
||||
* 20-entity synthetic DAO. This is the CORRECTNESS gate: any change to the
|
||||
* captures changes the fingerprint. Entity-count-fixed so it is comparable
|
||||
* across experiments regardless of the timing sizes.
|
||||
* - capture_groups_fp / fixture_count: corpus sanity.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { emitPythonScopeCaptures } from '../../src/core/ingestion/languages/python/captures.ts';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURE_ROOT = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'lang-resolution');
|
||||
|
||||
// ---- correctness fingerprint (order-independent, mirrors the Go golden) ----
|
||||
|
||||
function canonicalizeMatch(match) {
|
||||
const parts = [];
|
||||
for (const tag of Object.keys(match)) {
|
||||
const cap = match[tag];
|
||||
const r = cap.range;
|
||||
parts.push(`${tag}|${cap.text}|${r.startLine}:${r.startCol}-${r.endLine}:${r.endCol}`);
|
||||
}
|
||||
parts.sort();
|
||||
return parts.join(';');
|
||||
}
|
||||
|
||||
function digestCaptures(matches) {
|
||||
const matchStrings = matches.map(canonicalizeMatch).sort();
|
||||
return crypto.createHash('sha256').update(matchStrings.join('\n')).digest('hex');
|
||||
}
|
||||
|
||||
/** All `.py` files under `lang-resolution/python-*`, sorted by repo-relative key. */
|
||||
function collectPythonFixtures() {
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(FIXTURE_ROOT, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || !entry.name.startsWith('python-')) continue;
|
||||
const stack = [path.join(FIXTURE_ROOT, entry.name)];
|
||||
while (stack.length) {
|
||||
const dir = stack.pop();
|
||||
for (const c of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, c.name);
|
||||
if (c.isDirectory()) stack.push(p);
|
||||
else if (c.name.endsWith('.py')) {
|
||||
out.push({ key: path.relative(FIXTURE_ROOT, p).split(path.sep).join('/'), absPath: p });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => a.key.localeCompare(b.key));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthetic DAO-style source: top-level imports + N classes (each with methods,
|
||||
* exercising @scope.function + @declaration.function + receiver binding) + N
|
||||
* module functions. Maximizes top-level children (rootChildren) AND function
|
||||
* matches, which is exactly the O(matches x rootChildren) shape #1848 hit.
|
||||
*/
|
||||
function generatePyDao(entityCount) {
|
||||
const lines = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
lines.push(`from pkg.mod${i} import alpha${i}, beta${i}, gamma${i} as g${i}`);
|
||||
lines.push(`import top.level.module${i}`);
|
||||
}
|
||||
lines.push('');
|
||||
// Shared base + mixin so every Entity is heritage-bearing — exercises the
|
||||
// @reference.inherits synth (#1951) at scale (single + multiple inheritance),
|
||||
// not just the base capture loop.
|
||||
lines.push('class Base:', ' pass', '', 'class Mixin:', ' pass', '');
|
||||
for (let i = 0; i < entityCount; i++) {
|
||||
const n = String(i).padStart(4, '0');
|
||||
lines.push(
|
||||
`class Entity${n}(Base, Mixin):`,
|
||||
` def __init__(self, id: int, name: str):`,
|
||||
` self.id = id`,
|
||||
` self.name = name`,
|
||||
` def get_id(self) -> int:`,
|
||||
` return self.id`,
|
||||
` def set_name(self, name: str) -> None:`,
|
||||
` self.name = name`,
|
||||
` @classmethod`,
|
||||
` def make(cls, id: int):`,
|
||||
` return cls(id, "x")`,
|
||||
'',
|
||||
`def build_entity${n}(id: int, name: str) -> Entity${n}:`,
|
||||
` return Entity${n}(id, name)`,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---- timing ----
|
||||
|
||||
function timeOnce(src, filePath) {
|
||||
const start = process.hrtime.bigint();
|
||||
const matches = emitPythonScopeCaptures(src, filePath);
|
||||
const end = process.hrtime.bigint();
|
||||
return { ms: Number(end - start) / 1e6, count: matches.length };
|
||||
}
|
||||
|
||||
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 measureSize(entityCount, reps) {
|
||||
const src = generatePyDao(entityCount);
|
||||
// Warm up parser/query JIT (not counted).
|
||||
timeOnce(src, 'warmup.py');
|
||||
const samples = [];
|
||||
let count = 0;
|
||||
for (let i = 0; i < reps; i++) {
|
||||
const r = timeOnce(src, `bench-${entityCount}.py`);
|
||||
samples.push(r.ms);
|
||||
count = r.count;
|
||||
}
|
||||
return { ms: median(samples), count };
|
||||
}
|
||||
|
||||
// ---- run ----
|
||||
|
||||
function computeFingerprint() {
|
||||
let groups = 0;
|
||||
const perFixtureDigests = [];
|
||||
for (const { key, absPath } of collectPythonFixtures()) {
|
||||
const src = fs.readFileSync(absPath, 'utf8');
|
||||
const matches = emitPythonScopeCaptures(src, absPath);
|
||||
groups += matches.length;
|
||||
perFixtureDigests.push(`${key}\t${matches.length}\t${digestCaptures(matches)}`);
|
||||
}
|
||||
// Fixed 20-entity synthetic source so the fingerprint is comparable across
|
||||
// experiments independent of the timing sizes.
|
||||
const daoMatches = emitPythonScopeCaptures(generatePyDao(20), 'synthetic-dao-20.py');
|
||||
groups += daoMatches.length;
|
||||
perFixtureDigests.push(`synthetic:dao-20\t${daoMatches.length}\t${digestCaptures(daoMatches)}`);
|
||||
const fingerprint = crypto
|
||||
.createHash('sha256')
|
||||
.update(perFixtureDigests.sort().join('\n'))
|
||||
.digest('hex');
|
||||
return { fingerprint, groups, fixtureCount: perFixtureDigests.length };
|
||||
}
|
||||
|
||||
// Higher rep count keeps the median stable on noisy shared CI runners.
|
||||
const REPS = 7;
|
||||
const SCALING_BUDGET = 1.5; // ~3.2 (quadratic) vs ~1.0 (linear); 1.5 has headroom.
|
||||
const CHECK = process.argv.includes('--check');
|
||||
|
||||
const fp = computeFingerprint();
|
||||
const small = measureSize(250, REPS);
|
||||
const large = measureSize(800, REPS);
|
||||
const scalingRatio = small.ms > 0 ? large.ms / small.ms / (800 / 250) : 0;
|
||||
|
||||
const result = {
|
||||
elapsed_ms_250: Number(small.ms.toFixed(2)),
|
||||
elapsed_ms_800: Number(large.ms.toFixed(2)),
|
||||
scaling_ratio: Number(scalingRatio.toFixed(3)),
|
||||
capture_groups_250: small.count,
|
||||
capture_groups_800: large.count,
|
||||
fingerprint: fp.fingerprint,
|
||||
capture_groups_fp: fp.groups,
|
||||
fixture_count: fp.fixtureCount,
|
||||
};
|
||||
|
||||
if (!CHECK) {
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
} else {
|
||||
// CI gate: capture output unchanged (fingerprint == committed baseline) AND
|
||||
// the path is still linear (scaling ratio under budget). Re-baseline a
|
||||
// legitimate capture change with `node --import tsx measure.mjs` (no --check)
|
||||
// and commit the new baseline-fingerprint.txt deliberately.
|
||||
const baselinePath = path.resolve(__dirname, 'baseline-fingerprint.txt');
|
||||
const baseline = fs.readFileSync(baselinePath, 'utf8').trim();
|
||||
const failures = [];
|
||||
if (result.fingerprint !== baseline) {
|
||||
failures.push(
|
||||
`capture fingerprint drift: got ${result.fingerprint}, expected ${baseline} ` +
|
||||
`(emitPythonScopeCaptures output changed — re-baseline intentionally if expected)`,
|
||||
);
|
||||
}
|
||||
if (result.scaling_ratio >= SCALING_BUDGET) {
|
||||
failures.push(
|
||||
`capture scaling ratio ${result.scaling_ratio} >= ${SCALING_BUDGET} ` +
|
||||
`(possible O(n^2) regression; 250->800ms ${result.elapsed_ms_250}->${result.elapsed_ms_800})`,
|
||||
);
|
||||
}
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
if (failures.length > 0) {
|
||||
for (const f of failures) process.stderr.write(`[measure --check] FAIL: ${f}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stderr.write('[measure --check] PASS (capture fingerprint + scaling)\n');
|
||||
}
|
||||
84
gitnexus/bench/scope-capture/baselines.json
Normal file
84
gitnexus/bench/scope-capture/baselines.json
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
{
|
||||
"_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/<lang>-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. The synthetic source is now HERITAGE-BEARING for every language (each Entity extends/implements/embeds/uses-trait/conforms-to a shared base) so the #1951 @reference.inherits synth is gated at scale, not just the base capture loop. All languages thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp #1951, java #1956).",
|
||||
"go": {
|
||||
"fingerprint": "09ecd94911b830f52fa8807560abcbd79f163d02a2072870c1a59297e9a326e1",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1976: F33 generic composite literal constructor inference adds generic_type captures in composite_literal patterns; fingerprint drift expected."
|
||||
},
|
||||
"cobol": {
|
||||
"fingerprint": "68ee0e95eb9f86f2d92ca35f730f4c2d4d83abc1b5241ae767ff3437780ec8d1",
|
||||
"scaling_budget": 1.5,
|
||||
"_note": "Updated for F17-F23 fixes (P2: TIMES guard, ADD GIVING, SQL AS alias). See PR #1959."
|
||||
},
|
||||
"c": {
|
||||
"fingerprint": "0de009bdbfe095f530fa87eb32bce6ab83092c904f26b3c8fe8d8ab587cf6dc9",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance \u2014 flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96."
|
||||
},
|
||||
"cpp": {
|
||||
"fingerprint": "6d6207ae1df3943c5fae28983e0c294e55225456e7cf39af1d46fda21b6787c4",
|
||||
"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": "#1965 / #1923 F4: uninitialized non-leading multi-declarators now emit @declaration.variable captures; cpp-adl-inner-callable-outer-noncallable data::Pair a, b adds the legitimate fixture drift. Linear (~1.06).",
|
||||
"_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 \u2014 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 \u2014 pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture \u2014 pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae."
|
||||
},
|
||||
"csharp": {
|
||||
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.",
|
||||
"fingerprint": "2bb5bc8c19cb8eb08c9590545ad8a1968a7152951f7e12746e2d7901d542fed9",
|
||||
"scaling_budget": 1.5,
|
||||
"_note": "#2046: F35 qualified-constructor captures now emit @reference.qualified-name + a simple-name @reference.name on `new Ns.Foo()`/`new A.B.Foo()`; namespace_declaration/file_scoped_namespace_declaration now emit @declaration.namespace name captures (feeding the non-destructive namespacePrefix sidecar for `new B.Foo()` same-tail disambiguation). + csharp-interface-only-base and csharp-namespace-qualified-ctor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.11)."
|
||||
},
|
||||
"rust": {
|
||||
"fingerprint": "ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) \u2014 legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f."
|
||||
},
|
||||
"php": {
|
||||
"fingerprint": "bc2c27c5ba26d5aea61142a2a99fb772222f5b969205260eb7a71b4c0bd73cdb",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04).",
|
||||
"_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class — fixture count 138→140, fingerprint drift expected."
|
||||
},
|
||||
"ruby": {
|
||||
"fingerprint": "b5ea93bb3d0469c3821a8c70f5d5991c6f326e41097c119ad691154301dcc753",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1956 synth-widening: + ruby-qualified-base fixture; synth now reduces a scope_resolution superclass (class C < Mod::Super) to its trailing constant (matching the #1940 legacy leg), at parity. Linear (~1.03). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "F62: + scope_resolution class/module declaration captures \u2014 fixture count 78\u219281, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) \u2014 pure fixture-corpus drift, scope-extractor captures unchanged; 81\u219282. #1991: + ruby-nested-mixin-tail-collision fixture (85\u219286). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb."
|
||||
},
|
||||
"swift": {
|
||||
"fingerprint": "53325c6345161c5a495f997297af5a24fb718fd3e6647040160f8ab2a2c8e4c0",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1956: swift-qualified-base fixture + heritage-bearing scale source (class: Base, Serviceable \u2014 extends + protocol conformance); linear (~1.03)."
|
||||
},
|
||||
"dart": {
|
||||
"fingerprint": "a9e882b537765e8fd0ddfcd33b38b253dd86fc5ddffa6e4bf5a85ed8ee615eaa",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#939: dart added to the scope-capture bench with the registry-primary migration. Heritage-bearing scale source (Entity extends Base implements Marker) gates the @reference.inherits synth + the postfix-chain reference walk at scale. emitDartScopeCaptures threads tree-sitter captured nodes (no findNodeAtRange root-walk), so it is linear (~1.0).",
|
||||
"_rebaselined": "#1970 review + tri-review follow-ups: constructor-call retag, cascade calls, built-in suppression, enum scope, #1926 F24/F25, named-ctor dedup (crash fix), container-name binding suppression; heritage file-affinity resolution. Fixtures: member-call-contexts, constructor-body, named-constructor-body, heritage-name-collision, construct-cascade."
|
||||
},
|
||||
"java": {
|
||||
"fingerprint": "9b29cafe32873b4902bda311bd089ffc04efe08f13557b966d29544be514080a",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC<T>), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "#1928 / #2045: F35 adds qualified + qualified-generic constructor query captures (`new pkg.Foo()`, `new a.b.Foo()`, `new pkg.Box<T>()`); F38 synthesizes `@reference.call.constructor` on `super(...)`/`this(...)` explicit_constructor_invocation nodes; F41 generic-aware stripQualifier in interpret (type-binding normalization). + java-qualified-constructor and java-explicit-constructor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.06)."
|
||||
},
|
||||
"typescript": {
|
||||
"fingerprint": "3f44a4a6892698df2d145c8ff2812c3b318807648983c88aca28fbd694f172f9",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures \u2014 fingerprint drift expected.",
|
||||
"_note": "#1968: F44, F85, F87 \u2014 fingerprint drift expected."
|
||||
},
|
||||
"javascript": {
|
||||
"fingerprint": "d72f03c6c502235d2d4b74d66baa5c7d361f040d7a1b72e84acad61210d05ae8",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (extends Base); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.",
|
||||
"_rebaselined": "#1956 synth-widening: + javascript-qualified-base fixture; synthesizeJsInheritanceReferences now handles a member_expression base (class S extends ns.Base -> Base), matching the #1940 legacy leg + the TS terminalTsTypeNameNode property_identifier case, at parity. Linear (~1.05). | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged."
|
||||
},
|
||||
"kotlin": {
|
||||
"fingerprint": "a16400622892183581b8f5f8fa01f07842d19b8cf49ed021df52bd17009d749f",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (: Base()); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.",
|
||||
"_rebaselined": "#1956 synth-widening: + kotlin-qualified-base fixture; synthesizeKotlinInheritanceReferences now handles the explicit_delegation form (class F : Iface by d -> Iface), matching the #1940 legacy leg, at parity. Linear (~0.87). | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1930 F45: default parameters now emit optional-arity metadata; capture fingerprint changes, scaling remains linear."
|
||||
}
|
||||
}
|
||||
409
gitnexus/bench/scope-capture/measure.mjs
Normal file
409
gitnexus/bench/scope-capture/measure.mjs
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
/**
|
||||
* Unified build-free scope-capture measurement harness for every currently
|
||||
* benchmarked language (the ones with a `*-pipeline-benchmark.test.ts`):
|
||||
* go, csharp, rust, php, ruby, cobol, swift — plus python lives in its own
|
||||
* `bench/python-scope/` harness (richer: it also covers import resolution).
|
||||
*
|
||||
* For each language it:
|
||||
* - times `emit<Lang>ScopeCaptures` on a synthetic DAO-style source at two
|
||||
* sizes (250 / 800 top-level entities), reporting elapsed_ms + a scaling
|
||||
* ratio `(t_large/t_small)/(800/250)`: ~1.0 is linear, ~3.2 is quadratic
|
||||
* (the O(matches × rootChildren) shape #1848 hit in Go);
|
||||
* - computes an order-independent sha256 fingerprint over the whole
|
||||
* `lang-resolution/<lang>-*` fixture corpus + a fixed 20-entity synthetic
|
||||
* source, as the correctness gate.
|
||||
*
|
||||
* Build-free: imports the `.ts` hotpaths through tsx
|
||||
* (`node --import tsx bench/scope-capture/measure.mjs`). Static `.ts` imports
|
||||
* work; a top-level `await import()` breaks tsx's lexer.
|
||||
*
|
||||
* Without args: prints one JSON object per language.
|
||||
* With `--check`: asserts each language's fingerprint == its committed baseline
|
||||
* (baselines.json) AND scaling_ratio < that language's 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 { emitGoScopeCaptures } from '../../src/core/ingestion/languages/go/index.ts';
|
||||
import { emitCsharpScopeCaptures } from '../../src/core/ingestion/languages/csharp/index.ts';
|
||||
import { emitRustScopeCaptures } from '../../src/core/ingestion/languages/rust/index.ts';
|
||||
import { emitPhpScopeCaptures } from '../../src/core/ingestion/languages/php/index.ts';
|
||||
import { emitRubyScopeCaptures } from '../../src/core/ingestion/languages/ruby/index.ts';
|
||||
import { emitCobolScopeCaptures } from '../../src/core/ingestion/languages/cobol/index.ts';
|
||||
import { emitSwiftScopeCaptures } from '../../src/core/ingestion/languages/swift/index.ts';
|
||||
import { emitTsScopeCaptures } from '../../src/core/ingestion/languages/typescript/index.ts';
|
||||
import { emitJsScopeCaptures } from '../../src/core/ingestion/languages/javascript/index.ts';
|
||||
import { emitKotlinScopeCaptures } from '../../src/core/ingestion/languages/kotlin/index.ts';
|
||||
import { emitJavaScopeCaptures } from '../../src/core/ingestion/languages/java/index.ts';
|
||||
import { emitCScopeCaptures } from '../../src/core/ingestion/languages/c/index.ts';
|
||||
import { emitCppScopeCaptures } from '../../src/core/ingestion/languages/cpp/index.ts';
|
||||
import { emitDartScopeCaptures } from '../../src/core/ingestion/languages/dart/index.ts';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURE_ROOT = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'lang-resolution');
|
||||
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
|
||||
|
||||
// ---- correctness fingerprint (order-independent; mirrors python harness) ----
|
||||
|
||||
function canonicalizeMatch(match) {
|
||||
const parts = [];
|
||||
for (const tag of Object.keys(match)) {
|
||||
const cap = match[tag];
|
||||
if (cap === undefined || cap === null || cap.range === undefined) {
|
||||
parts.push(`${tag}|<no-range>`);
|
||||
continue;
|
||||
}
|
||||
const r = cap.range;
|
||||
parts.push(`${tag}|${cap.text}|${r.startLine}:${r.startCol}-${r.endLine}:${r.endCol}`);
|
||||
}
|
||||
parts.sort();
|
||||
return parts.join(';');
|
||||
}
|
||||
|
||||
function digestCaptures(matches) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(matches.map(canonicalizeMatch).sort().join('\n'))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/** All fixture files for a language, sorted by repo-relative key. */
|
||||
function collectFixtures(prefix, exts) {
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(FIXTURE_ROOT, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || !entry.name.startsWith(`${prefix}-`)) continue;
|
||||
const stack = [path.join(FIXTURE_ROOT, entry.name)];
|
||||
while (stack.length) {
|
||||
const dir = stack.pop();
|
||||
for (const c of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, c.name);
|
||||
if (c.isDirectory()) stack.push(p);
|
||||
else if (exts.some((e) => c.name.endsWith(e))) {
|
||||
out.push({ key: path.relative(FIXTURE_ROOT, p).split(path.sep).join('/'), absPath: p });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => a.key.localeCompare(b.key));
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- per-language config: synthetic DAO generators + fixture globs ----
|
||||
|
||||
const LANGS = [
|
||||
{
|
||||
name: 'go',
|
||||
emit: emitGoScopeCaptures,
|
||||
fixturePrefix: 'go',
|
||||
exts: ['.go'],
|
||||
file: 'bench.go',
|
||||
// Heritage-bearing: each Entity embeds Base (Go inheritance = struct
|
||||
// embedding) so the @reference.inherits synth (#1951) is driven at scale.
|
||||
header:
|
||||
'package generated\n\ntype Base struct{}\n\nfunc (b *Base) BaseMethod() string { return "base" }\n\n',
|
||||
unit: (n) =>
|
||||
`type Entity${n} struct {\n\tBase\n\tid int64\n\tname string\n}\n\n` +
|
||||
`func (e *Entity${n}) GetID() int64 { return e.id }\n` +
|
||||
`func (e *Entity${n}) SetName(v string) { e.name = v }\n\n`,
|
||||
},
|
||||
{
|
||||
name: 'csharp',
|
||||
emit: emitCsharpScopeCaptures,
|
||||
fixturePrefix: 'csharp',
|
||||
exts: ['.cs'],
|
||||
file: 'bench.cs',
|
||||
// Heritage-bearing: extends Base + implements IEntity (both forms) so the
|
||||
// @reference.inherits synth (#1951) is driven at scale, not just the base loop.
|
||||
header:
|
||||
'namespace Generated;\n\npublic class Base { }\n\npublic interface IEntity {\n long GetId();\n}\n\n',
|
||||
unit: (n) =>
|
||||
`public class Entity${n} : Base, IEntity {\n` +
|
||||
` public long Id;\n public string Name;\n` +
|
||||
` public long GetId() { return Id; }\n` +
|
||||
` public void SetName(string v) { Name = v; }\n}\n\n`,
|
||||
},
|
||||
{
|
||||
name: 'rust',
|
||||
emit: emitRustScopeCaptures,
|
||||
fixturePrefix: 'rust',
|
||||
exts: ['.rs'],
|
||||
file: 'bench.rs',
|
||||
// Heritage-bearing: `impl Shape for Entity_n` (Rust inheritance lives on
|
||||
// impl_item) so the @reference.inherits trait-impl synth (#1951) is driven
|
||||
// at scale. The two methods move into the trait impl to keep unit size flat.
|
||||
header: 'trait Shape {\n fn area(&self) -> i64;\n fn name(&self) -> String;\n}\n\n',
|
||||
unit: (n) =>
|
||||
`struct Entity${n} {\n id: i64,\n name: String,\n}\n\n` +
|
||||
`impl Shape for Entity${n} {\n` +
|
||||
` fn area(&self) -> i64 { self.id }\n` +
|
||||
` fn name(&self) -> String { self.name.clone() }\n}\n\n`,
|
||||
},
|
||||
{
|
||||
name: 'php',
|
||||
emit: emitPhpScopeCaptures,
|
||||
fixturePrefix: 'php',
|
||||
exts: ['.php'],
|
||||
file: 'bench.php',
|
||||
// Heritage-bearing: extends Base + uses a trait (both forms) so the
|
||||
// @reference.inherits synth (#1951) is driven at scale.
|
||||
header:
|
||||
'<?php\n\nclass Base {}\n\ntrait Auditable {\n public function audit() { return true; }\n}\n\n',
|
||||
unit: (n) =>
|
||||
`class Entity${n} extends Base {\n` +
|
||||
` use Auditable;\n` +
|
||||
` public $id;\n public $name;\n` +
|
||||
` function getId() { return $this->id; }\n` +
|
||||
` function setName($v) { $this->name = $v; }\n}\n\n`,
|
||||
},
|
||||
{
|
||||
name: 'ruby',
|
||||
emit: emitRubyScopeCaptures,
|
||||
fixturePrefix: 'ruby',
|
||||
exts: ['.rb'],
|
||||
file: 'bench.rb',
|
||||
// Heritage-bearing: `< Base` superclass + `include Trackable` mixin (both
|
||||
// forms) so the @reference.inherits synth (#1951) is driven at scale.
|
||||
header:
|
||||
'class Base\n def base_id\n @id\n end\nend\n\nmodule Trackable\n def track\n @tracked = true\n end\nend\n\n',
|
||||
unit: (n) =>
|
||||
`class Entity${n} < Base\n` +
|
||||
` include Trackable\n` +
|
||||
` def get_id\n @id\n end\n` +
|
||||
` def set_name(v)\n @name = v\n end\nend\n\n`,
|
||||
},
|
||||
{
|
||||
name: 'cobol',
|
||||
emit: emitCobolScopeCaptures,
|
||||
fixturePrefix: 'cobol',
|
||||
exts: ['.cbl', '.cpy'],
|
||||
file: 'bench.cbl',
|
||||
header:
|
||||
' IDENTIFICATION DIVISION.\n' +
|
||||
' PROGRAM-ID. BENCH.\n' +
|
||||
' PROCEDURE DIVISION.\n',
|
||||
unit: (n) => ` PARA-${String(n).padStart(5, '0')}.\n DISPLAY "P${n}".\n`,
|
||||
},
|
||||
{
|
||||
name: 'c',
|
||||
emit: emitCScopeCaptures,
|
||||
fixturePrefix: 'c',
|
||||
exts: ['.c', '.h'],
|
||||
file: 'bench.c',
|
||||
// C has no inheritance construct — flat scale source. Added (was unbenched);
|
||||
// adding it exposed + fixed the same O(n²) findNodeAtRange root-walk (#1956).
|
||||
header: '#include <stdint.h>\n#include <stddef.h>\n\ntypedef int64_t id_t;\n\n',
|
||||
unit: (n) =>
|
||||
`typedef struct Entity${n} {\n id_t id;\n const char *name;\n} Entity${n};\n\n` +
|
||||
`id_t entity_${n}_get_id(Entity${n} *e) { return e->id; }\n` +
|
||||
`void entity_${n}_set_name(Entity${n} *e, const char *v) { e->name = v; }\n\n`,
|
||||
},
|
||||
{
|
||||
name: 'cpp',
|
||||
emit: emitCppScopeCaptures,
|
||||
fixturePrefix: 'cpp',
|
||||
exts: ['.cpp', '.cc', '.cxx', '.hpp', '.h'],
|
||||
file: 'bench.cpp',
|
||||
// Heritage-bearing: `: public Base, public Mixin` (single + multiple
|
||||
// inheritance) drives emitCppInheritanceCaptures (#1951) at scale. Added
|
||||
// (was unbenched); adding it exposed + fixed the same O(n²) root-walk (#1956).
|
||||
header:
|
||||
'#include <string>\n\nclass Base {\n public:\n long baseId() const { return 0; }\n};\n\nclass Mixin {\n public:\n void mix() {}\n};\n\n',
|
||||
unit: (n) =>
|
||||
`class Entity${n} : public Base, public Mixin {\n public:\n long id;\n std::string name;\n` +
|
||||
` long getId() const { return id; }\n` +
|
||||
` void setName(std::string v) { name = v; }\n};\n\n`,
|
||||
},
|
||||
{
|
||||
name: 'swift',
|
||||
emit: emitSwiftScopeCaptures,
|
||||
fixturePrefix: 'swift',
|
||||
exts: ['.swift'],
|
||||
file: 'bench.swift',
|
||||
// Heritage-bearing: inherits Base + conforms to Serviceable (both forms) so
|
||||
// the @reference.inherits synth (#1951) is driven at scale.
|
||||
header:
|
||||
'class Base {\n func ping() -> String { return "base" }\n}\n\nprotocol Serviceable {\n func serve() -> String\n}\n\n',
|
||||
unit: (n) =>
|
||||
`class Entity${n}: Base, Serviceable {\n` +
|
||||
` var id: Int64 = 0\n var name: String = ""\n` +
|
||||
` func getId() -> Int64 { return self.id }\n` +
|
||||
` func serve() -> String { return self.name }\n}\n\n`,
|
||||
},
|
||||
{
|
||||
name: 'dart',
|
||||
emit: emitDartScopeCaptures,
|
||||
fixturePrefix: 'dart',
|
||||
exts: ['.dart'],
|
||||
file: 'bench.dart',
|
||||
// Heritage-bearing: `extends Base` (generic @reference.inherits pre-pass)
|
||||
// + `implements Marker` (Dart `implements <class>` → IMPLEMENTS marker) so
|
||||
// both heritage paths and the postfix-chain reference walk run at scale.
|
||||
header:
|
||||
'class Base {\n String ping() { return "base"; }\n}\n\nabstract class Marker {\n String mark();\n}\n\n',
|
||||
unit: (n) =>
|
||||
`class Entity${n} extends Base implements Marker {\n` +
|
||||
` int id = 0;\n String name = '';\n` +
|
||||
` int getId() { return this.id; }\n` +
|
||||
` String mark() { return this.name; }\n}\n\n`,
|
||||
},
|
||||
{
|
||||
name: 'java',
|
||||
emit: emitJavaScopeCaptures,
|
||||
fixturePrefix: 'java',
|
||||
exts: ['.java'],
|
||||
file: 'bench.java',
|
||||
// Java was previously unbenched. Heritage-bearing: extends Base + implements
|
||||
// Marker (both forms) so the @reference.inherits synth (#1951) is driven at scale.
|
||||
header: 'package generated;\n\nclass Base {}\n\ninterface Marker {}\n\n',
|
||||
unit: (n) =>
|
||||
`class Entity${n} extends Base implements Marker {\n` +
|
||||
` long id = 0L;\n String name = "";\n` +
|
||||
` public long getId() { return this.id; }\n` +
|
||||
` public void setName(String v) { this.name = v; }\n}\n\n`,
|
||||
},
|
||||
{
|
||||
name: 'typescript',
|
||||
emit: emitTsScopeCaptures,
|
||||
fixturePrefix: 'typescript',
|
||||
exts: ['.ts', '.tsx'],
|
||||
file: 'bench.ts',
|
||||
// Inheritance-bearing units so the @reference.inherits synth pass (#1951)
|
||||
// is exercised at scale, not just the base capture loop.
|
||||
header: 'class Base {}\n\n',
|
||||
unit: (n) =>
|
||||
`class Entity${n} extends Base {\n` +
|
||||
` id: number = 0;\n name: string = '';\n` +
|
||||
` getId(): number { return this.id; }\n` +
|
||||
` setName(v: string): void { this.name = v; }\n}\n\n`,
|
||||
},
|
||||
{
|
||||
name: 'javascript',
|
||||
emit: emitJsScopeCaptures,
|
||||
fixturePrefix: 'javascript',
|
||||
exts: ['.js', '.jsx', '.mjs', '.cjs'],
|
||||
file: 'bench.js',
|
||||
header: 'class Base {}\n\n',
|
||||
unit: (n) =>
|
||||
`class Entity${n} extends Base {\n` +
|
||||
` getId() { return this.id; }\n` +
|
||||
` setName(v) { this.name = v; }\n}\n\n`,
|
||||
},
|
||||
{
|
||||
name: 'kotlin',
|
||||
emit: emitKotlinScopeCaptures,
|
||||
fixturePrefix: 'kotlin',
|
||||
exts: ['.kt', '.kts'],
|
||||
file: 'bench.kt',
|
||||
header: 'open class Base\n\n',
|
||||
unit: (n) =>
|
||||
`class Entity${n} : Base() {\n` +
|
||||
` var id: Long = 0\n var name: String = ""\n` +
|
||||
` fun getId(): Long { return id }\n` +
|
||||
` fun setName(v: String) { name = v }\n}\n\n`,
|
||||
},
|
||||
];
|
||||
|
||||
function generate(lang, entityCount) {
|
||||
let src = lang.header;
|
||||
for (let i = 0; i < entityCount; i++) src += lang.unit(i);
|
||||
return src;
|
||||
}
|
||||
|
||||
// ---- 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 timeEmit(emit, src, file, reps) {
|
||||
emit(src, `warmup-${file}`); // warm parser/query JIT (not counted)
|
||||
const samples = [];
|
||||
let count = 0;
|
||||
for (let i = 0; i < reps; i++) {
|
||||
const start = process.hrtime.bigint();
|
||||
const out = emit(src, file);
|
||||
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
|
||||
count = out.length;
|
||||
}
|
||||
return { ms: median(samples), count };
|
||||
}
|
||||
|
||||
const SMALL = 250;
|
||||
const LARGE = 800;
|
||||
const REPS = 7;
|
||||
|
||||
function measureLang(lang) {
|
||||
// Correctness fingerprint over the fixture corpus + a fixed 20-entity source.
|
||||
const perFixture = [];
|
||||
let groups = 0;
|
||||
for (const { key, absPath } of collectFixtures(lang.fixturePrefix, lang.exts)) {
|
||||
const matches = lang.emit(fs.readFileSync(absPath, 'utf8'), absPath);
|
||||
groups += matches.length;
|
||||
perFixture.push(`${key}\t${matches.length}\t${digestCaptures(matches)}`);
|
||||
}
|
||||
const daoMatches = lang.emit(generate(lang, 20), `synthetic-dao-20${path.extname(lang.file)}`);
|
||||
groups += daoMatches.length;
|
||||
perFixture.push(`synthetic:dao-20\t${daoMatches.length}\t${digestCaptures(daoMatches)}`);
|
||||
const fingerprint = crypto
|
||||
.createHash('sha256')
|
||||
.update(perFixture.sort().join('\n'))
|
||||
.digest('hex');
|
||||
|
||||
// Scaling.
|
||||
const small = timeEmit(lang.emit, generate(lang, SMALL), lang.file, REPS);
|
||||
const large = timeEmit(lang.emit, generate(lang, LARGE), lang.file, REPS);
|
||||
const scalingRatio = small.ms > 0 ? large.ms / small.ms / (LARGE / SMALL) : 0;
|
||||
|
||||
return {
|
||||
language: lang.name,
|
||||
elapsed_ms_small: Number(small.ms.toFixed(2)),
|
||||
elapsed_ms_large: Number(large.ms.toFixed(2)),
|
||||
scaling_ratio: Number(scalingRatio.toFixed(3)),
|
||||
capture_groups_small: small.count,
|
||||
capture_groups_large: large.count,
|
||||
fingerprint,
|
||||
capture_groups_fp: groups,
|
||||
fixture_count: perFixture.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- run ----
|
||||
|
||||
const CHECK = process.argv.includes('--check');
|
||||
const results = LANGS.map(measureLang);
|
||||
|
||||
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.language];
|
||||
if (base === undefined) {
|
||||
failures.push(`${r.language}: no baseline recorded`);
|
||||
continue;
|
||||
}
|
||||
if (r.fingerprint !== base.fingerprint) {
|
||||
failures.push(
|
||||
`${r.language}: capture fingerprint drift (got ${r.fingerprint}, expected ${base.fingerprint})`,
|
||||
);
|
||||
}
|
||||
if (r.scaling_ratio >= base.scaling_budget) {
|
||||
failures.push(
|
||||
`${r.language}: scaling ratio ${r.scaling_ratio} >= budget ${base.scaling_budget} ` +
|
||||
`(${SMALL}->${LARGE} ms ${r.elapsed_ms_small}->${r.elapsed_ms_large})`,
|
||||
);
|
||||
}
|
||||
process.stdout.write(JSON.stringify(r) + '\n');
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
for (const f of failures) process.stderr.write(`[scope-capture --check] FAIL: ${f}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stderr.write(`[scope-capture --check] PASS (${results.length} languages)\n`);
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ const path = require('path');
|
|||
const { spawnSync } = require('child_process');
|
||||
const { acquireHookSlot } = require('./hook-lock.cjs');
|
||||
const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
|
||||
const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
|
||||
|
||||
function readInput() {
|
||||
try {
|
||||
|
|
@ -315,7 +316,7 @@ function buildStaleIndexHint(gitNexusDir, cwd) {
|
|||
|
||||
if (currentHead === lastCommit) return '';
|
||||
|
||||
const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`;
|
||||
const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings });
|
||||
return (
|
||||
`[GitNexus] index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` +
|
||||
`Run \`${analyzeCmd}\` to refresh the knowledge graph.`
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const path = require('path');
|
|||
const { spawnSync } = require('child_process');
|
||||
const { acquireHookSlot } = require('./hook-lock.cjs');
|
||||
const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
|
||||
const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
|
||||
|
||||
/**
|
||||
* Read JSON input from stdin synchronously.
|
||||
|
|
@ -340,7 +341,7 @@ function handlePostToolUse(input) {
|
|||
// If HEAD matches last indexed commit, no reindex needed
|
||||
if (currentHead && currentHead === lastCommit) return;
|
||||
|
||||
const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`;
|
||||
const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings });
|
||||
sendHookResponse(
|
||||
'PostToolUse',
|
||||
`GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` +
|
||||
|
|
|
|||
323
gitnexus/hooks/claude/resolve-analyze-cmd.cjs
Normal file
323
gitnexus/hooks/claude/resolve-analyze-cmd.cjs
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
/**
|
||||
* Single source of truth for how docs, hooks, and warnings invoke gitnexus.
|
||||
*
|
||||
* Automatically selects a working invocation path:
|
||||
* 1. Global `gitnexus` on PATH (best — no install step)
|
||||
* 2. npm 11+ with pnpm on PATH → `pnpm --allow-build=… dlx` (avoids the npx
|
||||
* arborist crash *and* pnpm 10+ ignored-build-script failures, #1939)
|
||||
* 3. npm < 11 with npm on PATH → `npx` (works; simpler than pnpm dlx)
|
||||
* 4. pnpm-only → `pnpm --allow-build=… dlx`
|
||||
* 5. Last resort → `npx` (warned on npm 11+ from analyze.ts)
|
||||
*
|
||||
* The `--allow-build` flags MUST precede the `dlx` token. pnpm < 10.14 keeps
|
||||
* `dlx` in its argv escape list, so flags placed *after* `dlx` are parsed as
|
||||
* package specs (ERR_PNPM_SPEC_NOT_SUPPORTED). The pre-`dlx` position parses
|
||||
* into dlx's allow-build option and has been honored since pnpm 10.2.0 (#1939).
|
||||
*
|
||||
* This stays self-contained CJS because the Claude/Antigravity hooks run as
|
||||
* standalone files copied into the user's hook dir, where no package import is
|
||||
* available. The CLI reuses this module from src/cli/resolve-invocation.ts via
|
||||
* createRequire rather than re-implementing it. Two committed copies must stay
|
||||
* byte-identical (enforced by resolve-invocation.test.ts) — edit both together:
|
||||
* gitnexus/hooks/claude/ (the canonical copy the CLI and `gitnexus setup` read)
|
||||
* and gitnexus-claude-plugin/hooks/. A THIRD copy is written at runtime to
|
||||
* `<repo>/.gitnexus/run.cjs` by `gitnexus analyze` (ai-context.ts) so docs can
|
||||
* reference it directly via the `require.main === module` exec tail below; that
|
||||
* copy is gitignored and refreshed on every analyze, so it cannot drift for long.
|
||||
*/
|
||||
|
||||
const { execFileSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const NPX_REF = 'gitnexus@latest';
|
||||
|
||||
// Native packages whose postinstall must run under pnpm 10+ (blocked by default).
|
||||
const PNPM_ALLOW_BUILD_BASE = ['@ladybugdb/core', 'gitnexus', 'tree-sitter'];
|
||||
const PNPM_ALLOW_BUILD_EMBEDDINGS = ['onnxruntime-node'];
|
||||
|
||||
// Version-probe timeout, kept under Claude Code's 10s hook budget. PATH presence
|
||||
// detection is now spawn-free (resolveOnPath scans PATH directly), so the only
|
||||
// subprocesses left are the version probes: in a linked worktree the stale-index
|
||||
// hook first runs `git rev-parse --git-common-dir` (~2s) and `git rev-parse HEAD`
|
||||
// (~3s); the pnpm path then adds up to two 1s `--version` probes (npm, pnpm), so
|
||||
// the worst case is ~7s — within budget. A healthy `--version` returns in well
|
||||
// under a second, so the realistic cost is far lower.
|
||||
const PROBE_TIMEOUT_MS = 1000;
|
||||
|
||||
/**
|
||||
* Absolute path to `command` on PATH, or null — a pure-Node, spawn-free lookup
|
||||
* that mirrors how a shell resolves a bare command name: each PATH dir × the
|
||||
* platform's executable extensions (PATHEXT on Windows; the bare name + X_OK on
|
||||
* POSIX). This replaces the former `where`/`which` subprocess (#1938 "Option A"):
|
||||
* it is byte-for-byte identical on every OS, with no dependency on the probe
|
||||
* binary being reachable (a sanitized PATH that drops System32 / `/usr/bin` no
|
||||
* longer defeats detection), no shell-spawn surface (CVE-2024-27980), and no
|
||||
* spawn timeout to tune. On Windows it matches PATHEXT extensions ONLY — exactly
|
||||
* what `where`/cmd.exe resolve — so neither an un-spawnable `.ps1`-only shim (not
|
||||
* in default PATHEXT) nor a bare extensionless file (which the shell cannot launch
|
||||
* as `command`) is a false positive. `preferExecExt` returns a recognized
|
||||
* `.cmd`/`.bat`/`.exe` shim ahead of an exotic PATHEXT hit (e.g. `.COM`) when both
|
||||
* match, matching what a user would actually launch. Pure (platform/env injectable)
|
||||
* so it is unit-testable without touching the host PATH.
|
||||
*/
|
||||
function resolveOnPath(
|
||||
command,
|
||||
preferExecExt = false,
|
||||
{ platform = process.platform, env = process.env } = {},
|
||||
) {
|
||||
const pathValue = env.PATH || env.Path || env.path || '';
|
||||
if (!pathValue) return null;
|
||||
const isWin = platform === 'win32';
|
||||
const exts = isWin
|
||||
? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD')
|
||||
.split(';')
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean)
|
||||
.map((e) => (e.startsWith('.') ? e : `.${e}`))
|
||||
: [''];
|
||||
let weakHit = null;
|
||||
// Split on the host's PATH delimiter. `platform` is injected only to choose the
|
||||
// extension/exec-bit rules; the PATH string is always host-format, so it must
|
||||
// split on the host delimiter (`path.delimiter`) — in production `platform` IS
|
||||
// the host, so they coincide. (Deriving the delimiter from an injected platform
|
||||
// would split a Windows drive-letter path `C:\…` at its colon under a POSIX
|
||||
// injection.)
|
||||
for (const dir of pathValue.split(path.delimiter).filter(Boolean)) {
|
||||
for (const ext of exts) {
|
||||
const candidate = path.join(dir, `${command}${ext}`);
|
||||
try {
|
||||
if (!fs.statSync(candidate).isFile()) continue;
|
||||
if (!isWin) fs.accessSync(candidate, fs.constants.X_OK);
|
||||
// Prefer a runnable .cmd/.bat/.exe shim; remember an exotic PATHEXT hit
|
||||
// (e.g. .COM) only as a last resort if nothing better turns up.
|
||||
if (isWin && preferExecExt && !/\.(cmd|bat|exe)$/i.test(ext)) {
|
||||
weakHit = weakHit || candidate;
|
||||
continue;
|
||||
}
|
||||
return candidate;
|
||||
} catch {
|
||||
/* not a runnable file here — try the next candidate */
|
||||
}
|
||||
}
|
||||
}
|
||||
return weakHit;
|
||||
}
|
||||
|
||||
// One spawn of `<command> --version` → { major, minor } (each null when
|
||||
// unreadable). Version injection happens at the resolver seam (getNpmMajorVersion
|
||||
// / formatPnpmAllowBuildArgs), so this stays a pure real-process probe.
|
||||
function probeVersion(command) {
|
||||
try {
|
||||
const output = execFileSync(command, ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
// On Windows, npm/pnpm resolve to `.cmd` shims; execFileSync does no
|
||||
// PATHEXT resolution and Node refuses to spawn `.cmd`/`.bat` without a
|
||||
// shell (CVE-2024-27980), so a bare `<command> --version` ENOENTs and the
|
||||
// probe would wrongly report a present tool as absent. A shell lets the OS
|
||||
// resolve the shim. POSIX needs no shell (direct PATH lookup works).
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
// Find the first line that starts with a version token (`MAJOR.MINOR`,
|
||||
// optional `v` prefix) rather than splitting the whole output — pnpm/npm
|
||||
// under Corepack or with an update notice can print a banner line on stdout
|
||||
// before the version (stderr is already dropped via the stdio config).
|
||||
const versionLine = output
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => /^v?\d+\.\d+/.test(l));
|
||||
const match = versionLine ? versionLine.match(/^v?(\d+)\.(\d+)/) : null;
|
||||
return {
|
||||
major: match ? Number(match[1]) : null,
|
||||
minor: match ? Number(match[2]) : null,
|
||||
};
|
||||
} catch {
|
||||
return { major: null, minor: null };
|
||||
}
|
||||
}
|
||||
|
||||
// `deps` is the single injection seam: an explicitly provided key — including a
|
||||
// `null` value, detected via `in` — is honored as-is so tests can simulate an
|
||||
// absent tool without spawning; an absent key falls through to the real probe.
|
||||
function getNpmMajorVersion(deps = {}) {
|
||||
return 'npmMajor' in deps ? deps.npmMajor : probeVersion('npm').major;
|
||||
}
|
||||
|
||||
/**
|
||||
* `--allow-build` flags for the pre-`dlx` position. Emitted for pnpm >= 10.2
|
||||
* (where the flag exists, and pnpm 10+ blocks build scripts by default). Omitted
|
||||
* below 10.2: pnpm < 10 runs build scripts anyway, and pnpm 10.0/10.1 lack the
|
||||
* flag (it would be rejected as an unknown option). `alwaysAllowBuild` forces the
|
||||
* flags for committed documentation, which cannot probe the reader's pnpm.
|
||||
*/
|
||||
function formatPnpmAllowBuildArgs(options = {}, deps = {}) {
|
||||
if (!options.alwaysAllowBuild) {
|
||||
const { major, minor } =
|
||||
'pnpmMajor' in deps
|
||||
? { major: deps.pnpmMajor, minor: 'pnpmMinor' in deps ? deps.pnpmMinor : null }
|
||||
: probeVersion('pnpm');
|
||||
const lacksAllowBuild =
|
||||
major !== null && (major < 10 || (major === 10 && minor !== null && minor < 2));
|
||||
if (lacksAllowBuild) return [];
|
||||
}
|
||||
const pkgs = [...PNPM_ALLOW_BUILD_BASE];
|
||||
if (options.embeddings) pkgs.push(...PNPM_ALLOW_BUILD_EMBEDDINGS);
|
||||
return pkgs.map((p) => `--allow-build=${p}`);
|
||||
}
|
||||
|
||||
/** Fixed install-free command for committed AGENTS.md / SKILL.md (pnpm >= 10.2). */
|
||||
function formatDocumentationDlxCommand(gitnexusArgs, options = {}) {
|
||||
const flags = formatPnpmAllowBuildArgs({ ...options, alwaysAllowBuild: true }).join(' ');
|
||||
const prefix = flags ? `${flags} ` : '';
|
||||
return `pnpm ${prefix}dlx ${NPX_REF} ${gitnexusArgs}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `gitnexus` | `pnpm` | `npx`. `GITNEXUS_INVOCATION` forces a mode
|
||||
* (test/escape hatch). `probe` is injectable so the preference order can be
|
||||
* unit-tested without spawning; it defaults to the real PATH probe. `deps` can
|
||||
* inject `{ npmMajor, pnpmMajor }` for tests.
|
||||
*/
|
||||
function resolveInvocationMode(probe = resolveOnPath, deps = {}) {
|
||||
const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase();
|
||||
if (forced === 'gitnexus' || forced === 'pnpm' || forced === 'npx') {
|
||||
return forced;
|
||||
}
|
||||
if (probe('gitnexus', true)) return 'gitnexus';
|
||||
|
||||
const npmMajor = getNpmMajorVersion(deps);
|
||||
// pnpm presence: prefer an explicit `pnpmPresent` flag (set by
|
||||
// formatAnalyzeCommand, which falls back to a PATH probe when the version is
|
||||
// unreadable) so a present-but-unparseable pnpm — slow probe, Corepack
|
||||
// banner — still selects pnpm instead of the npx crash path. Otherwise an
|
||||
// injected version (a successful `pnpm --version` proves presence)
|
||||
// short-circuits the `which pnpm` probe; failing both, fall back to PATH.
|
||||
const hasPnpm =
|
||||
'pnpmPresent' in deps
|
||||
? deps.pnpmPresent
|
||||
: 'pnpmMajor' in deps
|
||||
? deps.pnpmMajor !== null
|
||||
: Boolean(probe('pnpm'));
|
||||
|
||||
// npm 11+ npx install crash (#1939) — prefer pnpm dlx when available.
|
||||
if (hasPnpm && npmMajor !== null && npmMajor >= 11) return 'pnpm';
|
||||
// npm 10 and earlier: npx works; prefer it over pnpm dlx when npm is present.
|
||||
if (npmMajor !== null && npmMajor < 11) return 'npx';
|
||||
// npm absent or unreadable — use pnpm if present (with allow-build flags).
|
||||
if (hasPnpm) return 'pnpm';
|
||||
|
||||
return 'npx';
|
||||
}
|
||||
|
||||
function formatPnpmDlxCommand(gitnexusArgs, options = {}, deps = {}) {
|
||||
const flags = formatPnpmAllowBuildArgs(options, deps).join(' ');
|
||||
const prefix = flags ? `${flags} ` : '';
|
||||
return `pnpm ${prefix}dlx ${NPX_REF} ${gitnexusArgs}`;
|
||||
}
|
||||
|
||||
function formatAnalyzeCommand(options = {}, deps = {}) {
|
||||
const suffix = options.embeddings ? ' --embeddings' : '';
|
||||
// Keep the stale-index hook budget tight by querying each tool at most once.
|
||||
// The memoized `probe` is a spawn-free PATH scan (resolveOnPath) shared with
|
||||
// resolveInvocationMode, so `gitnexus` is scanned only once and no subprocess
|
||||
// is spawned for presence. pnpm's *version* is still captured by a single
|
||||
// `pnpm --version` (the allow-build gate needs the number), which also proves
|
||||
// presence; the memoized scan only re-checks pnpm when that version is
|
||||
// unreadable. Injected deps (tests) and forced/global modes skip the pnpm probe.
|
||||
const cache = new Map();
|
||||
const probe = (command, gitnexusWrapper) => {
|
||||
const key = `${command}:${gitnexusWrapper ? 1 : 0}`;
|
||||
if (!cache.has(key)) cache.set(key, resolveOnPath(command, gitnexusWrapper));
|
||||
return cache.get(key);
|
||||
};
|
||||
let resolved = deps;
|
||||
if (!('pnpmMajor' in deps)) {
|
||||
const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase();
|
||||
// pnpm is only consulted when no non-pnpm mode is already certain: forced
|
||||
// gitnexus/npx never use pnpm, and a present global gitnexus wins outright.
|
||||
const mightUsePnpm = forced === 'pnpm' || (forced !== 'gitnexus' && forced !== 'npx');
|
||||
if (mightUsePnpm && (forced === 'pnpm' || !probe('gitnexus', true))) {
|
||||
const { major, minor } = probeVersion('pnpm');
|
||||
// Carry presence separately from version: when the version probe fails
|
||||
// (timeout, Corepack banner) but pnpm is on PATH, still treat it as
|
||||
// present so mode resolution picks pnpm over the npx crash path. The
|
||||
// PATH probe is memoized and only runs when the version is unreadable.
|
||||
const pnpmPresent = major !== null || Boolean(probe('pnpm'));
|
||||
resolved = { ...deps, pnpmMajor: major, pnpmMinor: minor, pnpmPresent };
|
||||
}
|
||||
}
|
||||
const mode = resolveInvocationMode(probe, resolved);
|
||||
if (mode === 'gitnexus') return `gitnexus analyze${suffix}`;
|
||||
if (mode === 'pnpm') return `${formatPnpmDlxCommand(`analyze${suffix}`, options, resolved)}`;
|
||||
return `npx ${NPX_REF} analyze${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `mode` into a concrete { program, args } pair for a set of gitnexus
|
||||
* subcommand arguments. Shared by the direct-exec entrypoint below; pure (no
|
||||
* spawn) so it is unit-testable. `--embeddings` widens the pnpm allow-build set.
|
||||
*/
|
||||
function buildRunnerArgv(mode, gitnexusArgs, deps = {}) {
|
||||
// Match both the space form (`--embeddings`) and the equals form
|
||||
// (`--embeddings=5000`) Commander accepts, so the pnpm allow-build set still
|
||||
// widens to onnxruntime-node when a user hand-types the equals form.
|
||||
const embeddings = gitnexusArgs.some(
|
||||
(a) => a === '--embeddings' || a.startsWith('--embeddings='),
|
||||
);
|
||||
if (mode === 'gitnexus') return { program: 'gitnexus', args: [...gitnexusArgs] };
|
||||
if (mode === 'pnpm') {
|
||||
return {
|
||||
program: 'pnpm',
|
||||
args: [...formatPnpmAllowBuildArgs({ embeddings }, deps), 'dlx', NPX_REF, ...gitnexusArgs],
|
||||
};
|
||||
}
|
||||
return { program: 'npx', args: [NPX_REF, ...gitnexusArgs] };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
formatAnalyzeCommand,
|
||||
formatDocumentationDlxCommand,
|
||||
formatPnpmAllowBuildArgs,
|
||||
formatPnpmDlxCommand,
|
||||
resolveInvocationMode,
|
||||
buildRunnerArgv,
|
||||
resolveOnPath,
|
||||
getNpmMajorVersion,
|
||||
NPX_REF,
|
||||
PNPM_ALLOW_BUILD_BASE,
|
||||
};
|
||||
|
||||
// Direct-exec entrypoint (#1945): `node run.cjs <gitnexus args…>` resolves the
|
||||
// best available runner (global `gitnexus` → `pnpm dlx` → `npx`) at call time and
|
||||
// runs it, inheriting stdio and propagating the child's exit code. This lets the
|
||||
// committed skills and generated AGENTS.md/CLAUDE.md reference ONE stable,
|
||||
// CLI-neutral command without baking in a package-manager assumption. `gitnexus
|
||||
// analyze` drops a copy of this file at `.gitnexus/run.cjs`. Skipped on require()
|
||||
// (the CLI and tests reuse the exports above), so it runs only when invoked as a
|
||||
// script.
|
||||
if (require.main === module) {
|
||||
const gitnexusArgs = process.argv.slice(2);
|
||||
const { program, args } = buildRunnerArgv(resolveInvocationMode(), gitnexusArgs);
|
||||
try {
|
||||
execFileSync(program, args, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
// On Windows, `npx`/`pnpm`/`gitnexus` resolve to `.cmd`/`.ps1`/`.exe`
|
||||
// shims (npm, Volta, Corepack, scoop). execFileSync does not do PATHEXT
|
||||
// resolution and Node refuses to spawn `.cmd`/`.bat` without a shell
|
||||
// (CVE-2024-27980), so a bare program name ENOENTs. A shell lets the OS
|
||||
// resolve the shim; POSIX needs no shell (direct PATH lookup works).
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
} catch (err) {
|
||||
// Make spawn failures (resolved program absent from PATH) self-explanatory
|
||||
// instead of a silent exit 1, then propagate the runner's own exit code.
|
||||
if (typeof err.status !== 'number') {
|
||||
process.stderr.write(`gitnexus runner: could not launch \`${program}\` — ${err.message}\n`);
|
||||
}
|
||||
process.exit(typeof err.status === 'number' ? err.status : 1);
|
||||
}
|
||||
}
|
||||
276
gitnexus/package-lock.json
generated
276
gitnexus/package-lock.json
generated
|
|
@ -32,7 +32,7 @@
|
|||
"pandemonium": "^2.4.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"tree-sitter": "^0.21.1",
|
||||
"tree-sitter": "0.21.1",
|
||||
"tree-sitter-c": "0.21.4",
|
||||
"tree-sitter-c-sharp": "0.23.1",
|
||||
"tree-sitter-cpp": "0.23.2",
|
||||
|
|
@ -1307,9 +1307,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.132.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
|
||||
"integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==",
|
||||
"version": "0.133.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
||||
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
|
|
@ -1387,9 +1387,9 @@
|
|||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
|
||||
"integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1404,9 +1404,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz",
|
||||
"integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1421,9 +1421,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz",
|
||||
"integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1438,9 +1438,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz",
|
||||
"integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1455,9 +1455,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz",
|
||||
"integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
|
||||
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -1472,9 +1472,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1489,9 +1489,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz",
|
||||
"integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1506,9 +1506,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -1523,9 +1523,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
|
|
@ -1540,9 +1540,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1557,9 +1557,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz",
|
||||
"integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1574,9 +1574,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz",
|
||||
"integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1591,9 +1591,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz",
|
||||
"integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
|
||||
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
|
|
@ -1621,9 +1621,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
|
||||
"integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1638,9 +1638,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz",
|
||||
"integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1847,14 +1847,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz",
|
||||
"integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==",
|
||||
"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.7",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
|
|
@ -1868,8 +1868,8 @@
|
|||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.7",
|
||||
"vitest": "4.1.7"
|
||||
"@vitest/browser": "4.1.8",
|
||||
"vitest": "4.1.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
|
|
@ -1878,16 +1878,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz",
|
||||
"integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==",
|
||||
"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.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"@vitest/spy": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
|
|
@ -1896,13 +1896,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz",
|
||||
"integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==",
|
||||
"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.7",
|
||||
"@vitest/spy": "4.1.8",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
|
|
@ -1923,9 +1923,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz",
|
||||
"integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==",
|
||||
"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": {
|
||||
|
|
@ -1936,13 +1936,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz",
|
||||
"integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==",
|
||||
"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.7",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
|
|
@ -1950,14 +1950,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz",
|
||||
"integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==",
|
||||
"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.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
|
|
@ -1966,9 +1966,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz",
|
||||
"integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==",
|
||||
"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": {
|
||||
|
|
@ -1976,13 +1976,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz",
|
||||
"integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==",
|
||||
"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.7",
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
|
|
@ -3106,9 +3106,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.18",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
|
||||
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
|
||||
"version": "4.12.23",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
|
||||
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
|
|
@ -3594,9 +3594,9 @@
|
|||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz",
|
||||
"integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==",
|
||||
"version": "11.5.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
|
||||
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
|
|
@ -3799,9 +3799,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
|
||||
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
|
||||
"version": "8.8.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz",
|
||||
"integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
|
|
@ -4291,13 +4291,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
|
||||
"integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
||||
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.132.0",
|
||||
"@oxc-project/types": "=0.133.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
|
|
@ -4307,21 +4307,21 @@
|
|||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.2",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.2",
|
||||
"@rolldown/binding-darwin-x64": "1.0.2",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.2",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.2",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.2",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.2",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.2",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.2",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.2",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.2",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.2",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.2",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.2",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.2"
|
||||
"@rolldown/binding-android-arm64": "1.0.3",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.3",
|
||||
"@rolldown/binding-darwin-x64": "1.0.3",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.3",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.3",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.3",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.3",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.3",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.3",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
|
|
@ -4748,9 +4748,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -5039,9 +5039,9 @@
|
|||
"optional": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.22.3",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",
|
||||
"integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==",
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
||||
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -5167,17 +5167,17 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.14",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
|
||||
"integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
|
||||
"version": "8.0.16",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.15",
|
||||
"rolldown": "1.0.2",
|
||||
"tinyglobby": "^0.2.16"
|
||||
"rolldown": "1.0.3",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
|
|
@ -5245,19 +5245,19 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz",
|
||||
"integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==",
|
||||
"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.7",
|
||||
"@vitest/mocker": "4.1.7",
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"@vitest/runner": "4.1.7",
|
||||
"@vitest/snapshot": "4.1.7",
|
||||
"@vitest/spy": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"@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",
|
||||
|
|
@ -5285,12 +5285,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.7",
|
||||
"@vitest/browser-preview": "4.1.7",
|
||||
"@vitest/browser-webdriverio": "4.1.7",
|
||||
"@vitest/coverage-istanbul": "4.1.7",
|
||||
"@vitest/coverage-v8": "4.1.7",
|
||||
"@vitest/ui": "4.1.7",
|
||||
"@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"
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@
|
|||
"test:integration": "vitest run test/integration",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:parity": "tsx scripts/run-parity.ts",
|
||||
"test:cross-platform": "tsx scripts/run-cross-platform.ts",
|
||||
"postinstall": "node scripts/materialize-vendor-grammars.cjs && node scripts/build-tree-sitter-dart.cjs && node scripts/build-tree-sitter-proto.cjs && node scripts/build-tree-sitter-swift.cjs",
|
||||
"prepare": "node scripts/build.js",
|
||||
|
|
@ -77,7 +76,7 @@
|
|||
"pandemonium": "^2.4.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"tree-sitter": "^0.21.1",
|
||||
"tree-sitter": "0.21.1",
|
||||
"tree-sitter-c": "0.21.4",
|
||||
"tree-sitter-c-sharp": "0.23.1",
|
||||
"tree-sitter-cpp": "0.23.2",
|
||||
|
|
|
|||
|
|
@ -4,10 +4,8 @@
|
|||
* isolating the resolution cost from parse / heritage / pipeline
|
||||
* overhead.
|
||||
*
|
||||
* Usage: REGISTRY_PRIMARY_PYTHON=1 npx tsx scripts/bench-scope-resolution.ts
|
||||
* Usage: npx tsx scripts/bench-scope-resolution.ts
|
||||
*/
|
||||
process.env.REGISTRY_PRIMARY_PYTHON = '1';
|
||||
|
||||
import { generateId } from '../src/lib/utils.js';
|
||||
import { createKnowledgeGraph } from '../src/core/graph/graph.js';
|
||||
import { runScopeResolution } from '../src/core/ingestion/scope-resolution/index.js';
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
/**
|
||||
* CI helper — emits the `MIGRATED_LANGUAGES` set as a JSON matrix array for
|
||||
* GitHub Actions (`.github/workflows/ci-scope-parity.yml`).
|
||||
*
|
||||
* Consumed by the `discover` job in that workflow. Each entry has:
|
||||
* - `slug`: lowercase language id, matching `test/integration/resolvers/<slug>.test.ts`.
|
||||
* - `envvar`: uppercase suffix used to build the `REGISTRY_PRIMARY_<envvar>` toggle.
|
||||
*
|
||||
* Run with `npx tsx scripts/ci-list-migrated-languages.ts`. The script
|
||||
* writes a single JSON array to stdout (no wrapper object) so the
|
||||
* workflow can pipe it straight into `$GITHUB_OUTPUT`.
|
||||
*/
|
||||
|
||||
import { MIGRATED_LANGUAGES } from '../src/core/ingestion/registry-primary-flag.js';
|
||||
|
||||
const entries = [...MIGRATED_LANGUAGES].map((slug) => {
|
||||
const s = String(slug);
|
||||
return {
|
||||
slug: s,
|
||||
envvar: s.toUpperCase().replace(/-/g, '_'),
|
||||
};
|
||||
});
|
||||
|
||||
process.stdout.write(JSON.stringify(entries));
|
||||
|
|
@ -30,9 +30,10 @@ const PLATFORM_LOGIC = [
|
|||
'test/unit/setup-jsonc.test.ts',
|
||||
'test/unit/setup-codex.test.ts',
|
||||
'test/unit/setup-antigravity.test.ts',
|
||||
'test/unit/resolve-invocation.test.ts',
|
||||
'test/unit/platform-capabilities.test.ts',
|
||||
'test/unit/worker-pool-windows-quarantine.test.ts',
|
||||
'test/unit/lbug-pool-win-fts-probe.test.ts',
|
||||
'test/unit/lbug-pool-fts-load.test.ts',
|
||||
'test/unit/repo-manager.test.ts',
|
||||
'test/unit/repo-manager-finalize-invariant.test.ts',
|
||||
'test/unit/hooks.test.ts',
|
||||
|
|
@ -84,6 +85,7 @@ const SPAWN_CLI = [
|
|||
'test/integration/setup-antigravity.test.ts',
|
||||
'test/integration/antigravity-hook-e2e.test.ts',
|
||||
'test/unit/local-cli-subprocess.test.ts',
|
||||
'test/unit/runner-exec-tail.test.ts',
|
||||
];
|
||||
|
||||
// Worker threads tests — exercise real worker_threads which have
|
||||
|
|
@ -101,6 +103,7 @@ const NATIVE_ADDON_SMOKE = [
|
|||
'test/integration/pipeline.test.ts',
|
||||
'test/integration/pipeline-graph-golden.test.ts',
|
||||
'test/unit/parser-loader.test.ts',
|
||||
'test/unit/parser-loader-abi.test.ts',
|
||||
];
|
||||
|
||||
// Filesystem behavior tests — exercise operations that vary across
|
||||
|
|
|
|||
|
|
@ -1,128 +0,0 @@
|
|||
/**
|
||||
* Consolidated scope-resolution parity runner.
|
||||
*
|
||||
* Replaces the per-language matrix in ci-scope-parity.yml with a single
|
||||
* job that runs all migrated languages sequentially in one process. This
|
||||
* eliminates 8× redundant checkout + npm ci + build cycles (the old
|
||||
* workflow created a separate GitHub Actions job per language).
|
||||
*
|
||||
* For each language in MIGRATED_LANGUAGES:
|
||||
* 1. Run its resolver test with REGISTRY_PRIMARY_<LANG>=0 (legacy DAG)
|
||||
* 2. Run its resolver test with REGISTRY_PRIMARY_<LANG>=1 (registry-primary)
|
||||
*
|
||||
* Both modes must pass. Failures are collected and reported at the end
|
||||
* so all regressions are visible in a single CI run (equivalent to the
|
||||
* old workflow's fail-fast: false behavior).
|
||||
*
|
||||
* Vitest output streams to the console in real time (stdio: 'inherit')
|
||||
* so CI logs show the actual test output directly. No per-invocation
|
||||
* timeout — the CI job-level timeout (30 min) is the outer guard.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/run-parity.ts
|
||||
* npx tsx scripts/run-parity.ts --language python # single language
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { MIGRATED_LANGUAGES } from '../src/core/ingestion/registry-primary-flag.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
interface ParityFailure {
|
||||
lang: string;
|
||||
mode: 'legacy' | 'registry-primary';
|
||||
}
|
||||
|
||||
function envVarName(slug: string): string {
|
||||
return `REGISTRY_PRIMARY_${slug.toUpperCase().replace(/-/g, '_')}`;
|
||||
}
|
||||
|
||||
function testFilePath(slug: string): string {
|
||||
return `test/integration/resolvers/${slug}.test.ts`;
|
||||
}
|
||||
|
||||
function runVitest(testFile: string, env: Record<string, string>): boolean {
|
||||
try {
|
||||
execFileSync('npx', ['vitest', 'run', testFile], {
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse CLI args
|
||||
const args = process.argv.slice(2);
|
||||
const langFlag = args.indexOf('--language');
|
||||
const singleLang = langFlag >= 0 ? args[langFlag + 1] : undefined;
|
||||
|
||||
if (langFlag >= 0 && singleLang === undefined) {
|
||||
console.error('--language requires a value');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const languages = singleLang ? [singleLang] : [...MIGRATED_LANGUAGES].map(String);
|
||||
|
||||
// Verify test files exist before running
|
||||
const missingFiles: string[] = [];
|
||||
for (const lang of languages) {
|
||||
const file = path.resolve(ROOT, testFilePath(lang));
|
||||
try {
|
||||
fs.accessSync(file);
|
||||
} catch {
|
||||
missingFiles.push(`${testFilePath(lang)} (${lang})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingFiles.length > 0) {
|
||||
console.error('Missing resolver test files:');
|
||||
for (const f of missingFiles) console.error(` ${f}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Scope-resolution parity: ${languages.length} language(s)`);
|
||||
console.log(`Languages: ${languages.join(', ')}\n`);
|
||||
|
||||
const failures: ParityFailure[] = [];
|
||||
|
||||
for (const lang of languages) {
|
||||
const file = testFilePath(lang);
|
||||
const envVar = envVarName(lang);
|
||||
|
||||
console.log(`\n── ${lang} — legacy DAG (${envVar}=0) ──`);
|
||||
if (!runVitest(file, { [envVar]: '0' })) {
|
||||
failures.push({ lang, mode: 'legacy' });
|
||||
}
|
||||
|
||||
console.log(`\n── ${lang} — registry-primary (${envVar}=1) ──`);
|
||||
if (!runVitest(file, { [envVar]: '1' })) {
|
||||
failures.push({ lang, mode: 'registry-primary' });
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
const total = languages.length * 2;
|
||||
const passed = total - failures.length;
|
||||
|
||||
console.log('\n═══════════════════════════════════════');
|
||||
console.log('PARITY SUMMARY');
|
||||
console.log('═══════════════════════════════════════');
|
||||
console.log(`Passed: ${passed}/${total}`);
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.log(`\nFAILURES (${failures.length}):`);
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ ${f.lang} [${f.mode}]`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\nAll parity checks passed.');
|
||||
|
|
@ -5,14 +5,16 @@ description: "Use when the user needs to run GitNexus CLI commands like analyze/
|
|||
|
||||
# GitNexus CLI Commands
|
||||
|
||||
All commands work via `npx` — no global install required.
|
||||
Commands below use `node .gitnexus/run.cjs <command>` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required.
|
||||
|
||||
> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
|
||||
|
||||
## Commands
|
||||
|
||||
### analyze — Build or refresh the index
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
node .gitnexus/run.cjs analyze
|
||||
```
|
||||
|
||||
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
|
||||
|
|
@ -28,7 +30,7 @@ Run from the project root. This parses all source files, builds the knowledge gr
|
|||
### status — Check index freshness
|
||||
|
||||
```bash
|
||||
npx gitnexus status
|
||||
node .gitnexus/run.cjs status
|
||||
```
|
||||
|
||||
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
|
||||
|
|
@ -36,7 +38,7 @@ Shows whether the current repo has a GitNexus index, when it was last updated, a
|
|||
### clean — Delete the index
|
||||
|
||||
```bash
|
||||
npx gitnexus clean
|
||||
node .gitnexus/run.cjs clean
|
||||
```
|
||||
|
||||
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
|
||||
|
|
@ -49,7 +51,7 @@ Deletes the `.gitnexus/` directory and unregisters the repo from the global regi
|
|||
### wiki — Generate documentation from the graph
|
||||
|
||||
```bash
|
||||
npx gitnexus wiki
|
||||
node .gitnexus/run.cjs wiki
|
||||
```
|
||||
|
||||
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
|
||||
|
|
@ -66,7 +68,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir
|
|||
### list — Show all indexed repos
|
||||
|
||||
```bash
|
||||
npx gitnexus list
|
||||
node .gitnexus/run.cjs list
|
||||
```
|
||||
|
||||
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
|||
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ description: "Use when the user asks how code works, wants to understand archite
|
|||
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
|
||||
```
|
||||
|
||||
> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
|
|||
2. **Match your task to a skill below** and **read that skill file**
|
||||
3. **Follow the skill's workflow and checklist**
|
||||
|
||||
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
|
||||
> If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first.
|
||||
|
||||
## Skills
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ description: "Use when the user wants to know what will break if they change som
|
|||
4. Assess risk and report to user
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ description: "Use when the user wants to review a pull request, understand what
|
|||
6. Summarize findings with risk assessment
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing.
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal before reviewing.
|
||||
|
||||
## Checklist
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
|||
4. Plan update order: interfaces → implementations → callers → tests
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
|
||||
## Checklists
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ export interface AIContextOptions {
|
|||
skipAgentsMd?: boolean;
|
||||
noStats?: boolean;
|
||||
skipSkills?: boolean;
|
||||
/**
|
||||
* Default branch used by the generated regression-compare example (#243).
|
||||
* Resolved by the CLI (CLI flag > `.gitnexusrc` > auto-detect > "main"); a
|
||||
* plain caller that omits it gets "main", preserving prior behavior.
|
||||
*/
|
||||
defaultBranch?: string;
|
||||
}
|
||||
|
||||
const GITNEXUS_START_MARKER = '<!-- gitnexus:start -->';
|
||||
|
|
@ -89,13 +95,35 @@ async function findGroupsContainingRegistryName(registryName: string): Promise<s
|
|||
return hits;
|
||||
}
|
||||
|
||||
function generateGitNexusContent(
|
||||
/**
|
||||
* Strip backticks from a branch name before it is embedded in a Markdown
|
||||
* inline-code span (#1996 tri-review P1). validateBranchName already rejects
|
||||
* backticks for CLI/config/auto-detect inputs; this is the last-line defense at
|
||||
* the generation sink so the embedding is provably safe regardless of caller.
|
||||
*/
|
||||
export function markdownSafeBranch(branch: string): string {
|
||||
return branch.replace(/`/g, '');
|
||||
}
|
||||
|
||||
export function generateGitNexusContent(
|
||||
projectName: string,
|
||||
stats: RepoStats,
|
||||
generatedSkills?: GeneratedSkillInfo[],
|
||||
groupNames?: string[],
|
||||
noStats?: boolean,
|
||||
skipSkills?: boolean,
|
||||
// Project-relative path to the runner `gitnexus analyze` drops next to the
|
||||
// index (#1945). Referenced by docs so a single CLI-neutral command resolves
|
||||
// the available runner (global `gitnexus` → `pnpm dlx` → `npx`) at call time.
|
||||
runnerPath: string = '.gitnexus/run.cjs',
|
||||
// Default branch for the regression-compare example (#243). Configurable so
|
||||
// projects on `develop`/`master`/etc. don't get `base_ref: "main"` rewritten
|
||||
// back over their fix on every analyze. The value is embedded inside a
|
||||
// Markdown inline-code span: validateBranchName rejects backticks upstream,
|
||||
// and `markdownSafeBranch` strips any remaining backtick here as defense in
|
||||
// depth, so JSON.stringify's quote/escape handling is sufficient and the
|
||||
// branch cannot break out of the span (#1996 tri-review P1).
|
||||
defaultBranch: string = 'main',
|
||||
): string {
|
||||
const generatedRows =
|
||||
generatedSkills && generatedSkills.length > 0
|
||||
|
|
@ -127,18 +155,27 @@ function generateGitNexusContent(
|
|||
|------|---------------------|
|
||||
${tableBody}`
|
||||
: '';
|
||||
// Docs reference the project-local runner `gitnexus analyze` writes (#1945):
|
||||
// a single, CLI-neutral, machine-independent command (no per-machine churn,
|
||||
// #1706) that auto-selects the available runner at call time. Kept terse to
|
||||
// stay under the CLAUDE.md block token budget (#856); the cli skill carries the
|
||||
// full bootstrap + npm-11 fallback (`node.target is null` npx install crash).
|
||||
const runner = `node ${runnerPath}`;
|
||||
const bootstrapNote =
|
||||
`No \`${runnerPath}\` yet? \`npx gitnexus analyze\` ` +
|
||||
'(npm 11 crash → `npm i -g gitnexus`; #1939).';
|
||||
|
||||
return `${GITNEXUS_START_MARKER}
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows)`}. Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run \`npx gitnexus analyze\` in terminal first.
|
||||
> Index stale? Run \`${runner} analyze\` from the project root — it auto-selects an available runner. ${bootstrapNote}
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run \`gitnexus_impact({target: "symbolName", direction: "upstream"})\` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run \`gitnexus_detect_changes()\` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST run \`gitnexus_detect_changes()\` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: \`gitnexus_detect_changes({scope: "compare", base_ref: ${JSON.stringify(markdownSafeBranch(defaultBranch))}})\`.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use \`gitnexus_query({query: "concept"})\` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use \`gitnexus_context({name: "symbolName"})\`.
|
||||
|
|
@ -163,7 +200,7 @@ ${
|
|||
groupNames && groupNames.length > 0
|
||||
? `## Cross-Repo Groups
|
||||
|
||||
This repository is listed under GitNexus **group(s): ${groupNames.join(', ')}** (see \`~/.gitnexus/groups/\`). For cross-repo analysis, use MCP tools \`impact\`, \`query\`, and \`context\` with \`repo\` set to \`@<groupName>\` or \`@<groupName>/<memberPath>\` (paths match keys in that group’s \`group.yaml\`). Use \`group_list\` / \`group_sync\` for membership and sync. From the terminal: \`npx gitnexus group list\`, \`npx gitnexus group sync <name>\`, \`npx gitnexus group impact <name> --target <symbol> --repo <group-path>\`.
|
||||
This repository is listed under GitNexus **group(s): ${groupNames.join(', ')}** (see \`~/.gitnexus/groups/\`). For cross-repo analysis, use MCP tools \`impact\`, \`query\`, and \`context\` with \`repo\` set to \`@<groupName>\` or \`@<groupName>/<memberPath>\` (paths match keys in that group’s \`group.yaml\`). Use \`group_list\` / \`group_sync\` for membership and sync. From the project root: \`${runner} group list\`, \`${runner} group sync <name>\`, \`${runner} group impact <name> --target <symbol> --repo <group-path>\` (the \`${runnerPath}\` path is repo-root-relative).
|
||||
|
||||
`
|
||||
: ''
|
||||
|
|
@ -379,13 +416,36 @@ Use GitNexus tools to accomplish this task.
|
|||
*/
|
||||
export async function generateAIContextFiles(
|
||||
repoPath: string,
|
||||
_storagePath: string,
|
||||
storagePath: string,
|
||||
projectName: string,
|
||||
stats: RepoStats,
|
||||
generatedSkills?: GeneratedSkillInfo[],
|
||||
options?: AIContextOptions,
|
||||
): Promise<{ files: string[] }> {
|
||||
const groupNames = await findGroupsContainingRegistryName(projectName);
|
||||
|
||||
// Drop a project-local runner next to the index (#1945) so the generated docs
|
||||
// can reference one CLI-neutral command that resolves the available runner at
|
||||
// call time. It is a copy of the canonical self-contained resolver, which the
|
||||
// CLI and hooks already share; failure to copy is non-fatal (docs carry a
|
||||
// bootstrap fallback). `runnerPath` is project-relative with POSIX separators
|
||||
// so the emitted command is identical across platforms.
|
||||
const runnerPath = path.relative(repoPath, path.join(storagePath, 'run.cjs')).replace(/\\/g, '/');
|
||||
try {
|
||||
const runnerSrc = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'hooks',
|
||||
'claude',
|
||||
'resolve-analyze-cmd.cjs',
|
||||
);
|
||||
await fs.mkdir(storagePath, { recursive: true });
|
||||
await fs.copyFile(runnerSrc, path.join(storagePath, 'run.cjs'));
|
||||
} catch (err) {
|
||||
logger.warn(`Could not write GitNexus runner to ${runnerPath}: ${String(err)}`);
|
||||
}
|
||||
|
||||
const content = generateGitNexusContent(
|
||||
projectName,
|
||||
stats,
|
||||
|
|
@ -393,6 +453,8 @@ export async function generateAIContextFiles(
|
|||
groupNames,
|
||||
options?.noStats,
|
||||
options?.skipSkills,
|
||||
runnerPath,
|
||||
options?.defaultBranch ?? 'main',
|
||||
);
|
||||
const createdFiles: string[] = [];
|
||||
|
||||
|
|
@ -435,3 +497,56 @@ export async function generateAIContextFiles(
|
|||
|
||||
return { files: createdFiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh only the `base_ref: "..."` value inside the GitNexus block of an
|
||||
* already-generated AGENTS.md / CLAUDE.md, in place (#1996 tri-review P2).
|
||||
*
|
||||
* The `alreadyUpToDate` analyze fast path returns before the normal
|
||||
* {@link generateAIContextFiles} call, so a changed `.gitnexusrc` defaultBranch
|
||||
* (or `--default-branch`) would otherwise not take effect until the next
|
||||
* re-index. This does a surgical line update that preserves the rest of the
|
||||
* block — including community-skill rows written by a prior `--skills` run —
|
||||
* rather than regenerating (which would drop those rows on a no-`--skills` run).
|
||||
*
|
||||
* Best-effort: missing files, a missing/blank block, or a block with no
|
||||
* `base_ref` line (e.g. a user-trimmed keep block) are silently skipped. Writes
|
||||
* only when the value actually changes, so a routine up-to-date run is a no-op.
|
||||
*/
|
||||
export async function refreshBaseRefLine(
|
||||
repoPath: string,
|
||||
defaultBranch: string,
|
||||
options?: { skipAgentsMd?: boolean },
|
||||
): Promise<{ files: string[] }> {
|
||||
if (options?.skipAgentsMd) return { files: [] };
|
||||
const replacement = `base_ref: ${JSON.stringify(markdownSafeBranch(defaultBranch))}`;
|
||||
const updated: string[] = [];
|
||||
for (const name of ['AGENTS.md', 'CLAUDE.md']) {
|
||||
const filePath = path.join(repoPath, name);
|
||||
if (!(await fileExists(filePath))) continue;
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(filePath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const startIdx = findSectionMarkerIndex(content, GITNEXUS_START_MARKER);
|
||||
if (startIdx === -1) continue;
|
||||
const endIdx = findSectionMarkerIndex(content, GITNEXUS_END_MARKER, startIdx);
|
||||
if (endIdx === -1 || endIdx <= startIdx) continue;
|
||||
const blockEnd = endIdx + GITNEXUS_END_MARKER.length;
|
||||
const block = content.substring(startIdx, blockEnd);
|
||||
// Only the generated regression example carries a base_ref line, and only
|
||||
// one per block; replace its quoted value while leaving the rest untouched.
|
||||
const newBlock = block.replace(/base_ref: "(?:[^"\\]|\\.)*"/, replacement);
|
||||
if (newBlock === block) continue; // no base_ref line present, or already current
|
||||
const newContent = content.substring(0, startIdx) + newBlock + content.substring(blockEnd);
|
||||
try {
|
||||
await fs.writeFile(filePath, newContent, 'utf-8');
|
||||
updated.push(name);
|
||||
} catch {
|
||||
// best-effort — never fail analyze over a context refresh
|
||||
}
|
||||
}
|
||||
return { files: updated };
|
||||
}
|
||||
|
|
|
|||
426
gitnexus/src/cli/analyze-config.ts
Normal file
426
gitnexus/src/cli/analyze-config.ts
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
/**
|
||||
* Project-local `.gitnexusrc` support for `gitnexus analyze` (#243).
|
||||
*
|
||||
* Lets a repository commit recurring `analyze` defaults — default branch for the
|
||||
* generated regression example, AI-context / skills opt-outs, embedding knobs —
|
||||
* so contributors don't re-pass the same flags on every run. Design rules:
|
||||
*
|
||||
* - Config is repo-local (`.gitnexusrc` at the resolved repo root). It is NOT
|
||||
* read from `.gitnexus/` because that directory is index storage and is
|
||||
* commonly gitignored.
|
||||
* - JSON only. No YAML, no `package.json` field, no global `~/.gitnexus` file
|
||||
* in this pass.
|
||||
* - CLI flags always override config (see {@link mergeAnalyzeOptions}).
|
||||
* - Fail closed: unknown keys, wrong value types, conflicting aliases, and
|
||||
* invalid JSON all throw {@link GitNexusRcError} so a typo never silently
|
||||
* no-ops. Errors are actionable and surface before any expensive analysis.
|
||||
* - Config values never reach a shell. Strings are validated against control
|
||||
* and hidden/bidirectional characters so they cannot inject markdown,
|
||||
* JSON, or hidden controls into generated context files.
|
||||
*
|
||||
* Both a flat shape and a nested `analyze` block are accepted:
|
||||
*
|
||||
* { "defaultBranch": "develop", "skipContextFiles": true }
|
||||
* { "analyze": { "defaultBranch": "develop", "skipSkills": true } }
|
||||
*
|
||||
* When both set the same option, the nested `analyze` block wins (deterministic,
|
||||
* documented precedence). Conflicting *aliases at the same level* (e.g. both
|
||||
* `defaultBranch` and `branch`) are rejected.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { AnalyzeOptions } from './analyze.js';
|
||||
|
||||
export const GITNEXUS_RC_FILENAME = '.gitnexusrc';
|
||||
|
||||
/** Final fallback when no branch is configured or detectable. */
|
||||
export const DEFAULT_BRANCH_FALLBACK = 'main';
|
||||
|
||||
/** Git refs longer than this are almost certainly a mistake / injection attempt. */
|
||||
const BRANCH_MAX_LENGTH = 255;
|
||||
|
||||
/**
|
||||
* Thrown for any `.gitnexusrc` problem (missing-file is NOT an error — it
|
||||
* returns `undefined`). The message is user-facing and names the file so the
|
||||
* CLI can print it verbatim before starting the progress bar.
|
||||
*/
|
||||
export class GitNexusRcError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'GitNexusRcError';
|
||||
}
|
||||
}
|
||||
|
||||
type ValueKind =
|
||||
| 'boolean'
|
||||
| 'boolean-negate'
|
||||
| 'string'
|
||||
| 'numeric-string'
|
||||
| 'embeddings'
|
||||
| 'branch';
|
||||
|
||||
interface KeySpec {
|
||||
/** The `AnalyzeOptions` field this config key normalizes into. */
|
||||
target: keyof AnalyzeOptions;
|
||||
kind: ValueKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allowed `.gitnexusrc` keys and how each maps onto `AnalyzeOptions`.
|
||||
*
|
||||
* Aliases intentionally collapse onto a shared target:
|
||||
* - `branch` is the legacy alias for `defaultBranch` (issue-comment shape).
|
||||
* - `skipContextFiles` / `skipAiContext` are aliases for `skipAgentsMd` — they
|
||||
* suppress the AGENTS.md / CLAUDE.md block ONLY. They do not imply
|
||||
* `skipSkills`, and they are weaker than `indexOnly` (which skips all
|
||||
* file injection). This matches the existing CLI semantics exactly.
|
||||
* - `noStats` is the negation of `stats`.
|
||||
*/
|
||||
const KEY_SPECS: Record<string, KeySpec> = {
|
||||
defaultBranch: { target: 'defaultBranch', kind: 'branch' },
|
||||
branch: { target: 'defaultBranch', kind: 'branch' },
|
||||
skipAgentsMd: { target: 'skipAgentsMd', kind: 'boolean' },
|
||||
skipContextFiles: { target: 'skipAgentsMd', kind: 'boolean' },
|
||||
skipAiContext: { target: 'skipAgentsMd', kind: 'boolean' },
|
||||
skipSkills: { target: 'skipSkills', kind: 'boolean' },
|
||||
indexOnly: { target: 'indexOnly', kind: 'boolean' },
|
||||
stats: { target: 'stats', kind: 'boolean' },
|
||||
noStats: { target: 'stats', kind: 'boolean-negate' },
|
||||
embeddings: { target: 'embeddings', kind: 'embeddings' },
|
||||
dropEmbeddings: { target: 'dropEmbeddings', kind: 'boolean' },
|
||||
name: { target: 'name', kind: 'string' },
|
||||
allowDuplicateName: { target: 'allowDuplicateName', kind: 'boolean' },
|
||||
maxFileSize: { target: 'maxFileSize', kind: 'numeric-string' },
|
||||
workerTimeout: { target: 'workerTimeout', kind: 'numeric-string' },
|
||||
walCheckpointThreshold: { target: 'walCheckpointThreshold', kind: 'numeric-string' },
|
||||
workers: { target: 'workers', kind: 'numeric-string' },
|
||||
embeddingThreads: { target: 'embeddingThreads', kind: 'numeric-string' },
|
||||
embeddingBatchSize: { target: 'embeddingBatchSize', kind: 'numeric-string' },
|
||||
embeddingSubBatchSize: { target: 'embeddingSubBatchSize', kind: 'numeric-string' },
|
||||
embeddingDevice: { target: 'embeddingDevice', kind: 'string' },
|
||||
};
|
||||
|
||||
/** Top-level container key for the nested form; not itself an `AnalyzeOptions` field. */
|
||||
const NESTED_KEY = 'analyze';
|
||||
|
||||
const ALLOWED_KEYS_HINT = `Allowed keys: ${Object.keys(KEY_SPECS).join(', ')} (or a nested "${NESTED_KEY}" object).`;
|
||||
|
||||
/**
|
||||
* Reject control characters and hidden / bidirectional Unicode in a string
|
||||
* value. These have no legitimate place in a branch name, registry name, or
|
||||
* device string, and would otherwise let a committed config smuggle invisible
|
||||
* controls into generated AGENTS.md / CLAUDE.md content.
|
||||
*/
|
||||
const isHiddenOrControl = (codePoint: number): boolean =>
|
||||
codePoint < 0x20 ||
|
||||
codePoint === 0x7f ||
|
||||
(codePoint >= 0x200b && codePoint <= 0x200f) || // zero-width + LRM/RLM
|
||||
(codePoint >= 0x202a && codePoint <= 0x202e) || // bidi embeddings/overrides
|
||||
(codePoint >= 0x2060 && codePoint <= 0x2064) || // word-joiner + invisible math
|
||||
(codePoint >= 0x2066 && codePoint <= 0x206f) || // bidi isolates + deprecated
|
||||
codePoint === 0xfeff; // BOM / zero-width no-break space
|
||||
|
||||
const assertNoHiddenChars = (value: string, source: string): void => {
|
||||
for (const ch of value) {
|
||||
const cp = ch.codePointAt(0);
|
||||
if (cp !== undefined && isHiddenOrControl(cp)) {
|
||||
throw new GitNexusRcError(
|
||||
`${source}: value contains control or hidden/bidirectional characters, which are not allowed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate a user-supplied branch name (from CLI or `.gitnexusrc`). Returns the
|
||||
* trimmed name or throws {@link GitNexusRcError}. Conservative but accepts the
|
||||
* shapes real branches use (`feature/foo-bar`, `release/1.2`, `develop`).
|
||||
*/
|
||||
export function validateBranchName(value: string, source: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new GitNexusRcError(`${source}: branch name must not be empty.`);
|
||||
}
|
||||
if (trimmed.length > BRANCH_MAX_LENGTH) {
|
||||
throw new GitNexusRcError(`${source}: branch name is too long (max ${BRANCH_MAX_LENGTH}).`);
|
||||
}
|
||||
assertNoHiddenChars(trimmed, source);
|
||||
if (/\s/.test(trimmed)) {
|
||||
throw new GitNexusRcError(`${source}: branch name must not contain whitespace.`);
|
||||
}
|
||||
// git ref-name rules (subset): reject characters git itself forbids in refs.
|
||||
if (/[~^:?*[\\]/.test(trimmed)) {
|
||||
throw new GitNexusRcError(
|
||||
`${source}: branch name contains characters not allowed in a git ref (~ ^ : ? * [ \\).`,
|
||||
);
|
||||
}
|
||||
if (trimmed.startsWith('-')) {
|
||||
throw new GitNexusRcError(`${source}: branch name must not start with "-".`);
|
||||
}
|
||||
if (trimmed.includes('..')) {
|
||||
throw new GitNexusRcError(`${source}: branch name must not contain "..".`);
|
||||
}
|
||||
// Git permits a backtick in a ref, but the branch is embedded inside a
|
||||
// Markdown inline-code span in the generated AGENTS.md/CLAUDE.md regression
|
||||
// example, where a backtick would close the span early and let the rest of
|
||||
// the template render as instruction text. Reject it at this single
|
||||
// chokepoint so all three tiers (CLI flag, .gitnexusrc, auto-detect via
|
||||
// sanitizeDetectedBranch) are covered (#1996 tri-review P1).
|
||||
if (trimmed.includes('`')) {
|
||||
throw new GitNexusRcError(
|
||||
`${source}: branch name must not contain a backtick (it would break the generated Markdown).`,
|
||||
);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort validation for an auto-detected branch (from git). Never throws —
|
||||
* returns `undefined` for anything unusable so the resolver falls back to the
|
||||
* next precedence tier.
|
||||
*/
|
||||
export function sanitizeDetectedBranch(value: string | null | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
return validateBranchName(value, 'detected branch');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeValue = (kind: ValueKind, value: unknown, key: string): unknown => {
|
||||
const source = `${GITNEXUS_RC_FILENAME} "${key}"`;
|
||||
switch (kind) {
|
||||
case 'boolean':
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new GitNexusRcError(`${source} must be a boolean (true/false).`);
|
||||
}
|
||||
return value;
|
||||
case 'boolean-negate':
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new GitNexusRcError(`${source} must be a boolean (true/false).`);
|
||||
}
|
||||
return !value;
|
||||
case 'branch':
|
||||
if (typeof value !== 'string') {
|
||||
throw new GitNexusRcError(`${source} must be a string branch name.`);
|
||||
}
|
||||
return validateBranchName(value, source);
|
||||
case 'string': {
|
||||
if (typeof value !== 'string') {
|
||||
throw new GitNexusRcError(`${source} must be a string.`);
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new GitNexusRcError(`${source} must not be empty.`);
|
||||
}
|
||||
assertNoHiddenChars(trimmed, source);
|
||||
// `name` flows into the generated AGENTS.md/CLAUDE.md as `**${name}**` and
|
||||
// inside `gitnexus://repo/${name}/…` code spans, so a Markdown-significant
|
||||
// character would break those spans or inject emphasis/links/HTML into
|
||||
// agent-instruction content (#1996 tri-review P1). `_` is intentionally
|
||||
// allowed (legitimate in repo names; intraword `_` is not emphasis).
|
||||
// embeddingDevice (the other `string`-kind option) only ever holds a
|
||||
// fixed device token, so this guard never rejects a valid value there.
|
||||
if (/[`*[\]<>]/.test(trimmed)) {
|
||||
throw new GitNexusRcError(
|
||||
`${source} must not contain Markdown-significant characters (\` * [ ] < >).`,
|
||||
);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
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
|
||||
// string and let the downstream per-flag validation enforce ranges so the
|
||||
// error messages stay in one place.
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new GitNexusRcError(`${source} must be a finite number.`);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new GitNexusRcError(`${source} must not be empty.`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
throw new GitNexusRcError(`${source} must be a number or numeric string.`);
|
||||
}
|
||||
case 'embeddings': {
|
||||
// Mirror `--embeddings [limit]`: boolean toggles, a non-negative integer
|
||||
// sets the node cap (normalized to a string, as Commander would supply).
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new GitNexusRcError(
|
||||
`${source} must be true/false or a non-negative integer (node cap; 0 disables the cap).`,
|
||||
);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
throw new GitNexusRcError(
|
||||
`${source} must be a boolean or a non-negative integer (node cap; 0 disables the cap).`,
|
||||
);
|
||||
}
|
||||
default:
|
||||
// Exhaustive — kept for forward-compat if a new kind is added.
|
||||
throw new GitNexusRcError(`${source}: unsupported config value kind.`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize one level (flat top-level or the nested `analyze` block) into a
|
||||
* partial `AnalyzeOptions`. Rejects unknown keys and two aliases that configure
|
||||
* the same option at the same level.
|
||||
*/
|
||||
const normalizeLevel = (
|
||||
obj: Record<string, unknown>,
|
||||
{ allowNestedKey }: { allowNestedKey: boolean },
|
||||
): Partial<AnalyzeOptions> => {
|
||||
const out: Partial<AnalyzeOptions> = {};
|
||||
const setBy = new Map<keyof AnalyzeOptions, string>();
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (allowNestedKey && key === NESTED_KEY) continue; // handled separately
|
||||
// `Object.hasOwn`, not a truthiness check: a plain-object lookup like
|
||||
// `KEY_SPECS["__proto__"]` returns an inherited member (Object.prototype,
|
||||
// truthy) and would slip past `if (!spec)`, hitting the wrong error branch
|
||||
// instead of the documented "Unknown key" message (#1996 tri-review P3).
|
||||
if (!Object.hasOwn(KEY_SPECS, key)) {
|
||||
throw new GitNexusRcError(
|
||||
`Unknown key "${key}" in ${GITNEXUS_RC_FILENAME}. ${ALLOWED_KEYS_HINT}`,
|
||||
);
|
||||
}
|
||||
const spec = KEY_SPECS[key];
|
||||
const prev = setBy.get(spec.target);
|
||||
if (prev && prev !== key) {
|
||||
throw new GitNexusRcError(
|
||||
`${GITNEXUS_RC_FILENAME}: "${prev}" and "${key}" both configure the same option; set only one.`,
|
||||
);
|
||||
}
|
||||
setBy.set(spec.target, key);
|
||||
(out as Record<string, unknown>)[spec.target] = normalizeValue(spec.kind, value, key);
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Locate, read, parse, validate, and normalize `.gitnexusrc` at `repoRoot`.
|
||||
*
|
||||
* @returns the normalized config defaults, or `undefined` when no file exists
|
||||
* (the normal case). Throws {@link GitNexusRcError} on any problem.
|
||||
*/
|
||||
export function loadAnalyzeConfig(repoRoot: string): Partial<AnalyzeOptions> | undefined {
|
||||
const filePath = path.join(repoRoot, GITNEXUS_RC_FILENAME);
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(filePath, 'utf-8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return undefined;
|
||||
throw new GitNexusRcError(`Could not read ${GITNEXUS_RC_FILENAME}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// Strip a leading UTF-8 BOM: Node's 'utf-8' decode keeps it, and JSON.parse
|
||||
// then fails with a confusing "Unexpected token" on an otherwise-valid file
|
||||
// (#1996 tri-review). Only one leading BOM is stripped; in-string control
|
||||
// rejection still applies to the parsed values.
|
||||
if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new GitNexusRcError(
|
||||
`${GITNEXUS_RC_FILENAME} is not valid JSON: ${(err as Error).message}. ` +
|
||||
`Expected a JSON object such as {"defaultBranch": "develop", "skipContextFiles": true}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new GitNexusRcError(`${GITNEXUS_RC_FILENAME} must contain a JSON object.`);
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const flat = normalizeLevel(obj, { allowNestedKey: true });
|
||||
|
||||
let nested: Partial<AnalyzeOptions> = {};
|
||||
if (Object.prototype.hasOwnProperty.call(obj, NESTED_KEY)) {
|
||||
const nestedRaw = obj[NESTED_KEY];
|
||||
if (nestedRaw === null || typeof nestedRaw !== 'object' || Array.isArray(nestedRaw)) {
|
||||
throw new GitNexusRcError(`${GITNEXUS_RC_FILENAME} "${NESTED_KEY}" must be a JSON object.`);
|
||||
}
|
||||
nested = normalizeLevel(nestedRaw as Record<string, unknown>, { allowNestedKey: false });
|
||||
}
|
||||
|
||||
// Nested `analyze` block overrides flat top-level keys for the same option.
|
||||
return { ...flat, ...nested };
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge CLI options over `.gitnexusrc` defaults. CLI wins whenever it provides a
|
||||
* value — including an explicit `false` (so an explicit CLI off-switch beats a
|
||||
* config `true`). Config fills only the genuinely-unset (`undefined`) options.
|
||||
*
|
||||
* `stats` is special: Commander always materializes it (`true` by default,
|
||||
* `false` only when `--no-stats` is passed), so plain `??` can't tell "default
|
||||
* on" from "explicitly on". The rule is: a passed `--no-stats` (`stats: false`)
|
||||
* always wins; otherwise the config value applies. There is intentionally no
|
||||
* `--stats` counter-flag, so config can only turn stats off, not force it back
|
||||
* on against a `--no-stats`.
|
||||
*
|
||||
* `defaultBranch` is NOT resolved here — see {@link resolveDefaultBranch}, which
|
||||
* applies the CLI > config > auto-detect > "main" precedence chain.
|
||||
*/
|
||||
export function mergeAnalyzeOptions(
|
||||
cli: AnalyzeOptions,
|
||||
config: Partial<AnalyzeOptions> | undefined,
|
||||
): AnalyzeOptions {
|
||||
if (!config) return cli;
|
||||
|
||||
const merged: AnalyzeOptions = { ...cli };
|
||||
for (const key of Object.keys(config) as (keyof AnalyzeOptions)[]) {
|
||||
if (key === 'stats' || key === 'defaultBranch') continue; // handled below / by resolver
|
||||
if (merged[key] === undefined) {
|
||||
(merged as Record<string, unknown>)[key] = config[key];
|
||||
}
|
||||
}
|
||||
|
||||
if (config.stats !== undefined && cli.stats !== false) {
|
||||
merged.stats = config.stats;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the default branch threaded into generated context, applying the
|
||||
* precedence chain:
|
||||
*
|
||||
* CLI `--default-branch` > `.gitnexusrc` `defaultBranch`/`branch`
|
||||
* > auto-detected `origin/HEAD` > {@link DEFAULT_BRANCH_FALLBACK} ("main").
|
||||
*
|
||||
* User-supplied values (CLI, config) are validated strictly and throw on bad
|
||||
* input. The auto-detected value is best-effort and silently ignored if
|
||||
* unusable.
|
||||
*/
|
||||
export function resolveDefaultBranch(input: {
|
||||
cliBranch?: string;
|
||||
configBranch?: string;
|
||||
detectedBranch?: string | null;
|
||||
}): string {
|
||||
if (input.cliBranch !== undefined) {
|
||||
return validateBranchName(input.cliBranch, '--default-branch');
|
||||
}
|
||||
if (input.configBranch !== undefined) {
|
||||
return validateBranchName(input.configBranch, `${GITNEXUS_RC_FILENAME} "defaultBranch"`);
|
||||
}
|
||||
const detected = sanitizeDetectedBranch(input.detectedBranch);
|
||||
if (detected) return detected;
|
||||
return DEFAULT_BRANCH_FALLBACK;
|
||||
}
|
||||
|
|
@ -26,7 +26,14 @@ import {
|
|||
AnalysisNotFinalizedError,
|
||||
assertAnalysisFinalized,
|
||||
} from '../storage/repo-manager.js';
|
||||
import { getGitRoot, hasGitDir } from '../storage/git.js';
|
||||
import { getGitRoot, hasGitDir, getDefaultBranch } from '../storage/git.js';
|
||||
import {
|
||||
loadAnalyzeConfig,
|
||||
mergeAnalyzeOptions,
|
||||
resolveDefaultBranch,
|
||||
validateBranchName,
|
||||
GitNexusRcError,
|
||||
} from './analyze-config.js';
|
||||
import { runFullAnalysis } from '../core/run-analyze.js';
|
||||
import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
|
||||
import { warnMissingOptionalGrammars } from './optional-grammars.js';
|
||||
|
|
@ -35,6 +42,8 @@ import fs from 'fs/promises';
|
|||
import { cliError } from './cli-message.js';
|
||||
import { formatElapsed } from './format-elapsed.js';
|
||||
import { isHfDownloadFailure } from '../core/embeddings/hf-env.js';
|
||||
import { isLocalEmbeddingRuntimeBlockerMessage } from '../core/embeddings/runtime-support.js';
|
||||
import { warnIfNpm11NpxRisk } from './resolve-invocation.js';
|
||||
|
||||
// Capture stderr.write at module load BEFORE anything (LadybugDB native
|
||||
// init, progress bar, console redirection) can monkey-patch it. The
|
||||
|
|
@ -553,6 +562,13 @@ export interface AnalyzeOptions {
|
|||
stats?: boolean;
|
||||
/** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */
|
||||
skipSkills?: boolean;
|
||||
/**
|
||||
* Default branch for the generated regression-compare example (#243). From
|
||||
* `--default-branch`; may also be supplied via `.gitnexusrc`. Resolved to a
|
||||
* concrete branch (CLI > `.gitnexusrc` > auto-detected origin/HEAD > "main")
|
||||
* before being threaded into the generated AGENTS.md / CLAUDE.md content.
|
||||
*/
|
||||
defaultBranch?: 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. */
|
||||
|
|
@ -617,6 +633,11 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
// a stack trace and a non-zero exit code instead of a silent exit 0.
|
||||
installFatalHandlers();
|
||||
|
||||
// npm-11 npx-crash nudge (#1939). Runs here, after the heap re-exec guard,
|
||||
// so it fires once in the working process and never on the lazy-startup path
|
||||
// of other commands (e.g. `gitnexus mcp`).
|
||||
warnIfNpm11NpxRisk();
|
||||
|
||||
// Snapshot the GITNEXUS_* env vars that the impl writes for downstream
|
||||
// consumption, so they don't leak across `analyzeCommand` invocations in
|
||||
// programmatic callers (tests, long-running hosts). `process.exit(0)` on
|
||||
|
|
@ -632,16 +653,116 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
}
|
||||
};
|
||||
|
||||
const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions): Promise<void> => {
|
||||
if (options?.verbose) {
|
||||
const analyzeCommandImpl = async (
|
||||
inputPath?: string,
|
||||
cliOptions?: AnalyzeOptions,
|
||||
): Promise<void> => {
|
||||
console.log('\n GitNexus Analyzer\n');
|
||||
|
||||
// ── Resolve the target repo root ──────────────────────────────────
|
||||
// Resolved FIRST because `.gitnexusrc` is read from the repo root (not the
|
||||
// caller's cwd), and config can set defaults that the validation below
|
||||
// consumes. `--skip-git` is a CLI-only flag (never a config key), so the raw
|
||||
// CLI options are authoritative for repo-root resolution.
|
||||
let repoPath: string;
|
||||
if (inputPath) {
|
||||
repoPath = path.resolve(inputPath);
|
||||
} else if (cliOptions?.skipGit) {
|
||||
// --skip-git: treat cwd as the index root, do not walk up to a parent git repo.
|
||||
repoPath = path.resolve(process.cwd());
|
||||
} else {
|
||||
const gitRoot = getGitRoot(process.cwd());
|
||||
if (!gitRoot) {
|
||||
console.log(
|
||||
' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
repoPath = gitRoot;
|
||||
}
|
||||
|
||||
const repoHasGit = hasGitDir(repoPath);
|
||||
if (!repoHasGit && !cliOptions?.skipGit) {
|
||||
console.log(
|
||||
' Not a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (!repoHasGit) {
|
||||
console.log(
|
||||
' Warning: no .git directory found — commit-tracking and incremental updates disabled.\n',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate an explicit `--default-branch` up front so its errors are
|
||||
// attributed to the flag (with a CLI-specific recovery hint) rather than to
|
||||
// `.gitnexusrc`, which the user may not even have (#1996 tri-review).
|
||||
if (cliOptions?.defaultBranch !== undefined) {
|
||||
try {
|
||||
validateBranchName(cliOptions.defaultBranch, '--default-branch');
|
||||
} catch (err) {
|
||||
cliError(` ${err instanceof Error ? err.message : String(err)}\n`, {
|
||||
recoveryHint: 'default-branch-invalid',
|
||||
});
|
||||
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.
|
||||
let options: AnalyzeOptions;
|
||||
let resolvedDefaultBranch: string;
|
||||
try {
|
||||
const fileConfig = loadAnalyzeConfig(repoPath);
|
||||
options = mergeAnalyzeOptions(cliOptions ?? {}, fileConfig);
|
||||
|
||||
// Resolve the default branch threaded into generated context:
|
||||
// CLI --default-branch > .gitnexusrc defaultBranch/branch
|
||||
// > auto-detected origin/HEAD > "main".
|
||||
// Only shell out to git when no branch was configured AND the generated
|
||||
// context will actually use it, keeping the common path free of an extra
|
||||
// git call. Detection is best-effort and never blocks analyze.
|
||||
const cliBranch = cliOptions?.defaultBranch;
|
||||
const configBranch = fileConfig?.defaultBranch;
|
||||
const willGenerateContext = !options.indexOnly && !options.skipAgentsMd;
|
||||
let detectedBranch: string | null = null;
|
||||
if (
|
||||
cliBranch === undefined &&
|
||||
configBranch === undefined &&
|
||||
repoHasGit &&
|
||||
!cliOptions?.skipGit &&
|
||||
willGenerateContext
|
||||
) {
|
||||
try {
|
||||
detectedBranch = getDefaultBranch(repoPath);
|
||||
} catch {
|
||||
detectedBranch = null;
|
||||
}
|
||||
}
|
||||
resolvedDefaultBranch = resolveDefaultBranch({ cliBranch, configBranch, detectedBranch });
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof GitNexusRcError
|
||||
? err.message
|
||||
: `Invalid .gitnexusrc: ${err instanceof Error ? err.message : String(err)}`;
|
||||
cliError(` ${msg}\n`, { recoveryHint: 'gitnexusrc-invalid' });
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.verbose) {
|
||||
process.env.GITNEXUS_VERBOSE = '1';
|
||||
}
|
||||
|
||||
if (options?.maxFileSize) {
|
||||
if (options.maxFileSize) {
|
||||
process.env.GITNEXUS_MAX_FILE_SIZE = options.maxFileSize;
|
||||
}
|
||||
|
||||
if (options?.workerTimeout) {
|
||||
if (options.workerTimeout) {
|
||||
const workerTimeoutSeconds = Number(options.workerTimeout);
|
||||
if (!Number.isFinite(workerTimeoutSeconds) || workerTimeoutSeconds < 1) {
|
||||
cliError(' --worker-timeout must be at least 1 second.\n');
|
||||
|
|
@ -653,7 +774,7 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
|
|||
);
|
||||
}
|
||||
|
||||
if (options?.walCheckpointThreshold !== undefined) {
|
||||
if (options.walCheckpointThreshold !== undefined) {
|
||||
const parsed = parseWalCheckpointThreshold(options.walCheckpointThreshold);
|
||||
if (parsed === undefined) {
|
||||
cliError(' --wal-checkpoint-threshold must be an integer >= -1.\n');
|
||||
|
|
@ -670,7 +791,7 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
|
|||
// values back-to-back and observe the value they passed, not whatever the
|
||||
// previous call leaked.
|
||||
let workerPoolSize: number | undefined;
|
||||
if (options?.workers !== undefined) {
|
||||
if (options.workers !== undefined) {
|
||||
const parsedWorkers = Number(options.workers);
|
||||
if (!Number.isInteger(parsedWorkers) || parsedWorkers < 0) {
|
||||
cliError(
|
||||
|
|
@ -688,7 +809,7 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
|
|||
// sibling-validation pattern (exit before bar.start() — otherwise
|
||||
// process.exit() leaves the progress bar's hidden cursor uncleared).
|
||||
let embeddingsNodeLimit: number | undefined;
|
||||
if (typeof options?.embeddings === 'string') {
|
||||
if (typeof options.embeddings === 'string') {
|
||||
const parsed = Number(options.embeddings);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
cliError(
|
||||
|
|
@ -700,7 +821,7 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
|
|||
}
|
||||
embeddingsNodeLimit = parsed;
|
||||
}
|
||||
const embeddingsEnabled = !!options?.embeddings;
|
||||
const embeddingsEnabled = !!options.embeddings;
|
||||
|
||||
const setPositiveEnv = (
|
||||
optionName: string,
|
||||
|
|
@ -722,23 +843,23 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
|
|||
!setPositiveEnv(
|
||||
'--embedding-threads',
|
||||
'GITNEXUS_EMBEDDING_THREADS',
|
||||
options?.embeddingThreads,
|
||||
options.embeddingThreads,
|
||||
) ||
|
||||
!setPositiveEnv(
|
||||
'--embedding-batch-size',
|
||||
'GITNEXUS_EMBEDDING_BATCH_SIZE',
|
||||
options?.embeddingBatchSize,
|
||||
options.embeddingBatchSize,
|
||||
) ||
|
||||
!setPositiveEnv(
|
||||
'--embedding-sub-batch-size',
|
||||
'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE',
|
||||
options?.embeddingSubBatchSize,
|
||||
options.embeddingSubBatchSize,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.embeddingDevice) {
|
||||
if (options.embeddingDevice) {
|
||||
const allowed = new Set(['auto', 'cpu', 'dml', 'cuda', 'wasm']);
|
||||
if (!allowed.has(options.embeddingDevice)) {
|
||||
cliError(' --embedding-device must be one of: auto, cpu, dml, cuda, wasm.\n');
|
||||
|
|
@ -748,7 +869,7 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
|
|||
process.env.GITNEXUS_EMBEDDING_DEVICE = options.embeddingDevice;
|
||||
}
|
||||
|
||||
if (options?.repairFts && options?.force) {
|
||||
if (options.repairFts && options.force) {
|
||||
cliError(
|
||||
' Cannot combine `--repair-fts` with `--force`. ' +
|
||||
'Use `--repair-fts` for fast FTS-only repair, or `--force` for a full rebuild.\n',
|
||||
|
|
@ -757,52 +878,18 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
|
|||
return;
|
||||
}
|
||||
|
||||
console.log('\n GitNexus Analyzer\n');
|
||||
|
||||
// `--index-only` is the stronger contract — it suppresses every form of file
|
||||
// injection, including community skill writes that `--skills` would normally
|
||||
// produce. Surface the override explicitly so users don't wonder why a
|
||||
// pipeline re-index ran but no skill files appeared. The pipeline still
|
||||
// re-runs (see `force: options?.force || options?.skills` below); the warning
|
||||
// re-runs (see `force: options.force || options.skills` below); the warning
|
||||
// is purely about the dropped post-index write step.
|
||||
if (options?.indexOnly && options?.skills) {
|
||||
if (options.indexOnly && options.skills) {
|
||||
console.log(
|
||||
' Note: --index-only overrides --skills; community skill files will not be written.\n',
|
||||
);
|
||||
}
|
||||
|
||||
let repoPath: string;
|
||||
if (inputPath) {
|
||||
repoPath = path.resolve(inputPath);
|
||||
} else if (options?.skipGit) {
|
||||
// --skip-git: treat cwd as the index root, do not walk up to a parent git repo.
|
||||
repoPath = path.resolve(process.cwd());
|
||||
} else {
|
||||
const gitRoot = getGitRoot(process.cwd());
|
||||
if (!gitRoot) {
|
||||
console.log(
|
||||
' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
repoPath = gitRoot;
|
||||
}
|
||||
|
||||
const repoHasGit = hasGitDir(repoPath);
|
||||
if (!repoHasGit && !options?.skipGit) {
|
||||
console.log(
|
||||
' Not a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (!repoHasGit) {
|
||||
console.log(
|
||||
' Warning: no .git directory found \u2014 commit-tracking and incremental updates disabled.\n',
|
||||
);
|
||||
}
|
||||
|
||||
// If the target repo contains files an optional grammar would parse but
|
||||
// that grammar's native binding is absent, warn before analysis so users
|
||||
// learn why those files end up unparsed instead of silently getting a
|
||||
|
|
@ -932,36 +1019,39 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
|
|||
|
||||
// ── Run shared analysis orchestrator ───────────────────────────────
|
||||
try {
|
||||
const skipAll = options?.indexOnly;
|
||||
const skipAgentsMd = skipAll || options?.skipAgentsMd;
|
||||
const skipSkills = skipAll || options?.skipSkills;
|
||||
const skipAll = options.indexOnly;
|
||||
const skipAgentsMd = skipAll || options.skipAgentsMd;
|
||||
const skipSkills = skipAll || options.skipSkills;
|
||||
const result = await runFullAnalysis(
|
||||
repoPath,
|
||||
{
|
||||
// Pipeline re-index — OR'd with --skills because skill generation
|
||||
// needs a fresh pipelineResult. Has no bearing on the registry
|
||||
// collision guard (see allowDuplicateName below).
|
||||
force: options?.force || options?.skills,
|
||||
repairFts: options?.repairFts,
|
||||
force: options.force || options.skills,
|
||||
repairFts: options.repairFts,
|
||||
embeddings: embeddingsEnabled,
|
||||
embeddingsNodeLimit,
|
||||
dropEmbeddings: options?.dropEmbeddings,
|
||||
verbose: options?.verbose,
|
||||
skipGit: options?.skipGit,
|
||||
dropEmbeddings: options.dropEmbeddings,
|
||||
verbose: options.verbose,
|
||||
skipGit: options.skipGit,
|
||||
skipAgentsMd,
|
||||
skipSkills,
|
||||
// Resolved default branch (CLI > .gitnexusrc > auto-detect > "main")
|
||||
// threaded into the generated regression-compare example (#243).
|
||||
defaultBranch: resolvedDefaultBranch,
|
||||
// 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
|
||||
// passed --no-stats). Reading `options.noStats` here returns
|
||||
// undefined every time, so the flag was a no-op on the markdown
|
||||
// rewrite path before this fix. See #1477.
|
||||
noStats: options?.stats === false,
|
||||
registryName: options?.name,
|
||||
noStats: options.stats === false,
|
||||
registryName: options.name,
|
||||
// Registry-collision bypass — its own CLI flag, intentionally NOT
|
||||
// overloading --force. A user who hits the collision guard should
|
||||
// be able to accept the duplicate name without also paying the
|
||||
// cost of a full pipeline re-index. See #829 review round 2.
|
||||
allowDuplicateName: options?.allowDuplicateName,
|
||||
allowDuplicateName: options.allowDuplicateName,
|
||||
// Worker pool size threaded from --workers, replacing the previous
|
||||
// GITNEXUS_WORKER_POOL_SIZE env mutation. `undefined` defers to the
|
||||
// env / auto-formula fallback inside the pipeline.
|
||||
|
|
@ -981,6 +1071,21 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
|
|||
// that half-finalized state, runFullAnalysis returns alreadyUpToDate
|
||||
// on the next invocation unless we check the registry here too.
|
||||
await assertAnalysisFinalized(repoPath);
|
||||
// The fast path skips context regeneration, but a changed `.gitnexusrc`
|
||||
// defaultBranch / `--default-branch` must still take effect. Surgically
|
||||
// refresh just the `base_ref` line in AGENTS.md/CLAUDE.md in place,
|
||||
// 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).
|
||||
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 */
|
||||
}
|
||||
clearInterval(elapsedTimer);
|
||||
process.removeListener('SIGINT', sigintHandler);
|
||||
console.log = origLog;
|
||||
|
|
@ -990,6 +1095,11 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
|
|||
console.error = origError;
|
||||
bar.stop();
|
||||
console.log(' Already up to date\n');
|
||||
if (baseRefRefreshed.length > 0) {
|
||||
console.log(
|
||||
` Updated base_ref to "${resolvedDefaultBranch}" in ${baseRefRefreshed.join(', ')}\n`,
|
||||
);
|
||||
}
|
||||
// Safe to return without process.exit(0) — the early-return path in
|
||||
// runFullAnalysis never opens LadybugDB, so no native handles prevent exit.
|
||||
return;
|
||||
|
|
@ -1063,9 +1173,13 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
|
|||
{
|
||||
skipAgentsMd,
|
||||
skipSkills,
|
||||
// Same resolved branch as the main run (#243) so the --skills
|
||||
// re-generation of AGENTS.md/CLAUDE.md does not revert base_ref
|
||||
// to "main".
|
||||
defaultBranch: resolvedDefaultBranch,
|
||||
// Mirror runFullAnalysis `noStats` bridge (#1477) — same expression;
|
||||
// exercised on the `--skills` path by analyze-no-stats-bridge.test.ts.
|
||||
noStats: options?.stats === false,
|
||||
noStats: options.stats === false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -1185,6 +1299,20 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
|
|||
return;
|
||||
}
|
||||
|
||||
// Local embedding runtime unsupported on this platform (macOS Intel ships no
|
||||
// darwin/x64 ONNX native binding, #1515). The guard threw before importing
|
||||
// transformers.js, so this is a clean, actionable GitNexus message. Checked
|
||||
// before the network-heuristic isHfDownloadFailure branch below (and before
|
||||
// the generic module-not-found "installation may be corrupt" hint) so the
|
||||
// explicit platform message always takes priority.
|
||||
if (isLocalEmbeddingRuntimeBlockerMessage(msg)) {
|
||||
cliError(` ${msg.replace(/\n/g, '\n ')}\n`, {
|
||||
recoveryHint: 'local-embedding-unsupported',
|
||||
});
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// HF download failure — show clean guidance without the raw stack trace.
|
||||
// Checked before writeFatalToStderr so the user sees one focused message
|
||||
// rather than a stack-trace dump followed by a second remediation block.
|
||||
|
|
|
|||
|
|
@ -49,9 +49,12 @@ export type RecoveryHint =
|
|||
| 'heap-oom-respawn'
|
||||
| 'native-worker-abort'
|
||||
| 'hf-endpoint-unreachable'
|
||||
| 'local-embedding-unsupported'
|
||||
| 'large-repo'
|
||||
| 'npm-resolution'
|
||||
| 'module-not-found';
|
||||
| 'module-not-found'
|
||||
| 'gitnexusrc-invalid'
|
||||
| 'default-branch-invalid';
|
||||
|
||||
/**
|
||||
* Common shape for the optional structured-field bag passed to
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { getRuntimeCapabilities, getRuntimeFingerprint } from '../core/platform/capabilities.js';
|
||||
import { resolveEmbeddingConfig } from '../core/embeddings/config.js';
|
||||
import { isHttpMode } from '../core/embeddings/http-client.js';
|
||||
import { getLocalEmbeddingRuntimeBlocker } from '../core/embeddings/runtime-support.js';
|
||||
import { checkLbugNative } from '../core/lbug/native-check.js';
|
||||
import { getExtensionInstallPolicy } from '../core/lbug/extension-loader.js';
|
||||
import { t } from './i18n/index.js';
|
||||
|
|
@ -50,6 +51,33 @@ export function padDisplayEnd(value: string, columns: number): string {
|
|||
|
||||
const label = (key: Parameters<typeof t>[0], width: number): string => padDisplayEnd(t(key), width);
|
||||
|
||||
/**
|
||||
* Embedding-runtime support status for the `doctor` Embeddings section.
|
||||
* Pure and DI-friendly so it can be unit-tested without running the whole
|
||||
* command. Delegates the platform decision to
|
||||
* {@link getLocalEmbeddingRuntimeBlocker} so the wording stays in one place.
|
||||
*
|
||||
* - HTTP mode: always supported (never touches the native runtime).
|
||||
* - Local mode on an unsupported platform (macOS Intel, #1515): reports the
|
||||
* blocker as `detail` so the caller can surface the full guidance.
|
||||
*/
|
||||
export function localEmbeddingDoctorStatus(opts: {
|
||||
httpMode: boolean;
|
||||
platform?: NodeJS.Platform;
|
||||
arch?: NodeJS.Architecture;
|
||||
}): { status: string; detail: string | null } {
|
||||
if (opts.httpMode) {
|
||||
return { status: '✓ http endpoint configured', detail: null };
|
||||
}
|
||||
const platform = opts.platform ?? process.platform;
|
||||
const arch = opts.arch ?? process.arch;
|
||||
const blocker = getLocalEmbeddingRuntimeBlocker({ platform, arch });
|
||||
if (blocker) {
|
||||
return { status: `✗ local embeddings unavailable on ${platform}/${arch}`, detail: blocker };
|
||||
}
|
||||
return { status: '✓ local embeddings supported', detail: null };
|
||||
}
|
||||
|
||||
export const doctorCommand = async () => {
|
||||
const fingerprint = getRuntimeFingerprint();
|
||||
const capabilities = getRuntimeCapabilities();
|
||||
|
|
@ -102,4 +130,13 @@ export const doctorCommand = async () => {
|
|||
console.log(
|
||||
` ${label('doctor.labels.subBatch', 12)}${t('doctor.chunks', { count: embeddingConfig.subBatchSize })}`,
|
||||
);
|
||||
// Surface local-runtime support so macOS Intel users see up front that local
|
||||
// embeddings can't load here (the bundled ONNX Runtime ships no darwin/x64
|
||||
// native binding, #1515) — rather than discovering it only when
|
||||
// `analyze --embeddings` fails. Literal label like the 'native' line above.
|
||||
const support = localEmbeddingDoctorStatus({ httpMode: isHttpMode() });
|
||||
console.log(` ${padDisplayEnd('Support:', 12)}${support.status}`);
|
||||
if (support.detail) {
|
||||
process.stderr.write(`\n${support.detail.replace(/^/gm, ' ')}\n\n`);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -101,6 +101,9 @@ const OPTION_DESCRIPTION_KEYS = {
|
|||
'context|--content': 'help.option.content',
|
||||
'impact|-d, --direction <dir>': 'help.option.impact.direction',
|
||||
'impact|-r, --repo <name>': 'help.option.repo.target',
|
||||
'impact|-u, --uid <uid>': 'help.option.context.uid',
|
||||
'impact|-f, --file <path>': 'help.option.context.file',
|
||||
'impact|--kind <kind>': 'help.option.impact.kind',
|
||||
'impact|--depth <n>': 'help.option.impact.depth',
|
||||
'impact|--include-tests': 'help.option.impact.includeTests',
|
||||
'impact|--limit <n>': 'help.option.impact.limit',
|
||||
|
|
|
|||
|
|
@ -43,8 +43,11 @@ export const en = {
|
|||
'tool.noIndexed': 'GitNexus: No indexed repositories found. Run: gitnexus analyze',
|
||||
'tool.usage.query': 'Usage: gitnexus query <search_query>',
|
||||
'tool.usage.context': 'Usage: gitnexus context <symbol_name> [--uid <uid>] [--file <path>]',
|
||||
'tool.usage.impact': 'Usage: gitnexus impact <symbol_name> [--direction upstream|downstream]',
|
||||
'tool.usage.impact':
|
||||
'Usage: gitnexus impact <symbol_name> [--uid <uid>] [--file <path>] [--kind <kind>] [--direction upstream|downstream]',
|
||||
'tool.usage.cypher': 'Usage: gitnexus cypher <cypher_query>',
|
||||
'tool.warn.unknownKind':
|
||||
"--kind '{{kind}}' is not a known symbol kind (e.g. Function, Class, Method); it will not narrow the result.",
|
||||
'tool.detectChanges.noChanges': 'No changes detected.',
|
||||
'tool.detectChanges.changesSummary': 'Changes: {{files}} files, {{symbols}} symbols',
|
||||
'tool.detectChanges.affectedProcesses': 'Affected processes: {{count}}',
|
||||
|
|
@ -186,7 +189,7 @@ export const en = {
|
|||
'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':
|
||||
'LLM provider: openai, openrouter, azure, custom, cursor, claude, or codex (default: openai)',
|
||||
'LLM provider: openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: openai)',
|
||||
'help.option.wiki.model': 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)',
|
||||
'help.option.wiki.baseUrl':
|
||||
'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1',
|
||||
|
|
@ -213,6 +216,8 @@ export const en = {
|
|||
'help.option.repo.target': 'Target repository',
|
||||
'help.option.context.uid': 'Direct symbol UID (zero-ambiguity lookup)',
|
||||
'help.option.context.file': 'File path to disambiguate common names',
|
||||
'help.option.impact.kind':
|
||||
'Kind filter to disambiguate common names (e.g. Function, Class, Method)',
|
||||
'help.option.impact.direction': 'upstream (dependants) or downstream (dependencies)',
|
||||
'help.option.impact.depth': 'Max relationship depth (default: 3)',
|
||||
'help.option.impact.includeTests': 'Include test files in results',
|
||||
|
|
|
|||
|
|
@ -47,8 +47,11 @@ export const zhCN = {
|
|||
'tool.noIndexed': 'GitNexus:未找到已索引仓库。请运行:gitnexus analyze',
|
||||
'tool.usage.query': '用法:gitnexus query <搜索词>',
|
||||
'tool.usage.context': '用法:gitnexus context <符号名> [--uid <uid>] [--file <路径>]',
|
||||
'tool.usage.impact': '用法:gitnexus impact <符号名> [--direction upstream|downstream]',
|
||||
'tool.usage.impact':
|
||||
'用法:gitnexus impact <符号名> [--uid <uid>] [--file <路径>] [--kind <类型>] [--direction upstream|downstream]',
|
||||
'tool.usage.cypher': '用法:gitnexus cypher <Cypher 查询>',
|
||||
'tool.warn.unknownKind':
|
||||
"--kind '{{kind}}' 不是已知的符号类型(如 Function、Class、Method),不会用于缩小结果范围。",
|
||||
'tool.detectChanges.noChanges': '未检测到变更。',
|
||||
'tool.detectChanges.changesSummary': '变更:{{files}} 个文件,{{symbols}} 个符号',
|
||||
'tool.detectChanges.affectedProcesses': '受影响流程:{{count}}',
|
||||
|
|
@ -175,7 +178,7 @@ export const zhCN = {
|
|||
'help.option.clean.lbugSidecars': '清理已隔离的 LadybugDB missing-shadow WAL sidecar',
|
||||
'help.option.wiki.force': '即使已是最新也强制完整重新生成',
|
||||
'help.option.wiki.provider':
|
||||
'LLM 提供商:openai、openrouter、azure、custom、cursor、claude 或 codex(默认:openai)',
|
||||
'LLM 提供商:openai、openrouter、azure、custom、cursor、claude、codex 或 opencode(默认:openai)',
|
||||
'help.option.wiki.model': 'LLM 模型或 Azure deployment 名称(默认:minimax/minimax-m2.5)',
|
||||
'help.option.wiki.baseUrl':
|
||||
'LLM API base URL。Azure v1:https://{resource}.openai.azure.com/openai/v1',
|
||||
|
|
@ -199,6 +202,7 @@ export const zhCN = {
|
|||
'help.option.repo.target': '目标仓库',
|
||||
'help.option.context.uid': '直接符号 UID(零歧义查找)',
|
||||
'help.option.context.file': '用于消除常见名称歧义的文件路径',
|
||||
'help.option.impact.kind': '用于消除常见名称歧义的类型过滤(如 Function、Class、Method)',
|
||||
'help.option.impact.direction': 'upstream(依赖它的项)或 downstream(它依赖的项)',
|
||||
'help.option.impact.depth': '最大关系遍历深度(默认:3)',
|
||||
'help.option.impact.includeTests': '在结果中包含测试文件',
|
||||
|
|
|
|||
|
|
@ -44,6 +44,11 @@ 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(
|
||||
'--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('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
|
||||
.option(
|
||||
'--skip-skills',
|
||||
|
|
@ -150,7 +155,7 @@ program
|
|||
.option('-f, --force', 'Force full regeneration even if up to date')
|
||||
.option(
|
||||
'--provider <provider>',
|
||||
'LLM provider: openai, openrouter, azure, custom, cursor, claude, or codex (default: openai)',
|
||||
'LLM provider: openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: openai)',
|
||||
)
|
||||
.option('--model <model>', 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)')
|
||||
.option(
|
||||
|
|
@ -219,10 +224,16 @@ program
|
|||
.action(createLbugLazyAction(() => import('./tool.js'), 'contextCommand'));
|
||||
|
||||
program
|
||||
.command('impact <target>')
|
||||
.command('impact [target]')
|
||||
.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('-u, --uid <uid>', 'Direct symbol UID (zero-ambiguity lookup)')
|
||||
.option('-f, --file <path>', 'File path to disambiguate common names')
|
||||
.option(
|
||||
'--kind <kind>',
|
||||
'Kind filter to disambiguate common names (e.g. Function, Class, Method)',
|
||||
)
|
||||
.option('--depth <n>', 'Max relationship depth (default: 3)')
|
||||
.option('--include-tests', 'Include test files in results')
|
||||
.option('--limit <n>', 'Max symbols per depth level (default: 100)')
|
||||
|
|
|
|||
98
gitnexus/src/cli/resolve-invocation.ts
Normal file
98
gitnexus/src/cli/resolve-invocation.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* npm 11.x npx-install-crash nudge for the `analyze` command (#1939).
|
||||
*
|
||||
* The gitnexus/pnpm/npx selection itself lives in the canonical hook helper
|
||||
* (hooks/claude/resolve-analyze-cmd.cjs) — self-contained CJS because the copied
|
||||
* hook runtime cannot import from the package. We reuse it here via createRequire
|
||||
* instead of re-implementing it, so there is one source of truth for the
|
||||
* invocation decision. This module adds only the npm-version probe and the
|
||||
* warning, which are CLI-only. The relative path resolves identically from
|
||||
* src/cli/ (tsx, vitest) and dist/cli/ (shipped), since both sit one level under
|
||||
* the package root and `hooks/` is published.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
type InvocationMode = 'gitnexus' | 'pnpm' | 'npx';
|
||||
|
||||
interface InvocationResolver {
|
||||
// `probe` is injectable in the cjs (defaults to the real PATH probe) so the
|
||||
// preference order is unit-testable without spawning; the CLI calls it with
|
||||
// no argument.
|
||||
resolveInvocationMode: (
|
||||
probe?: (command: string, gitnexusWrapper?: boolean) => string | null,
|
||||
) => InvocationMode;
|
||||
formatDocumentationDlxCommand: (
|
||||
gitnexusArgs: string,
|
||||
options?: { embeddings?: boolean },
|
||||
) => string;
|
||||
NPX_REF: string;
|
||||
}
|
||||
|
||||
const { resolveInvocationMode, formatDocumentationDlxCommand, NPX_REF } = createRequire(
|
||||
import.meta.url,
|
||||
// `require()` returns `any`; go through `unknown` so the cast reads as an
|
||||
// explicit narrowing to the subset this module uses, not a claim that the
|
||||
// cjs's full export shape is known here. The drift guard below verifies it.
|
||||
)('../../hooks/claude/resolve-analyze-cmd.cjs') as unknown as InvocationResolver;
|
||||
|
||||
// Fail loud at module load if the canonical cjs export shape drifts (e.g. a
|
||||
// renamed export), rather than as a late TypeError inside warnIfNpm11NpxRisk.
|
||||
if (
|
||||
typeof resolveInvocationMode !== 'function' ||
|
||||
typeof formatDocumentationDlxCommand !== 'function' ||
|
||||
typeof NPX_REF !== 'string'
|
||||
) {
|
||||
throw new Error(
|
||||
'resolve-analyze-cmd.cjs must export resolveInvocationMode (function), formatDocumentationDlxCommand (function), and NPX_REF (string)',
|
||||
);
|
||||
}
|
||||
|
||||
export { NPX_REF };
|
||||
|
||||
// Re-implemented here (rather than reusing the cjs export) so vitest's
|
||||
// `vi.mock('node:child_process')` intercepts it — the cjs uses bare
|
||||
// `require('child_process')`, which the mock cannot reach. Timeout matches the
|
||||
// cjs PROBE_TIMEOUT_MS (1s) so this CLI probe shares the same hook-budget cap;
|
||||
// `npm --version` is a sub-second local call.
|
||||
export function getNpmMajorVersion(): number | null {
|
||||
try {
|
||||
const output = execFileSync('npm', ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 1000,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
// Windows `npm` is a `.cmd` shim; without a shell execFileSync ENOENTs
|
||||
// (CVE-2024-27980) and the npm-11 npx-crash warning below would never
|
||||
// fire on Windows. Mirrors probeVersion in resolve-analyze-cmd.cjs.
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
// Read the first version-shaped line so a Corepack/update banner on stdout
|
||||
// doesn't defeat the parse (mirrors the cjs probeVersion hardening).
|
||||
const major = output
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => /^v?\d+\./.test(l))
|
||||
?.match(/^v?(\d+)\./);
|
||||
return major ? Number(major[1]) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line stderr nudge when an npm 11+ user is on the npx install path (#1939).
|
||||
* Skipped when a global `gitnexus` or `pnpm` is already preferred, so it never
|
||||
* nags users who are not exposed to the npx/arborist crash.
|
||||
*/
|
||||
export function warnIfNpm11NpxRisk(): void {
|
||||
if (resolveInvocationMode() !== 'npx') return;
|
||||
const major = getNpmMajorVersion();
|
||||
if (major === null || major < 11) return;
|
||||
process.stderr.write(
|
||||
`Warning: npm ${major}.x can crash while installing gitnexus via npx ` +
|
||||
`(npm/arborist "node.target is null"). Prefer: ${formatDocumentationDlxCommand('analyze')} ` +
|
||||
`or npm install -g ${NPX_REF}. See https://github.com/abhigyanpatwari/GitNexus/issues/1939\n`,
|
||||
);
|
||||
}
|
||||
|
|
@ -33,7 +33,44 @@ if (typeof _pkg.version !== 'string' || !_pkg.version) {
|
|||
'gitnexus/package.json#version is missing or not a string — cannot generate MCP fallback config.',
|
||||
);
|
||||
}
|
||||
const NPX_REF = `gitnexus@${_pkg.version}`;
|
||||
// Version-pinned ref for the persisted MCP entry — deliberately distinct from
|
||||
// the cjs's exported `gitnexus@latest` hint ref (resolve-analyze-cmd.cjs); the
|
||||
// two are not unified (see the comment above and that file's MCP_PINNED_REF).
|
||||
const MCP_PINNED_REF = `gitnexus@${_pkg.version}`;
|
||||
|
||||
/**
|
||||
* Build the `command` string written into an editor's hook settings, which the
|
||||
* editor shell-evaluates. `hookPath` is already forward-slash-normalized.
|
||||
*
|
||||
* On POSIX, single-quote the path: a single-quoted shell string expands nothing,
|
||||
* so spaces and metacharacters ($, backtick, ;, |, &, newline, parens) in the
|
||||
* install path cannot run as commands. The only character needing escaping
|
||||
* inside single quotes is the single quote, via the standard `'\''` idiom
|
||||
* (close, literal-quote, reopen). The previous double-quoted `node "..."` form
|
||||
* left $/backtick live — a code-execution risk for an adversarial $HOME.
|
||||
*
|
||||
* On Windows, filenames cannot contain these POSIX metacharacters and the path
|
||||
* is forward-slashed, so keep the double-quoted form with backslash-then-quote
|
||||
* escaping (CodeQL js/incomplete-sanitization safe ordering).
|
||||
*/
|
||||
export function formatHookCommand(
|
||||
hookPath: string,
|
||||
isWindows = process.platform === 'win32',
|
||||
): string {
|
||||
if (isWindows) {
|
||||
const escaped = hookPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
return `node "${escaped}"`;
|
||||
}
|
||||
return `node '${hookPath.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
// The exact source line each hook adapter ships, rewritten at install time to
|
||||
// point cliPath at the installed CLI. Kept as a named constant so the install
|
||||
// patch and its drift guard reference one string — if the adapter source ever
|
||||
// changes this literal, the guard records an actionable error instead of
|
||||
// silently shipping a hook with an unresolved relative cliPath.
|
||||
const CLI_PATH_SOURCE_LITERAL =
|
||||
"let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');";
|
||||
|
||||
interface SetupResult {
|
||||
configured: string[];
|
||||
|
|
@ -99,12 +136,12 @@ function getMcpEntry() {
|
|||
if (process.platform === 'win32') {
|
||||
return {
|
||||
command: 'cmd',
|
||||
args: ['/c', 'npx', '-y', NPX_REF, 'mcp'],
|
||||
args: ['/c', 'npx', '-y', MCP_PINNED_REF, 'mcp'],
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: 'npx',
|
||||
args: ['-y', NPX_REF, 'mcp'],
|
||||
args: ['-y', MCP_PINNED_REF, 'mcp'],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -120,9 +157,9 @@ function getOpenCodeMcpEntry() {
|
|||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return { type: 'local', command: ['cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'] };
|
||||
return { type: 'local', command: ['cmd', '/c', 'npx', '-y', MCP_PINNED_REF, 'mcp'] };
|
||||
}
|
||||
return { type: 'local', command: ['npx', '-y', NPX_REF, 'mcp'] };
|
||||
return { type: 'local', command: ['npx', '-y', MCP_PINNED_REF, 'mcp'] };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -334,6 +371,48 @@ async function mergeHooksJsonc(
|
|||
return true;
|
||||
}
|
||||
|
||||
const HOOK_HELPERS = [
|
||||
'hook-lock.cjs',
|
||||
'hook-db-lock-probe.cjs',
|
||||
'win-rm-list-json.ps1',
|
||||
'resolve-analyze-cmd.cjs',
|
||||
] as const;
|
||||
|
||||
// win-rm-list-json.ps1 is best-effort: it is read (not require()'d) by
|
||||
// hook-db-lock-probe.cjs only on Windows, and that probe fails open when the
|
||||
// script is absent. Every other helper is top-level require()'d by the adapters,
|
||||
// so its absence crashes the installed hook — those are the ones a failed copy
|
||||
// must gate hook registration on (see copyHookHelpers' return value).
|
||||
const BEST_EFFORT_HOOK_HELPERS = new Set<string>(['win-rm-list-json.ps1']);
|
||||
|
||||
/**
|
||||
* Copy the shared hook helpers from `srcDir` into `destDir`. The adapters
|
||||
* top-level `require()` the `.cjs` helpers, so a missing required helper makes
|
||||
* the installed hook crash with MODULE_NOT_FOUND. A failed copy is recorded as a
|
||||
* setup error, and the names of any failed REQUIRED helpers are returned so the
|
||||
* caller can fail closed (skip hook registration) instead of registering a hook
|
||||
* that crashes at runtime. `win-rm-list-json.ps1` is best-effort — its absence is
|
||||
* recorded but does not gate registration. Both the Claude and Antigravity
|
||||
* install paths copy this same list from hooks/claude/ (the canonical source).
|
||||
*/
|
||||
export async function copyHookHelpers(
|
||||
srcDir: string,
|
||||
destDir: string,
|
||||
label: string,
|
||||
result: SetupResult,
|
||||
): Promise<string[]> {
|
||||
const failedRequired: string[] = [];
|
||||
for (const helper of HOOK_HELPERS) {
|
||||
try {
|
||||
await fs.copyFile(path.join(srcDir, helper), path.join(destDir, helper));
|
||||
} catch {
|
||||
result.errors.push(`${label}: failed to copy ${helper} — hook may crash at runtime`);
|
||||
if (!BEST_EFFORT_HOOK_HELPERS.has(helper)) failedRequired.push(helper);
|
||||
}
|
||||
}
|
||||
return failedRequired;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install GitNexus hooks to ~/.claude/settings.json for Claude Code.
|
||||
* Merges hook config without overwriting existing hooks, preserving
|
||||
|
|
@ -361,49 +440,44 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
|
|||
const resolvedCli = path.join(__dirname, '..', 'cli', 'index.js');
|
||||
const normalizedCli = path.resolve(resolvedCli).replace(/\\/g, '/');
|
||||
const jsonCli = JSON.stringify(normalizedCli);
|
||||
content = content.replace(
|
||||
"let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');",
|
||||
`let cliPath = ${jsonCli};`,
|
||||
);
|
||||
if (!content.includes(CLI_PATH_SOURCE_LITERAL)) {
|
||||
result.errors.push(
|
||||
'Claude Code hooks: gitnexus-hook.cjs no longer contains the cliPath literal to patch — the installed hook may fail to resolve the CLI. Update CLI_PATH_SOURCE_LITERAL in setup.ts.',
|
||||
);
|
||||
}
|
||||
content = content.replace(CLI_PATH_SOURCE_LITERAL, `let cliPath = ${jsonCli};`);
|
||||
await fs.writeFile(dest, content, 'utf-8');
|
||||
} catch {
|
||||
// Script not found in source — skip
|
||||
}
|
||||
|
||||
// Fail closed: registering the hook without its adapter would crash on every
|
||||
// tool invocation. Mirrors the Antigravity adapter guard below (this path
|
||||
// previously registered regardless of whether the adapter wrote).
|
||||
try {
|
||||
await fs.copyFile(
|
||||
path.join(pluginHooksPath, 'hook-lock.cjs'),
|
||||
path.join(destHooksDir, 'hook-lock.cjs'),
|
||||
);
|
||||
await fs.access(dest);
|
||||
} catch {
|
||||
// Helper not found in source — skip
|
||||
result.errors.push(
|
||||
'Claude Code hooks: adapter script was not installed — skipping hook registration',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.copyFile(
|
||||
path.join(pluginHooksPath, 'hook-db-lock-probe.cjs'),
|
||||
path.join(destHooksDir, 'hook-db-lock-probe.cjs'),
|
||||
const failedRequired = await copyHookHelpers(
|
||||
pluginHooksPath,
|
||||
destHooksDir,
|
||||
'Claude Code hooks',
|
||||
result,
|
||||
);
|
||||
if (failedRequired.length > 0) {
|
||||
result.errors.push(
|
||||
`Claude Code hooks: required helper(s) ${failedRequired.join(', ')} failed to copy — skipping hook registration`,
|
||||
);
|
||||
} catch {
|
||||
// Helper not found in source — skip
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.copyFile(
|
||||
path.join(pluginHooksPath, 'win-rm-list-json.ps1'),
|
||||
path.join(destHooksDir, 'win-rm-list-json.ps1'),
|
||||
);
|
||||
} catch {
|
||||
// Helper not found in source — skip
|
||||
return;
|
||||
}
|
||||
|
||||
const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/');
|
||||
// Escape backslashes FIRST, then quotes (CodeQL js/incomplete-sanitization).
|
||||
// The previous shape `replace(/"/g, '\\"')` alone would let `path\with"quote`
|
||||
// become `path\with\"quote`, where the trailing `\` before `"` could
|
||||
// unescape the quote inside the surrounding double-quoted shell context.
|
||||
const escapedHookPath = hookPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const hookCmd = `node "${escapedHookPath}"`;
|
||||
const hookCmd = formatHookCommand(hookPath);
|
||||
|
||||
// Check which hook events need entries (idempotent: skip if already registered)
|
||||
const parsed = await (async () => {
|
||||
|
|
@ -566,10 +640,12 @@ async function installAntigravityHooks(result: SetupResult): Promise<void> {
|
|||
const resolvedCli = path.join(__dirname, '..', 'cli', 'index.js');
|
||||
const normalizedCli = path.resolve(resolvedCli).replace(/\\/g, '/');
|
||||
const jsonCli = JSON.stringify(normalizedCli);
|
||||
content = content.replace(
|
||||
"let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');",
|
||||
`let cliPath = ${jsonCli};`,
|
||||
);
|
||||
if (!content.includes(CLI_PATH_SOURCE_LITERAL)) {
|
||||
result.errors.push(
|
||||
'Antigravity hooks: gitnexus-antigravity-hook.cjs no longer contains the cliPath literal to patch — the installed hook may fail to resolve the CLI. Update CLI_PATH_SOURCE_LITERAL in setup.ts.',
|
||||
);
|
||||
}
|
||||
content = content.replace(CLI_PATH_SOURCE_LITERAL, `let cliPath = ${jsonCli};`);
|
||||
await fs.writeFile(adapterDest, content, 'utf-8');
|
||||
} catch {
|
||||
// Adapter not found in source — skip
|
||||
|
|
@ -591,19 +667,21 @@ async function installAntigravityHooks(result: SetupResult): Promise<void> {
|
|||
// required by hook-db-lock-probe.cjs on Windows — without it, the MCP
|
||||
// server ownership probe silently fails open and the hook may contend
|
||||
// with the MCP server on the LadybugDB.
|
||||
for (const helper of ['hook-lock.cjs', 'hook-db-lock-probe.cjs', 'win-rm-list-json.ps1']) {
|
||||
try {
|
||||
await fs.copyFile(path.join(pluginClaudeDir, helper), path.join(destHooksDir, helper));
|
||||
} catch {
|
||||
result.errors.push(
|
||||
`Antigravity hooks: failed to copy ${helper} — hook may crash at runtime`,
|
||||
);
|
||||
}
|
||||
const failedRequired = await copyHookHelpers(
|
||||
pluginClaudeDir,
|
||||
destHooksDir,
|
||||
'Antigravity hooks',
|
||||
result,
|
||||
);
|
||||
if (failedRequired.length > 0) {
|
||||
result.errors.push(
|
||||
`Antigravity hooks: required helper(s) ${failedRequired.join(', ')} failed to copy — skipping hook registration`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const hookPath = path.join(destHooksDir, 'gitnexus-antigravity-hook.cjs').replace(/\\/g, '/');
|
||||
const escapedHookPath = hookPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const hookCmd = `node "${escapedHookPath}"`;
|
||||
const hookCmd = formatHookCommand(hookPath);
|
||||
|
||||
const parsed = await (async () => {
|
||||
try {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue