Merge upstream main into feat/r-language-support

This commit is contained in:
Mick Cooney 2026-10-17 22:54:14 +01:00
commit bdaefacd31
61 changed files with 2896 additions and 197 deletions

View file

@ -14,21 +14,26 @@
# `@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:
# The digest below is for `5.2.3-22-bookworm` (Node 22.23.3). Workspace
# dependencies require at least Node 22.22.2 on the Node 22 line. 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 \
# mcr.microsoft.com/devcontainers/typescript-node:5.2.3-22-bookworm \
# --format '{{json .Manifest.Digest}}'
FROM mcr.microsoft.com/devcontainers/typescript-node@sha256:7c2e711a4f7b02f32d2da16192d5e05aa7c95279be4ce889cff5df316f251c1d
FROM mcr.microsoft.com/devcontainers/typescript-node@sha256:0cb6b4bfcb0458a6c1d2345934fb9de35da7b9d32343f47e49d9bd66feeac780
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Bun is installed via the official remote script (bun.sh/install), pinned by
# version. Claude Code and Cursor also use official install scripts (no version
# to pin). To harden Bun: switch to a pinned tarball + per-arch sha256
# version. Claude Code, Codex, and Cursor also use official install scripts.
# To harden Bun: switch to a pinned tarball + per-arch sha256
# (release artifacts at github.com/oven-sh/bun/releases).
ARG BUN_VERSION
ARG TZ=UTC
ARG USERNAME=node
ARG INSTALL_BROWSER_DEPS=false
ARG PLAYWRIGHT_VERSION
# 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
@ -38,25 +43,35 @@ ENV BUN_VERSION=${BUN_VERSION} \
BUN_INSTALL=/home/${USERNAME}/.bun \
TZ=${TZ} \
DEVCONTAINER=true \
NODE_OPTIONS=--max-old-space-size=4096 \
GITNEXUS_AUTO_HEAP=0 \
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 /
# required. util-linux supplies findmnt/mount for the 9p helper; the remaining
# diagnostic tools support investigating process, memory and filesystem delays.
# 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 \
python3 make g++ git curl ca-certificates bash unzip util-linux procps lsof strace time jq ripgrep \
&& rm -rf /var/lib/apt/lists/*
# UI profile: install OS dependencies at image-build time as root. Runtime
# browser downloads use the project's locked Playwright and a persistent volume.
RUN --mount=type=cache,target=/root/.npm \
if [ "${INSTALL_BROWSER_DEPS}" = true ]; then \
test -n "${PLAYWRIGHT_VERSION}" \
&& npm exec --yes --package="@playwright/test@${PLAYWRIGHT_VERSION}" -- playwright install-deps chromium \
&& rm -rf /var/lib/apt/lists/*; \
fi
COPY bootstrap-native.cjs /usr/local/share/gitnexus-devcontainer/bootstrap-native.cjs
# 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.
# ownership the first time it is mounted. post-create.sh handles ownership on
# the CLI config volumes (~/.claude, ~/.codex, ~/.cursor), including reused ones.
RUN mkdir -p \
/home/${USERNAME}/.npm \
/home/${USERNAME}/.local/bin \
@ -68,36 +83,38 @@ RUN mkdir -p \
USER ${USERNAME}
# Native CLI installers use this directory for their launchers.
ENV PATH=/home/${USERNAME}/.local/bin:${BUN_INSTALL}/bin:${PATH}
# Install Claude Code via the official native installer. Downloads the latest
# self-contained binary for the running platform and places it at
# ~/.local/bin/claude — no Node.js runtime dependency, no version to pin.
RUN curl -fsSL https://claude.ai/install.sh | bash
RUN curl -fsSL --retry 3 --max-time 120 https://claude.ai/install.sh | bash \
&& /home/${USERNAME}/.local/bin/claude --version
# Install the Codex CLI globally via npm. No version pinned — @latest at build
# time. (Codex has no native binary installer; npm is the canonical method.)
RUN npm install -g @openai/codex
# Install standalone Codex without npm or interactive onboarding. Keep a seed
# outside ~/.codex: its runtime config volume can hide image-installed packages
# on rebuild. post-create.sh restores the seed only if Codex cannot run.
RUN curl -fsSL --retry 3 --max-time 120 https://chatgpt.com/codex/install.sh -o /tmp/install-codex.sh \
&& CODEX_NON_INTERACTIVE=1 sh /tmp/install-codex.sh \
&& /home/${USERNAME}/.local/bin/codex --version \
&& mkdir -p /home/${USERNAME}/.local/share \
&& mv /home/${USERNAME}/.codex/packages/standalone /home/${USERNAME}/.local/share/codex-standalone \
&& rm /tmp/install-codex.sh
# Install the Cursor agent CLI via the official install script. Downloads the
# latest agent-cli-package for the running platform and places `cursor-agent`
# and `agent` into ~/.local/bin — no version or hash to pin.
RUN curl -fsSL https://cursor.com/install | bash
RUN curl -fsSL --retry 3 --max-time 120 https://cursor.com/install | bash \
&& /home/${USERNAME}/.local/bin/cursor-agent --version
# 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
# on every build. The installer script itself is not pinned. `BUN_INSTALL` is
# set in ENV above so the binary lands at a known path regardless of 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}

View file

@ -4,7 +4,9 @@ A cross-platform Dev Container that pre-installs Claude Code, OpenAI Codex CLI,
> ### ⚠️ 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**.
> **The default 9p setup grants `SYS_ADMIN`, which can weaken read-only mount protections.** Remove that capability and disable tuning as described below to restore the boundary assumed in the following security notes.
>
> **With `SYS_ADMIN` removed**, 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.
>
@ -18,7 +20,7 @@ A cross-platform Dev Container that pre-installs Claude Code, OpenAI Codex CLI,
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.
5. Wait for the image build and lifecycle commands to finish preparing dependencies and AI CLI configuration.
6. Authenticate the three CLIs once — see [First-time CLI authentication](#first-time-cli-authentication) below.
## Windows 11 setup
@ -48,7 +50,7 @@ Windows-native works, but Docker Desktop's Windows bind-mount layer has rough ed
- **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.
- **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. `install-deps.sh` rebuilds the generated cache and verifies the pre-commit hook, 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.
@ -84,11 +86,92 @@ Open the repo folder in VS Code → **Reopen in Container**. The image is multi-
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.
## Workspace and image profiles
The main configuration keeps your existing host checkout bind-mounted at `/workspace`. For Windows hosts, the WSL2 filesystem setup above avoids the Windows filesystem translation. Additional profiles are generated from the main configuration, using host Node.js with no npm installation required:
```bash
# A separate checkout in a Docker-managed native filesystem volume.
node .devcontainer/profile.cjs native --ref main
# Bind-mounted checkout with Chromium system dependencies and browser cache.
node .devcontainer/profile.cjs ui
# Combine the native workspace and UI support.
node .devcontainer/profile.cjs native --ui --ref main
# Work on a branch pushed to your own fork (HTTPS or Git SSH).
node .devcontainer/profile.cjs native --url https://github.com/YOUR-NAME/GitNexus.git --ref your-branch
# Pull a published toolchain image instead of building locally.
node .devcontainer/profile.cjs prebuilt
node .devcontainer/profile.cjs prebuilt --ui
```
These commands overwrite the ignored `.devcontainer/generated/devcontainer.json`. Select that configuration when reopening the folder in Dev Containers, or pass `--config .devcontainer/generated/devcontainer.json` to the Dev Container CLI. Regenerate after changing the main configuration. Existing containers need recreation when switching workspace mounts.
The native profile clones the selected **remote branch** of GitNexus into its volume before dependency setup. It does not copy uncommitted host edits. Push your branch first and use `--ref your-branch` to work on it; contributors using a fork must also pass `--url` for that fork. HTTPS, `git@host:owner/repo.git`, and `ssh://git@host/owner/repo.git` URLs are supported without embedded HTTPS credentials. Fresh checkouts track their remote branch, so ordinary `git pull` and `git push` work. Reused volumes keep their current branch and local changes; the bootstrap neither resets nor automatically pulls an existing checkout. The volume is the authoritative checkout: use Git to export changes before removing it. Host credentials/configuration still use the existing read-only staging mounts.
`--ref` and `--url` apply only to `native`. `--image` selects an existing image for consumer profiles and preserves the configured Features, including GitHub CLI, so custom images need not bundle `gh`. Dev Containers applies those Features to the selected image. `image-build` accepts only `--ui`, since its build output is named by the publishing command.
UI profiles install OS dependencies during the image build using the Playwright version in `gitnexus-web/package-lock.json`. Setup installs matching Chromium binaries into a persistent browser-cache volume. They also allocate 1 GiB of private shared memory. Prebuilt UI profiles require the `main-ui` image variant; explicit images passed with `--image` must include those OS dependencies.
## Dependency reuse, storage and diagnostics
Setup records completed installations inside each dependency volume. Fingerprints cover package manifests, lockfiles, local file-dependency manifests, Node version/ABI, npm version, architecture, and tracked native grammar/build inputs. The CLI receipt also includes the effective `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` setting. A matching receipt with all direct dependencies present skips `npm ci`. Missing dependencies or changed inputs cause a clean reinstall, with lifecycle scripts enabled. Failed installs leave no valid receipt.
Husky preparation and verification **always run**, including cached setup. Shared and CLI source builds run independently of dependency reuse, so pulling source changes still refreshes compiled output. After pulling changes in a running container, run:
```bash
bash .devcontainer/install-deps.sh
# Repair nested dependency damage, new untracked native build inputs, or
# changed npm installation settings that are outside the fingerprint.
GITNEXUS_FORCE_DEPS=1 bash .devcontainer/install-deps.sh
```
The shared GitNexus store and registry persist under `GITNEXUS_HOME=/home/node/.gitnexus` in a native named volume, retaining worktree sharing. Existing checkout-local `.gitnexus` directories stay in place; a new mount does not hide or replace them. The native workspace profile also places checkout-local indexes on its native filesystem. If moving an existing container whose shared store lives only in its writable layer, export that store before deleting the old container.
Setup prints timings for ownership repair, dependency installation/reuse, builds, and browser provisioning. Inspect the environment at any time with:
```bash
node .devcontainer/doctor.cjs
node .devcontainer/dev-test.cjs core test/unit/resolve-invocation.test.ts
node .devcontainer/dev-test.cjs web
GITNEXUS_DEV_TEST_WORKERS=2 node .devcontainer/dev-test.cjs core --watch
```
The diagnostic command reports CPU/memory allowance, cgroup memory events, memory/I/O pressure, disk space/inodes, file-descriptor and watcher limits, filesystem options, and hook status. The development test runner chooses up to four workers, respecting cgroup v2 CPU/memory limits and leaving interactive headroom. Its explicit override accepts 1–99 workers. The ordinary package test commands and CI test configuration remain available. Ownership repair still checks nested entries on each setup; timings can establish whether a more aggressive cache is worthwhile.
## Prebuilt images
`.github/workflows/devcontainer-publish.yml` verifies the tooling and publishes amd64/arm64 core and UI images to GHCR on relevant `main` pushes or manual dispatch on `main`. Pull requests do not publish images. Registry build caches are separate for the two variants. Consumers supply runtime mounts and lifecycle commands; published toolchain metadata excludes those settings to avoid duplicate lifecycle execution.
The rolling tags are `ghcr.io/abhigyanpatwari/gitnexus-devcontainer:main` and `:main-ui`, available **after the first successful publication**. A fork publishes under its own repository-derived name. Each publication also records a unique `sha-COMMIT-run-RUN-ATTEMPT` tag in the workflow summary (with `-ui` for UI images). Pass that full reference or an image digest with `--image` to select a fixed build. Combine `native --image IMAGE` to use a prebuilt image with a native workspace. Local builds remain the default while the publishing workflow is reviewed and merged. New GHCR packages are [private by default](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry); a maintainer must enable public visibility for anonymous pulls, or developers must authenticate to GHCR. The workflow links the package to this repository using its OCI source label.
## 9p workspace tuning
`.devcontainer/tune-9p.sh` can attempt a larger 9p `msize` on each container start. It is **enabled by default**, with `SYS_ADMIN` granted to permit the remount attempt, and applies only when `/workspace` itself is a 9p mount. **Rebuild** an existing container to apply the capability. Check inside the container with:
```bash
findmnt -rn --mountpoint /workspace -o FSTYPE,OPTIONS
```
To disable the remount attempts, set `containerEnv.WORKSPACE_9P_TUNE` to `"0"` in `.devcontainer/devcontainer.json`. To also remove the broad mount privileges, remove `"SYS_ADMIN"` from `capAdd` and **rebuild**. Setting the flag alone does not remove the capability. The default target is 1 MiB (`1048576` bytes); change `containerEnv.WORKSPACE_9P_MSIZE` to choose another size. The existing defaults also accept these two variables from the host environment when creating the container.
The helper uses `sudo -n` with a five-second timeout, skips other filesystems and mounts already at or above the target, and never fails startup. It reads the mount options again after a remount and distinguishes a verified target value from an unchanged or unknown value. To retry manually:
```bash
WORKSPACE_9P_TUNE=1 bash .devcontainer/tune-9p.sh /workspace
```
This is an experiment, with no measured speedup claimed. [`msize` sets the 9p packet payload size](https://www.kernel.org/doc/html/latest/filesystems/9p.html), but [upstream Linux's 9p implementation](https://github.com/torvalds/linux/blob/master/fs/9p/vfs_super.c) has no reconfigure handler to apply a new value. A successful remount command can therefore leave `msize` unchanged; Docker Desktop's kernel and server may also limit it. Increasing privileges alone does not guarantee tuning will work. For Windows development, keeping the checkout on the WSL2 filesystem remains the documented setup above.
## 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).
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. Lifecycle scripts and CLI tools edit the container copies. The default `SYS_ADMIN` capability means the read-only mounts are not a security boundary against privileged container code. Session sub-paths overlay the config volume via their own named volumes (Docker mount precedence — more specific path wins).
| Mount | Source | Target | Mode | Purpose |
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
@ -133,7 +216,7 @@ On the **first** container-create, `post-create.sh` copies each of these out of
**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.
**With `SYS_ADMIN` removed, 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.
@ -214,7 +297,7 @@ The bind mount source directories are guaranteed to exist by the `initializeComm
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
- **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 mounted read-only; default `SYS_ADMIN` can weaken that protection
- 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'
@ -223,9 +306,9 @@ Host and container share a single trust boundary by design — fine for personal
- 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.
**With `SYS_ADMIN` removed**, 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).
**With `SYS_ADMIN` removed, what stays one-way:** 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.
@ -310,12 +393,15 @@ VS Code's Ports panel shows forwarded ports once their listener starts.
## Known gotchas
- **Warm installs reuse the npm cache.** Lifecycle installs prefer cached packages and skip audit requests and funding notices; cache misses still download normally, and native addon/prepare scripts still run. Run `npm audit` in each package when checking vulnerabilities. Volume ownership repair changes only entries whose owner or group differs from `node`.
- **VS Code skips generated indexes, build output, dependencies, and nested worktrees when watching/searching.** Open a sibling worktree as its own window when developing there. These exclusions do not change Vite or Vitest watchers.
- **Memory follows the running workload.** The image leaves `NODE_OPTIONS` unset, so Node uses its defaults and `gitnexus analyze` uses its RAM-aware sizing. For a specific indexing run, use `gitnexus analyze --memory-budget 4096` to set a 4 GiB heap budget. Docker Desktop's VM memory allocation still bounds the container.
- **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/Kotlin are all vendored uniformly: `node-gyp-build` picks a committed GitNexus-built prebuilt `.node` at install time (no compile), and only falls back to compiling from the vendored source during `postinstall` if no prebuild matches the host (then a toolchain is needed). Set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` (in your shell or `remoteEnv`, then rebuild) to skip all four; each loses parsing for the affected language(s), and the install still succeeds.
- **Native grammar builds add ~30s to first install.** Tree-sitter Dart/Proto/Swift/Kotlin/Zig are all vendored uniformly: `node-gyp-build` picks a committed GitNexus-built prebuilt `.node` at install time (no compile), and only falls back to compiling from the vendored source during `postinstall` if no prebuild matches the host (then a toolchain is needed). Set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` (in your shell or `remoteEnv`, then rebuild) to skip these optional grammars; each loses parsing for the affected language(s), and the install still succeeds. Changing this setting invalidates the CLI dependency receipt, including when it is removed to restore parsing.
- **`tree-sitter-kotlin`/`tree-sitter-swift` warnings on install** only appear when no prebuild matches the platform-arch (per `AGENTS.md`); they are non-fatal — parsing for that language is simply unavailable.
- **`.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`.
- **Husky pre-commit is verified during setup.** Root preparation always installs the hook, including cached dependency runs. Only its generated `.husky/_` cache is cleared for stale Windows permissions; the tracked `.husky/pre-commit` stays intact. Setup fails if the hook is missing, Git points elsewhere, or the environment or Husky's `init.sh` disables or skips it. Verification runs the installed Husky helper against a temporary sentinel hook without running the real formatter or changing staged files. Run `npm run prepare` at the repository root to restore the hook, and keep Husky enabled in its startup configuration.
## Rebuild / reset
@ -341,22 +427,26 @@ VS Code's Ports panel shows forwarded ports once their listener starts.
## 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`.
Claude Code, Codex, and Cursor install the latest available release at image build time using their official shell installers. Rebuild without the Docker cache to refresh those image layers. Bun's version is pinned by `BUN_VERSION` in `.devcontainer/devcontainer.json` `build.args`; the Node base image is pinned by digest in the Dockerfile.
Codex uses the [official standalone installer](https://developers.openai.com/codex/cli/) (`curl -fsSL https://chatgpt.com/codex/install.sh | sh`) rather than npm. The build runs it non-interactively and checks `codex --version`. Its native packages normally live under `~/.codex/packages/standalone`, inside the persistent config volume. The image keeps a package seed outside that mount; `post-create.sh` restores it when Codex cannot run, including when rebuilding with a config volume created by the previous npm-based image. A working standalone release already in the volume is kept. This avoids another download during setup and leaves credentials, configuration, and sessions intact.
## 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.
- **Codespaces tuning.** Host configuration mounts and `SYS_ADMIN` support are not actively tested there.
- **Non-Chromium browsers.** The UI profile provisions Chromium. Firefox/WebKit need their own browser binaries and system dependencies.
## Troubleshooting
If `[install-deps]` prints `npm warn EBADENGINE` with Node 22.16.0, run **Dev Containers: Rebuild Container** from the Command Palette after updating this checkout. The image now pins Node 22.23.3; workspace dependencies require at least 22.22.2 on the Node 22 line. Re-running `npm install` in the old container does not update its Node runtime.
| 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 |
| `EPERM: operation not permitted, copyfile ... '.husky/_/h'` in `postCreateCommand` | Leftover `.husky/_/` from a previous container run on a Windows-side bind mount | `install-deps.sh` regenerates `.husky/_` and verifies the active hook. 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 |

View file

@ -0,0 +1,71 @@
'use strict';
// Runs from the image before any workspace lifecycle script exists in the
// fresh volume. Never reset or overwrite a populated checkout.
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
function bootstrap(root, url, ref) {
if (!url || !ref || ref.startsWith('-')) throw new Error('A clone URL and branch are required');
fs.mkdirSync(root, { recursive: true });
const git = (...args) =>
execFileSync('git', args, {
cwd: root,
stdio: ['ignore', 'pipe', 'pipe'],
encoding: 'utf8',
}).trim();
git('check-ref-format', '--branch', ref);
if (!fs.existsSync(path.join(root, '.git'))) git('init');
let origin;
try {
origin = git('remote', 'get-url', 'origin');
} catch {
git('remote', 'add', 'origin', url);
origin = url;
}
if (origin !== url) throw new Error('Workspace volume has another origin; refusing to modify it');
try {
git('rev-parse', '--verify', 'HEAD');
return;
} catch {
/* Resume an interrupted initial fetch. */
}
const upstream = `refs/remotes/origin/${ref}`;
git('fetch', 'origin', `refs/heads/${ref}:${upstream}`);
git('checkout', '--no-overwrite-ignore', '--track', '-b', ref, upstream);
}
module.exports = { bootstrap };
if (require.main === module) {
const root = path.resolve(process.argv[2] || '/workspace');
// Repair UID changes on a reused workspace volume without following symlinks
// or descending into the separately mounted dependency volumes.
const uid = String(process.getuid());
const gid = String(process.getgid());
execFileSync(
'sudo',
[
'-n',
'find',
root,
'-xdev',
'(',
'!',
'-uid',
uid,
'-o',
'!',
'-gid',
gid,
')',
'-exec',
'chown',
'-h',
`${uid}:${gid}`,
'{}',
'+',
],
{ stdio: 'inherit' },
);
bootstrap(root, process.env.GITNEXUS_CLONE_URL, process.env.GITNEXUS_CLONE_REF);
}

View file

@ -0,0 +1,139 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { execFileSync } = require('node:child_process');
const markerName = '.gitnexus-devcontainer-install.json';
const nativeInputs = [
'vendor',
'prebuilds/bundled',
'scripts/build-tree-sitter-grammars.cjs',
'scripts/prepare-tree-sitter-bundle.cjs',
'scripts/arm64-prebuild.cjs',
];
function fingerprint(directory, runtime = process) {
const hash = crypto.createHash('sha256');
hash.update(
JSON.stringify([
3,
runtime.version,
runtime.versions.modules,
runtime.platform,
runtime.arch,
runtime.npmVersion,
]),
);
for (const file of ['package.json', 'package-lock.json']) {
hash.update(file);
hash.update(fs.readFileSync(path.join(directory, file)));
}
const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json')));
for (const dependency of Object.values({
...manifest.dependencies,
...manifest.devDependencies,
...manifest.optionalDependencies,
})) {
if (typeof dependency !== 'string' || !dependency.startsWith('file:')) continue;
const sibling = path.resolve(directory, dependency.slice(5), 'package.json');
hash.update(fs.readFileSync(sibling));
}
if (fs.existsSync(path.join(directory, 'scripts/build-tree-sitter-grammars.cjs'))) {
hash.update(
JSON.stringify({ skipOptionalGrammars: process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1' }),
);
// Blob IDs cover committed native inputs; the diff covers local tracked
// edits. Do not read npm credential files or hash large unchanged prebuilds.
for (const args of [
['ls-files', '-s', '--', ...nativeInputs],
['diff', 'HEAD', '--', ...nativeInputs],
]) {
hash.update(execFileSync('git', args, { cwd: directory, maxBuffer: 32 * 1024 * 1024 }));
}
}
return hash.digest('hex');
}
function ensureDependencies(directory, { run = runNpm, force = false, runtime = process } = {}) {
const expected = fingerprint(directory, runtime);
const marker = path.join(directory, 'node_modules', markerName);
const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json')));
const packages = Object.keys({ ...manifest.dependencies, ...manifest.devDependencies });
let saved;
try {
saved = JSON.parse(fs.readFileSync(marker, 'utf8'));
} catch {
/* First or interrupted install. */
}
const present = packages.every((name) =>
fs.existsSync(path.join(directory, 'node_modules', name, 'package.json')),
);
if (!force && present && saved?.fingerprint === expected) {
console.log(
`[install-deps] ${path.basename(directory)} dependencies unchanged; reusing volume`,
);
return false;
}
// Invalidate before installing: a failed attempt must never leave a reusable receipt.
fs.rmSync(marker, { force: true });
run(directory, ['ci']);
fs.mkdirSync(path.dirname(marker), { recursive: true });
const temporary = `${marker}.tmp`;
fs.writeFileSync(temporary, JSON.stringify({ fingerprint: fingerprint(directory, runtime) }));
fs.renameSync(temporary, marker);
return true;
}
function runNpm(directory, args) {
execFileSync('npm', args, { cwd: directory, stdio: 'inherit' });
}
function timed(label, action) {
const start = performance.now();
try {
return action();
} finally {
console.log(`[install-deps] ${label}: ${((performance.now() - start) / 1000).toFixed(2)}s`);
}
}
module.exports = { fingerprint, ensureDependencies, markerName };
if (require.main === module) {
const root = path.resolve(process.argv[2] || '/workspace');
const runtime = {
version: process.version,
versions: process.versions,
platform: process.platform,
arch: process.arch,
npmVersion: execFileSync('npm', ['--version'], { encoding: 'utf8' }).trim(),
};
const ensure = (directory) =>
timed(`dependencies ${path.basename(directory)}`, () =>
ensureDependencies(directory, { force: process.env.GITNEXUS_FORCE_DEPS === '1', runtime }),
);
ensure(root);
// Always restore and verify hooks, including the cached-install path.
runNpm(root, ['run', 'prepare']);
require('./verify-git-hooks.cjs').verifyGitHooks(root);
console.log('[install-deps] Husky hooks verified');
const shared = path.join(root, 'gitnexus-shared');
ensure(shared);
timed('shared build', () => runNpm(shared, ['run', 'build']));
const web = path.join(root, 'gitnexus-web');
ensure(web);
const core = path.join(root, 'gitnexus');
// A fresh npm ci already builds via prepare. A reused install still needs
// source compilation, even when manifests and lockfiles have not changed.
if (!ensure(core)) timed('CLI source build', () => runNpm(core, ['run', 'build']));
if (process.env.GITNEXUS_DEV_UI === '1') {
timed('Chromium provisioning', () =>
execFileSync(path.join(web, 'node_modules/.bin/playwright'), ['install', 'chromium'], {
cwd: web,
stdio: 'inherit',
}),
);
}
}

View file

@ -0,0 +1,252 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync, spawnSync } = require('node:child_process');
const { ensureDependencies, markerName } = require('./dependencies.cjs');
function fixture(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-dependencies-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
fs.writeFileSync(
path.join(root, 'package.json'),
JSON.stringify({ dependencies: { example: '1.0.0' } }),
);
fs.writeFileSync(path.join(root, 'package-lock.json'), '{}');
let installs = 0;
const run = () => {
installs++;
fs.mkdirSync(path.join(root, 'node_modules/example'), { recursive: true });
fs.writeFileSync(path.join(root, 'node_modules/example/package.json'), '{}');
};
return { root, run, count: () => installs };
}
test('reuses completed dependency volumes and reinstalls after lockfile changes', (t) => {
const f = fixture(t);
assert.equal(ensureDependencies(f.root, { run: f.run }), true);
assert.equal(ensureDependencies(f.root, { run: f.run }), false);
fs.writeFileSync(path.join(f.root, 'package-lock.json'), '{"lockfileVersion":3}');
assert.equal(ensureDependencies(f.root, { run: f.run }), true);
assert.equal(f.count(), 2);
});
test('repairs a missing direct dependency even if the receipt matches', (t) => {
const f = fixture(t);
ensureDependencies(f.root, { run: f.run });
fs.rmSync(path.join(f.root, 'node_modules/example'), { recursive: true });
assert.equal(ensureDependencies(f.root, { run: f.run }), true);
});
test('runtime ABI changes and explicit repair invalidate reuse', (t) => {
const f = fixture(t);
ensureDependencies(f.root, { run: f.run });
const runtime = {
version: 'v22.23.3',
versions: { modules: 'different' },
platform: 'linux',
arch: 'arm64',
};
assert.equal(ensureDependencies(f.root, { run: f.run, runtime }), true);
assert.equal(ensureDependencies(f.root, { run: f.run, runtime, force: true }), true);
});
for (const group of ['dependencies', 'devDependencies', 'optionalDependencies']) {
test(`local file-dependency manifest changes in ${group} invalidate reuse`, (t) => {
const f = fixture(t);
const local = path.join(f.root, 'local');
fs.mkdirSync(local);
fs.writeFileSync(path.join(local, 'package.json'), '{"version":"1.0.0"}');
const manifest = { dependencies: { example: '1.0.0' } };
manifest[group] ??= {};
manifest[group].local = 'file:./local';
fs.writeFileSync(path.join(f.root, 'package.json'), JSON.stringify(manifest));
const run = () => {
f.run();
fs.mkdirSync(path.join(f.root, 'node_modules/local'), { recursive: true });
fs.copyFileSync(
path.join(local, 'package.json'),
path.join(f.root, 'node_modules/local/package.json'),
);
};
ensureDependencies(f.root, { run });
assert.equal(ensureDependencies(f.root, { run }), false);
fs.writeFileSync(path.join(local, 'package.json'), '{"version":"1.0.1"}');
assert.equal(ensureDependencies(f.root, { run }), true);
assert.equal(ensureDependencies(f.root, { run }), false);
assert.equal(f.count(), 2);
});
}
test('tracked native source and installer edits invalidate reuse', (t) => {
const f = fixture(t);
fs.mkdirSync(path.join(f.root, 'scripts'));
fs.mkdirSync(path.join(f.root, 'vendor'));
fs.mkdirSync(path.join(f.root, 'prebuilds/bundled'), { recursive: true });
fs.writeFileSync(path.join(f.root, 'scripts/build-tree-sitter-grammars.cjs'), '// original');
fs.writeFileSync(path.join(f.root, 'scripts/arm64-prebuild.cjs'), '// original validator');
fs.writeFileSync(path.join(f.root, 'prebuilds/bundled/manifest.json'), '{"schemaVersion":1}');
fs.writeFileSync(path.join(f.root, 'prebuilds/bundled/grammar.node'), Buffer.from([1, 2, 3]));
fs.writeFileSync(path.join(f.root, 'vendor/grammar.c'), '// original');
const git = (...args) =>
execFileSync(
'git',
[
'-c',
'core.hooksPath=/dev/null',
'-c',
'commit.gpgsign=false',
'-c',
'user.name=Fixture',
'-c',
'user.email=fixture@example.invalid',
...args,
],
{ cwd: f.root, stdio: 'pipe' },
);
git('init');
git(
'add',
'package.json',
'package-lock.json',
'scripts/build-tree-sitter-grammars.cjs',
'scripts/arm64-prebuild.cjs',
'prebuilds/bundled',
'vendor/grammar.c',
);
git('commit', '-m', 'fixture');
ensureDependencies(f.root, { run: f.run });
assert.equal(ensureDependencies(f.root, { run: f.run }), false);
fs.writeFileSync(path.join(f.root, 'vendor/grammar.c'), '// changed grammar');
assert.equal(ensureDependencies(f.root, { run: f.run }), true);
fs.writeFileSync(
path.join(f.root, 'scripts/build-tree-sitter-grammars.cjs'),
'// changed installer',
);
assert.equal(ensureDependencies(f.root, { run: f.run }), true);
for (const [file, content] of [
['scripts/arm64-prebuild.cjs', '// changed validator'],
['prebuilds/bundled/manifest.json', '{"schemaVersion":2}'],
['prebuilds/bundled/grammar.node', Buffer.from([4, 5, 6])],
]) {
fs.writeFileSync(path.join(f.root, file), content);
assert.equal(ensureDependencies(f.root, { run: f.run }), true, file);
assert.equal(ensureDependencies(f.root, { run: f.run }), false, file);
}
});
test('failed reinstall removes its receipt and is retried', (t) => {
const f = fixture(t);
ensureDependencies(f.root, { run: f.run });
assert.throws(
() =>
ensureDependencies(f.root, {
force: true,
run: () => {
throw new Error('install failed');
},
}),
/install failed/,
);
assert.equal(fs.existsSync(path.join(f.root, 'node_modules', markerName)), false);
assert.equal(ensureDependencies(f.root, { run: f.run }), true);
});
test('changing optional-grammar policy invalidates the core install receipt', (t) => {
const f = fixture(t);
const previous = process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS;
t.after(() => {
if (previous === undefined) delete process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS;
else process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS = previous;
});
fs.mkdirSync(path.join(f.root, 'scripts'));
fs.writeFileSync(path.join(f.root, 'scripts/build-tree-sitter-grammars.cjs'), '// fixture');
execFileSync('git', ['init', '-q'], { cwd: f.root });
execFileSync(
'git',
[
'-c',
'core.hooksPath=/dev/null',
'-c',
'commit.gpgsign=false',
'-c',
'user.name=Fixture',
'-c',
'user.email=fixture@example.invalid',
'commit',
'--allow-empty',
'-m',
'fixture',
],
{ cwd: f.root, stdio: 'pipe' },
);
process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS = '1';
assert.equal(ensureDependencies(f.root, { run: f.run }), true);
assert.equal(ensureDependencies(f.root, { run: f.run }), false);
delete process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS;
assert.equal(ensureDependencies(f.root, { run: f.run }), true);
process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS = '0';
assert.equal(ensureDependencies(f.root, { run: f.run }), false);
process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS = '1';
assert.equal(ensureDependencies(f.root, { run: f.run }), true);
assert.equal(f.count(), 3);
});
test('cached lifecycle restores real Husky and rebuilds changed sources', (t) => {
const f = fixture(t);
fs.writeFileSync(path.join(f.root, 'package.json'), '{}');
for (const name of ['gitnexus', 'gitnexus-web', 'gitnexus-shared']) {
fs.mkdirSync(path.join(f.root, name));
fs.writeFileSync(path.join(f.root, name, 'package.json'), '{}');
fs.writeFileSync(path.join(f.root, name, 'package-lock.json'), '{}');
}
fs.mkdirSync(path.join(f.root, '.husky'));
fs.writeFileSync(path.join(f.root, '.husky/pre-commit'), 'exit 0\n');
const global = path.join(f.root, 'gitconfig');
fs.writeFileSync(global, '');
const env = {
...process.env,
PATH: `${f.root}:${process.env.PATH}`,
HUSKY: '',
GIT_CONFIG_GLOBAL: global,
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_COUNT: '0',
GITNEXUS_FORCE_DEPS: '0',
GITNEXUS_DEV_UI: '0',
};
for (const key of ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE'])
delete env[key];
execFileSync('git', ['init', '-q'], { cwd: f.root, env });
const husky = path.resolve(__dirname, '../node_modules/husky/bin.js');
fs.writeFileSync(
path.join(f.root, 'npm'),
`#!${process.execPath}
const fs = require('node:fs');
const path = require('node:path');
const args = process.argv.slice(2);
if (args[0] === '--version') { console.log('10.9.2'); process.exit(0); }
fs.appendFileSync(${JSON.stringify(path.join(f.root, 'calls'))}, path.basename(process.cwd()) + ':' + args.join(' ') + '\\n');
if (args[0] === 'ci') fs.mkdirSync('node_modules', { recursive: true });
if (args.join(' ') === 'run prepare') require('node:child_process').execFileSync(process.execPath, [${JSON.stringify(husky)}], { stdio: 'inherit' });
`,
{ mode: 0o755 },
);
const invoke = () => {
const result = spawnSync(process.execPath, [path.join(__dirname, 'dependencies.cjs'), f.root], {
env,
encoding: 'utf8',
});
assert.equal(result.status, 0, result.stderr);
};
invoke();
fs.rmSync(path.join(f.root, '.husky/_'), { recursive: true });
fs.writeFileSync(path.join(f.root, 'calls'), '');
invoke();
const calls = fs.readFileSync(path.join(f.root, 'calls'), 'utf8');
assert.doesNotMatch(calls, /:ci/);
assert.match(calls, /:run prepare/);
assert.match(calls, /gitnexus:run build/);
assert.ok(fs.existsSync(path.join(f.root, '.husky/_/pre-commit')));
});

View file

@ -0,0 +1,23 @@
'use strict';
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { workerBudget } = require('./resources.cjs');
const [target, ...args] = process.argv.slice(2);
if (!['core', 'web'].includes(target))
throw new Error('Usage: node .devcontainer/dev-test.cjs core|web [Vitest arguments]');
const root = path.resolve(__dirname, '..', target === 'core' ? 'gitnexus' : 'gitnexus-web');
const workers = workerBudget();
console.log(`[dev-test] ${target}: ${workers} workers (override with GITNEXUS_DEV_TEST_WORKERS)`);
const result = spawnSync(
process.execPath,
[
path.join(root, 'node_modules/vitest/vitest.mjs'),
...(args.includes('--watch') ? [] : ['run']),
...args,
`--maxWorkers=${workers}`,
],
{ cwd: root, stdio: 'inherit' },
);
if (result.error) console.error(result.error.message);
process.exitCode = result.status ?? 1;

View file

@ -43,6 +43,11 @@
"remoteUser": "node",
"updateRemoteUserUID": true,
// Reap orphaned subprocesses from CLI workers and test runs.
"init": true,
// Allow the startup helper to attempt a 9p remount. See README for the
// effect on read-only mount protections and how to disable it.
"capAdd": ["SYS_ADMIN"],
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
"workspaceFolder": "/workspace",
@ -54,13 +59,11 @@
// 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
// Everything copied lands in the per-container named volume (role 2).
// The host stage is read-only; lifecycle scripts write to container copies.
// SYS_ADMIN is enabled for 9p tuning, so read-only flags are not a security
// boundary against privileged container code. Host and container DIVERGE
// after first create — host edits don't reach it 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
@ -202,19 +205,19 @@
// "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
// Lifecycle scripts do not write back to host CLI state. The default
// SYS_ADMIN capability can weaken read-only mount protections. 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.
// read-write into the container by this configuration.
"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
// the seed script does not 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",
@ -229,8 +232,8 @@
// 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.
// - CLI edits use the container's OWN writable copy. SYS_ADMIN can bypass
// read-only protections; see README for the trust boundary.
// - 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.)
@ -277,6 +280,7 @@
"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=gitnexus-store-${devcontainerId},target=/home/node/.gitnexus,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",
@ -314,7 +318,10 @@
// dependency explicit instead of silently following the default.
"containerEnv": {
"CODEX_HOME": "/home/node/.codex",
"HISTFILE": "/commandhistory/.zsh_history"
"HISTFILE": "/commandhistory/.zsh_history",
"GITNEXUS_HOME": "/home/node/.gitnexus",
"WORKSPACE_9P_TUNE": "${localEnv:WORKSPACE_9P_TUNE:1}",
"WORKSPACE_9P_MSIZE": "${localEnv:WORKSPACE_9P_MSIZE:1048576}"
},
"customizations": {
@ -332,6 +339,26 @@
"source.fixAll.eslint": "explicit"
},
"files.eol": "\n",
// Generated indexes and sibling worktrees can dwarf the source tree.
// These exclusions affect VS Code only; Vite and test watchers keep
// their own configuration.
"files.watcherExclude": {
"**/node_modules/**": true,
"**/.gitnexus/**": true,
"**/.worktrees/**": true,
"**/.claude/worktrees/**": true,
"**/dist/**": true,
"**/coverage/**": true
},
"search.exclude": {
"**/node_modules": true,
"**/.gitnexus": true,
"**/.worktrees": true,
"**/.claude/worktrees": true,
"**/dist": true,
"**/coverage": true
},
"git.autoRepositoryDetection": "openEditors",
"terminal.integrated.defaultProfile.linux": "zsh",
"terminal.integrated.profiles.linux": {
"bash": { "path": "bash", "icon": "terminal-bash" },
@ -361,15 +388,18 @@
},
// 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.
// - `updateContentCommand` prepares dependencies after workspace content is
// available during creation/prebuild refresh. It reuses successful installs
// when inputs match. In a running container, explicitly run install-deps.sh
// after pulling dependency changes; this hook is not a lockfile watcher.
// - `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
// - `postStartCommand` attempts 9p tuning on every start unless disabled; failure
// never blocks startup.
// Run lifecycle scripts 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"
"postCreateCommand": "bash .devcontainer/post-create.sh",
"postStartCommand": "bash .devcontainer/tune-9p.sh || true"
}

39
.devcontainer/doctor.cjs Normal file
View file

@ -0,0 +1,39 @@
'use strict';
const path = require('node:path');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const { read, workerBudget } = require('./resources.cjs');
const root = path.resolve(process.argv[2] || '/workspace');
console.log(
`[doctor] Node ${process.version}; ${os.availableParallelism()} available CPUs; suggested dev test workers ${workerBudget()}`,
);
for (const file of [
'/sys/fs/cgroup/memory.max',
'/sys/fs/cgroup/memory.current',
'/sys/fs/cgroup/memory.events',
'/sys/fs/cgroup/cpu.max',
'/proc/pressure/memory',
'/proc/pressure/io',
'/proc/sys/fs/inotify/max_user_watches',
'/proc/sys/fs/inotify/max_user_instances',
])
console.log(`[doctor] ${file}: ${read(file)}`);
for (const [command, args] of [
['findmnt', ['-rn', '-T', root, '-o', 'FSTYPE,OPTIONS']],
['df', ['-h', root]],
['df', ['-i', root]],
['bash', ['-c', 'ulimit -n']],
]) {
try {
console.log(execFileSync(command, args, { encoding: 'utf8', timeout: 5000 }).trim());
} catch {
console.log(`[doctor] ${command}: unavailable`);
}
}
try {
require('./verify-git-hooks.cjs').verifyGitHooks(root);
console.log('[doctor] Husky hooks verified');
} catch (error) {
console.log(`[doctor] Husky: ${error.message}`);
}

View file

@ -1,25 +1,26 @@
#!/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
# Devcontainer updateContentCommand. Runs during container creation after
# workspace content is available. Run this script explicitly after pulling
# dependency changes into an already-running container. 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
# Why the split: updateContentCommand handles dependency readiness, while
# postCreateCommand handles AI CLI state. Installation receipts reuse unchanged
# dependency volumes and refresh them when inputs change. 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"
ownership_start=$SECONDS
# 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
# We use `find -xdev` with `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
@ -28,41 +29,41 @@ echo "[install-deps] 1/4: chown workspace node_modules + npm cache mount points"
# 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.
# same. Only change entries with the wrong owner or group: carried-over volumes
# can contain hundreds of thousands of files that already have correct ownership.
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 {} +
/home/node/.npm \
/home/node/.gitnexus \
/home/node/.cache/ms-playwright; do
[ -d "$d" ] || continue
sudo find "$d" -xdev \( ! -user node -o ! -group node \) -exec chown -h node:node {} +
done
echo "[install-deps] ownership repair: $((SECONDS - ownership_start))s"
# Reuse the persistent npm cache without waiting for metadata freshness checks.
# Cache misses still use the registry. Keep install lifecycle scripts enabled for
# native addons and prepare builds; audits remain available via `npm audit`.
export npm_config_prefer_offline=true
export npm_config_audit=false
export npm_config_fund=false
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`.
# The tracked .husky/pre-commit is preserved. Verification below fails setup if
# regeneration was skipped or failed, so this cleanup cannot silently disable it.
# 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] 3/4: restore dependencies, verify Husky, and build sources"
node .devcontainer/dependencies.cjs /workspace
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] 4/4: environment summary"
node .devcontainer/doctor.cjs /workspace
echo "[install-deps] done"

View file

@ -3,8 +3,8 @@
# 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.
# defines. This script prepares AI CLI binaries and syncs configuration from
# the host into the container's own volumes.
set -euo pipefail
@ -67,9 +67,15 @@ for d in "${DIRS[@]}"; do
# 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 {} +
# Avoid chown syscalls for the already-correct entries on reused volumes.
sudo find "$d" -xdev \( ! -user node -o ! -group node \) -exec chown -h node:node {} +
done
# The standalone installer stores Codex under ~/.codex, which a reused config
# volume hides. Restore the image's native package without downloading it again
# or overwriting a working standalone installation already in that volume.
bash "$SCRIPT_DIR/seed-codex.sh"
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

163
.devcontainer/profile.cjs Normal file
View file

@ -0,0 +1,163 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
function replaceOnce(text, pattern, replacement) {
const matches = text.match(new RegExp(pattern.source, 'g'));
if (matches?.length !== 1) throw new Error(`Canonical devcontainer layout changed: ${pattern}`);
return text.replace(pattern, () => replacement);
}
function profile(
source,
{
native = false,
ui = false,
image,
imageOnly = false,
ref = 'main',
playwright,
url = 'https://github.com/abhigyanpatwari/GitNexus.git',
} = {},
) {
if (imageOnly && image) throw new Error('--image cannot be used with image-build');
if (
typeof ref !== 'string' ||
!/^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/.test(ref) ||
ref === 'HEAD' ||
ref.includes('..') ||
ref.endsWith('.') ||
ref.split('/').some((part) => !part || part.startsWith('.') || part.endsWith('.lock'))
)
throw new Error('Invalid clone branch');
// Character safety check only; Docker validates the image reference grammar.
if (image && !/^[a-zA-Z0-9][a-zA-Z0-9./:_@-]*$/.test(image))
throw new Error('Invalid image reference');
if (
native &&
!/^(?:https:\/\/[a-zA-Z0-9.-]+(?::[0-9]+)?\/|ssh:\/\/git@[a-zA-Z0-9.-]+(?::[0-9]+)?\/|git@[a-zA-Z0-9.-]+:)[a-zA-Z0-9._/-]+$/.test(
url,
)
)
throw new Error('Invalid clone URL; use HTTPS or Git SSH without embedded credentials');
let result = source;
if (image) {
const start = result.indexOf(' "build": {');
const end = result.indexOf('\n // Runs on the HOST', start);
if (start < 0 || end < 0) throw new Error('Canonical build block changed');
result = result.slice(0, start) + ` "image": ${JSON.stringify(image)},\n` + result.slice(end);
} else {
result = replaceOnce(result, /"dockerfile": "Dockerfile"/, '"dockerfile": "../Dockerfile"');
result = replaceOnce(result, /"context": "\."/, '"context": ".."');
if (ui) {
if (!/^[0-9]+\.[0-9]+\.[0-9]+$/.test(playwright))
throw new Error('Locked Playwright version required');
result = replaceOnce(
result,
/"BUN_VERSION":/,
`"INSTALL_BROWSER_DEPS": "true",\n "PLAYWRIGHT_VERSION": ${JSON.stringify(playwright)},\n "BUN_VERSION":`,
);
}
}
if (native) {
result = replaceOnce(
result,
/"workspaceMount": "[^"\n]+"/,
'"workspaceMount": "source=gitnexus-workspace-${devcontainerId},target=/workspace,type=volume"',
);
result = replaceOnce(
result,
/"CODEX_HOME":/,
`"GITNEXUS_CLONE_URL": ${JSON.stringify(url)},\n ` +
`"GITNEXUS_CLONE_REF": ${JSON.stringify(ref)},\n "CODEX_HOME":`,
);
result = replaceOnce(
result,
/"updateContentCommand":/,
'"onCreateCommand": "node /usr/local/share/gitnexus-devcontainer/bootstrap-native.cjs",\n "updateContentCommand":',
);
}
if (imageOnly) {
const start = result.indexOf(' "build": {');
const end = result.indexOf('\n // Runs on the HOST', start);
const features = result.match(/"features": \{[\s\S]*?\n \},/);
if (start < 0 || end < 0 || !features)
throw new Error('Image build requires the canonical build and feature blocks');
// Publish toolchain metadata only. Lifecycle hooks and host mounts belong
// to the consuming profile, avoiding duplicate hook execution on attach.
return '{\n' + result.slice(start, end) + '\n ' + features[0].slice(0, -1) + '\n}\n';
}
if (ui) {
result = replaceOnce(
result,
/"CODEX_HOME":/,
'"GITNEXUS_DEV_UI": "1",\n "PLAYWRIGHT_BROWSERS_PATH": "/home/node/.cache/ms-playwright",\n "CODEX_HOME":',
);
result = replaceOnce(
result,
/"source=npm-cache-/,
'"source=playwright-cache-${devcontainerId},target=/home/node/.cache/ms-playwright,type=volume",\n "source=npm-cache-',
);
result = replaceOnce(
result,
/"workspaceMount":/,
'"runArgs": ["--shm-size=1g"],\n "workspaceMount":',
);
}
return result;
}
module.exports = { profile };
if (require.main === module) {
const [mode, ...args] = process.argv.slice(2);
if (!['native', 'ui', 'prebuilt', 'image-build'].includes(mode))
throw new Error(
'Usage: node .devcontainer/profile.cjs native [--ui] [--image IMAGE] [--ref BRANCH] [--url URL]\n' +
' node .devcontainer/profile.cjs ui [--image IMAGE]\n' +
' node .devcontainer/profile.cjs prebuilt [--ui] [--image IMAGE]\n' +
' node .devcontainer/profile.cjs image-build [--ui]',
);
const options = new Map();
for (let i = 0; i < args.length; i++) {
const flag = args[i];
if (!['--ui', '--image', '--ref', '--url'].includes(flag))
throw new Error(`Unknown profile argument: ${flag}`);
if (options.has(flag)) throw new Error(`Duplicate profile argument: ${flag}`);
if (flag === '--ui') options.set(flag, true);
else {
const value = args[++i];
if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`);
options.set(flag, value);
}
}
if (mode === 'image-build' && options.has('--image'))
throw new Error('--image cannot be used with image-build');
if (mode !== 'native' && (options.has('--ref') || options.has('--url')))
throw new Error('--ref and --url require the native profile');
const lock = JSON.parse(
fs.readFileSync(path.join(__dirname, '../gitnexus-web/package-lock.json')),
);
const output = path.join(__dirname, 'generated');
const contents = profile(fs.readFileSync(path.join(__dirname, 'devcontainer.json'), 'utf8'), {
native: mode === 'native',
ui: mode === 'ui' || options.has('--ui'),
image:
options.get('--image') ||
(mode === 'prebuilt'
? `ghcr.io/abhigyanpatwari/gitnexus-devcontainer:main${options.has('--ui') ? '-ui' : ''}`
: undefined),
imageOnly: mode === 'image-build',
ref: options.get('--ref'),
url: options.get('--url'),
playwright: lock.packages['node_modules/@playwright/test'].version,
});
fs.mkdirSync(output, { recursive: true });
fs.writeFileSync(path.join(output, 'devcontainer.json'), contents);
fs.copyFileSync(
path.join(__dirname, 'devcontainer-lock.json'),
path.join(output, 'devcontainer-lock.json'),
);
console.log(
'Generated .devcontainer/generated/devcontainer.json; select it when reopening in container. Regenerate after updating the main configuration.',
);
}

View file

@ -0,0 +1,286 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync, spawnSync } = require('node:child_process');
const ts = require('typescript');
const { profile } = require('./profile.cjs');
const { bootstrap } = require('./bootstrap-native.cjs');
const source = fs.readFileSync(path.join(__dirname, 'devcontainer.json'), 'utf8');
const parse = (text) => {
const result = ts.parseConfigFileTextToJson('devcontainer.json', text);
assert.equal(result.error, undefined);
return result.config;
};
test('native/UI profile inherits hooks, storage and defaults while changing workspace and browser setup', () => {
const c = parse(
profile(source, { native: true, ui: true, playwright: '1.63.0', ref: 'feature/test' }),
);
assert.match(c.workspaceMount, /type=volume/);
assert.match(c.onCreateCommand, /bootstrap-native/);
assert.equal(c.containerEnv.GITNEXUS_CLONE_REF, 'feature/test');
assert.equal(c.build.args.PLAYWRIGHT_VERSION, '1.63.0');
assert.equal(c.containerEnv.GITNEXUS_DEV_UI, '1');
assert.ok(c.mounts.some((m) => m.includes('ms-playwright')));
assert.ok(c.mounts.some((m) => m.includes('/home/node/.gitnexus')));
assert.deepEqual(c.capAdd, ['SYS_ADMIN']);
assert.equal(c.updateContentCommand, 'bash .devcontainer/install-deps.sh');
});
test('image consumers retain configured features and runtime lifecycle', () => {
for (const options of [{}, { native: true }, { ui: true }, { native: true, ui: true }]) {
const image = 'ghcr.io/example/devcontainer@sha256:abcd';
const c = parse(profile(source, { ...options, image }));
assert.equal(c.build, undefined);
assert.equal(c.image, image);
assert.deepEqual(c.features, parse(source).features);
assert.ok(c.features['ghcr.io/devcontainers/features/github-cli:1']);
assert.equal(c.postCreateCommand, 'bash .devcontainer/post-create.sh');
}
});
test('image selection accepts short names, uppercase tags, ports and digests', () => {
for (const image of [
'a',
'a:RC',
'ghcr.io/example/image:ReleaseCandidate',
'localhost:5000/example/image:ReleaseCandidate',
`ghcr.io/example/image@sha256:${'a'.repeat(64)}`,
`ghcr.io/example/image:RC@sha256:${'a'.repeat(64)}`,
]) {
assert.equal(parse(profile(source, { image })).image, image);
}
for (const image of [
'--privileged',
'image;bad',
'image name',
'image\nname',
'$(id)',
'"image"',
])
assert.throws(() => profile(source, { image }), /Invalid image reference/);
});
test('published toolchain metadata excludes host mounts and lifecycle hooks', () => {
const c = parse(profile(source, { imageOnly: true, ui: true, playwright: '1.63.0' }));
assert.equal(c.mounts, undefined);
assert.equal(c.updateContentCommand, undefined);
assert.equal(c.containerEnv, undefined);
assert.equal(c.build.args.INSTALL_BROWSER_DEPS, 'true');
assert.ok(c.features['ghcr.io/devcontainers/features/github-cli:1']);
});
test('profile generator rejects unsafe values and changed canonical layout', () => {
assert.throws(() => profile(source, { ref: '--upload-pack=bad' }), /Invalid/);
assert.throws(() => profile(source, { image: 'image;bad' }), /Invalid/);
assert.throws(
() => profile(source, { imageOnly: true, image: 'ghcr.io/example/image' }),
/--image cannot/,
);
for (const url of ['--upload-pack=bad', 'ext::command', 'https://token@github.com/user/repo.git'])
assert.throws(() => profile(source, { native: true, url }), /Invalid clone URL/);
assert.throws(
() => profile(source.replace('"context": "."', '"context": "elsewhere"')),
/layout changed/,
);
});
test('native branch validation rejects invalid Git refs before generation', () => {
for (const ref of [
'foo..',
'topic/.hidden',
'topic/name.lock',
'topic/name.lock/child',
'topic//child',
'topic/name.',
'topic/',
'HEAD',
]) {
assert.notEqual(spawnSync('git', ['check-ref-format', '--branch', ref]).status, 0);
assert.throws(() => profile(source, { native: true, ref }), /Invalid clone branch/);
}
for (const ref of [
'main',
'feature/fix-123',
'release/v1.2.3',
'topic/name.locked',
'topic/name.LOCK',
]) {
assert.equal(spawnSync('git', ['check-ref-format', '--branch', ref]).status, 0);
assert.equal(
parse(profile(source, { native: true, ref })).containerEnv.GITNEXUS_CLONE_REF,
ref,
);
}
});
test('UI variants preserve empty, custom, and absent capability selections', () => {
for (const capabilities of [[], ['SYS_PTRACE'], undefined]) {
const customized = source.replace(
' "capAdd": ["SYS_ADMIN"],',
capabilities === undefined ? '' : ` "capAdd": ${JSON.stringify(capabilities)},`,
);
for (const options of [{}, { native: true }, { image: 'ghcr.io/example/devcontainer:ui' }]) {
const c = parse(profile(customized, { ...options, ui: true, playwright: '1.63.0' }));
assert.deepEqual(c.capAdd, capabilities);
assert.deepEqual(c.runArgs, ['--shm-size=1g']);
}
}
});
test('native profiles accept fork remotes over HTTPS and Git SSH', () => {
for (const url of [
'https://github.com/contributor/GitNexus.git',
'git@github.com:contributor/GitNexus.git',
'ssh://git@example.com:2222/contributor/GitNexus.git',
]) {
const c = parse(profile(source, { native: true, url, ref: 'feature/fork-only' }));
assert.equal(c.containerEnv.GITNEXUS_CLONE_URL, url);
assert.equal(c.containerEnv.GITNEXUS_CLONE_REF, 'feature/fork-only');
}
});
test('profile CLI validates combinations before writing and generates fork configuration', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-profile-cli-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const config = path.join(root, '.devcontainer');
fs.mkdirSync(config);
for (const file of ['profile.cjs', 'devcontainer.json', 'devcontainer-lock.json'])
fs.copyFileSync(path.join(__dirname, file), path.join(config, file));
fs.mkdirSync(path.join(root, 'gitnexus-web'));
fs.writeFileSync(
path.join(root, 'gitnexus-web/package-lock.json'),
JSON.stringify({
packages: { 'node_modules/@playwright/test': { version: '1.63.0' } },
}),
);
const run = (...args) =>
spawnSync(process.execPath, [path.join(config, 'profile.cjs'), ...args], {
cwd: root,
encoding: 'utf8',
});
for (const [args, error] of [
[
['image-build', '--image', 'ghcr.io/example/image'],
/--image cannot be used with image-build/,
],
[['native', '--url'], /--url requires a value/],
[['native', '--ref', '--ui'], /--ref requires a value/],
[['prebuilt', '--url', 'https://github.com/user/repo.git'], /require the native profile/],
[['native', '--ref', 'main', '--ref', 'other'], /Duplicate profile argument/],
[['native', '--ref', 'foo..'], /Invalid clone branch/],
]) {
const result = run(...args);
assert.notEqual(result.status, 0);
assert.match(result.stderr, error);
assert.equal(fs.existsSync(path.join(config, 'generated')), false);
}
const result = run(
'native',
'--url',
'https://github.com/contributor/GitNexus.git',
'--ref',
'feature/fork-only',
);
assert.equal(result.status, 0, result.stderr);
const c = parse(fs.readFileSync(path.join(config, 'generated/devcontainer.json'), 'utf8'));
assert.equal(c.containerEnv.GITNEXUS_CLONE_URL, 'https://github.com/contributor/GitNexus.git');
assert.equal(c.containerEnv.GITNEXUS_CLONE_REF, 'feature/fork-only');
for (const args of [
['prebuilt'],
['prebuilt', '--ui'],
['native', '--image', 'a'],
['ui', '--image', 'ghcr.io/example/image:ReleaseCandidate'],
]) {
const result = run(...args);
assert.equal(result.status, 0, result.stderr);
const generated = parse(
fs.readFileSync(path.join(config, 'generated/devcontainer.json'), 'utf8'),
);
assert.deepEqual(generated.features, parse(source).features);
if (args.includes('--image')) assert.equal(generated.image, args.at(-1));
}
});
test('native workspace preserves edits, rejects a different origin and resumes interrupted fetches', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-native-profile-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const origin = path.join(root, 'origin');
fs.mkdirSync(origin);
const git = (...args) => execFileSync('git', args, { cwd: origin, stdio: 'pipe' });
git('init', '-b', 'main');
git('config', 'user.name', 'Profile test');
git('config', 'user.email', 'profile@example.invalid');
git('config', 'commit.gpgsign', 'false');
git('config', 'core.hooksPath', '/dev/null');
fs.writeFileSync(path.join(origin, 'file'), 'source');
git('add', 'file');
git('commit', '-m', 'fixture');
const checkout = path.join(root, 'checkout');
fs.mkdirSync(path.join(checkout, 'node_modules'), { recursive: true });
const bin = path.join(root, 'bin');
fs.mkdirSync(bin);
const ownershipLog = path.join(root, 'ownership.json');
fs.writeFileSync(
path.join(bin, 'sudo'),
`#!${process.execPath}\nrequire('node:fs').writeFileSync(${JSON.stringify(ownershipLog)}, JSON.stringify(process.argv.slice(2)));\n`,
{ mode: 0o755 },
);
const result = spawnSync(
process.execPath,
[path.join(__dirname, 'bootstrap-native.cjs'), checkout],
{
encoding: 'utf8',
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
GITNEXUS_CLONE_URL: origin,
GITNEXUS_CLONE_REF: 'main',
},
},
);
assert.equal(result.status, 0, result.stderr);
const ownership = JSON.parse(fs.readFileSync(ownershipLog, 'utf8'));
assert.deepEqual(ownership.slice(0, 4), ['-n', 'find', checkout, '-xdev']);
assert.ok(ownership.includes('-h'));
const checkoutGit = (...args) =>
execFileSync('git', args, { cwd: checkout, encoding: 'utf8' }).trim();
assert.equal(checkoutGit('rev-parse', '--abbrev-ref', '@{upstream}'), 'origin/main');
assert.match(checkoutGit('pull'), /Already up to date/);
fs.writeFileSync(path.join(checkout, 'file'), 'local edits');
bootstrap(checkout, origin, 'main');
assert.equal(fs.readFileSync(path.join(checkout, 'file'), 'utf8'), 'local edits');
assert.throws(() => bootstrap(checkout, path.join(root, 'other'), 'main'), /another origin/);
const retry = path.join(root, 'retry');
assert.throws(() => bootstrap(retry, origin, 'new-branch'));
git('branch', 'new-branch');
bootstrap(retry, origin, 'new-branch');
assert.equal(fs.readFileSync(path.join(retry, 'file'), 'utf8'), 'source');
assert.equal(
execFileSync('git', ['rev-parse', '--abbrev-ref', '@{upstream}'], {
cwd: retry,
encoding: 'utf8',
}).trim(),
'origin/new-branch',
);
const conflict = path.join(root, 'conflict');
fs.mkdirSync(conflict);
fs.writeFileSync(path.join(conflict, 'file'), 'pre-existing user file');
assert.throws(() => bootstrap(conflict, origin, 'main'));
assert.equal(fs.readFileSync(path.join(conflict, 'file'), 'utf8'), 'pre-existing user file');
const ignored = path.join(root, 'ignored-conflict');
fs.mkdirSync(ignored);
execFileSync('git', ['init', '-q'], { cwd: ignored });
fs.writeFileSync(path.join(ignored, '.git/info/exclude'), 'file\n');
fs.writeFileSync(path.join(ignored, 'file'), 'ignored user file');
assert.throws(() => bootstrap(ignored, origin, 'main'));
assert.equal(fs.readFileSync(path.join(ignored, 'file'), 'utf8'), 'ignored user file');
// Preserve the user's file elsewhere, then verify the same checkout can resume.
fs.renameSync(path.join(ignored, 'file'), path.join(ignored, 'saved-user-file'));
bootstrap(ignored, origin, 'main');
assert.equal(fs.readFileSync(path.join(ignored, 'file'), 'utf8'), 'source');
assert.equal(fs.readFileSync(path.join(ignored, 'saved-user-file'), 'utf8'), 'ignored user file');
});

View file

@ -0,0 +1,33 @@
'use strict';
const fs = require('node:fs');
const os = require('node:os');
function read(file) {
try {
return fs.readFileSync(file, 'utf8').trim();
} catch {
return 'unavailable';
}
}
function workerBudget({
memory = os.totalmem(),
parallelism = os.availableParallelism(),
memoryMax = read('/sys/fs/cgroup/memory.max'),
cpuMax = read('/sys/fs/cgroup/cpu.max'),
override = process.env.GITNEXUS_DEV_TEST_WORKERS,
} = {}) {
if (override !== undefined && override !== '') {
if (!/^[1-9][0-9]?$/.test(override))
throw new Error('GITNEXUS_DEV_TEST_WORKERS must be an integer from 1 to 99');
return Number(override);
}
if (/^[0-9]+$/.test(memoryMax)) memory = Math.min(memory, Number(memoryMax));
const [quota, period] = cpuMax.split(' ').map(Number);
if (Number.isFinite(quota) && quota > 0 && period > 0)
parallelism = Math.min(parallelism, Math.max(1, Math.floor(quota / period)));
// Leave room for the editor/API and native allocations outside Node's heap.
return Math.max(1, Math.min(4, parallelism - 1, Math.floor(memory / 2 ** 30) - 1));
}
module.exports = { read, workerBudget };

View file

@ -0,0 +1,25 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { workerBudget } = require('./resources.cjs');
test('worker budget respects CPU quota, memory allowance and interactive headroom', () => {
const limits = {
memory: 32 * 2 ** 30,
parallelism: 32,
override: '',
memoryMax: 'max',
cpuMax: 'max 100000',
};
assert.equal(workerBudget(limits), 4);
assert.equal(workerBudget({ ...limits, cpuMax: '200000 100000' }), 1);
assert.equal(workerBudget({ ...limits, memoryMax: String(3 * 2 ** 30) }), 2);
assert.equal(workerBudget({ ...limits, memoryMax: '100000000', cpuMax: '50000 100000' }), 1);
assert.equal(workerBudget({ ...limits, memoryMax: 'unavailable', cpuMax: 'unavailable' }), 4);
});
test('worker override is explicit and validated', () => {
assert.equal(workerBudget({ override: '2' }), 2);
for (const override of ['0', '-1', 'abc', '1;bad', '100'])
assert.throws(() => workerBudget({ override }), /integer/);
});

View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Restore the image's standalone Codex package into its mounted config volume.
# Optional paths allow migration tests to use isolated temporary directories.
set -euo pipefail
seed=${1:-/home/node/.local/share/codex-standalone}
standalone=${2:-/home/node/.codex/packages/standalone}
command=${3:-/home/node/.local/bin/codex}
if [ -x "$command" ] && "$command" --version >/dev/null 2>&1; then
exit 0
fi
echo "[post-create] restoring standalone Codex into its config volume"
test -d "$seed/releases"
test -L "$seed/current"
mkdir -p "$standalone/releases"
# Copy only packages; credentials, sessions and user configuration stay intact.
# Publish `current` after copying so an interrupted copy can be retried.
cp -r "$seed/releases/." "$standalone/releases/"
if [ -f "$seed/auto-update-version" ]; then
cp "$seed/auto-update-version" "$standalone/auto-update-version"
fi
ln -sfn "$(readlink "$seed/current")" "$standalone/current"
"$command" --version

View file

@ -0,0 +1,88 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
function fixture(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-codex-seed-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const seed = path.join(root, 'image-seed');
const config = path.join(root, 'config-volume');
const standalone = path.join(config, 'packages/standalone');
const command = path.join(root, 'codex');
const release = 'image-release';
const bin = path.join(seed, 'releases', release, 'bin/codex');
fs.mkdirSync(path.dirname(bin), { recursive: true });
fs.writeFileSync(bin, '#!/bin/sh\necho codex-image\n', { mode: 0o755 });
fs.writeFileSync(path.join(seed, 'auto-update-version'), release);
// Match the official installer's absolute symlinks, including the launcher
// that becomes dangling when Docker mounts an existing config volume.
fs.symlinkSync(path.join(standalone, 'releases', release), path.join(seed, 'current'));
fs.symlinkSync(path.join(standalone, 'current/bin/codex'), command);
const run = () =>
spawnSync('bash', [path.join(__dirname, 'seed-codex.sh'), seed, standalone, command], {
encoding: 'utf8',
});
return { root, seed, config, standalone, command, bin, run };
}
test('restores native Codex into an empty mounted config volume', (t) => {
const f = fixture(t);
const result = f.run();
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /codex-image/);
assert.equal(fs.statSync(f.command).mode & 0o111, 0o111);
assert.equal(
fs.readFileSync(path.join(f.standalone, 'auto-update-version'), 'utf8'),
'image-release',
);
});
test('migrates a reused config volume without changing its state', (t) => {
const f = fixture(t);
fs.mkdirSync(path.join(f.config, 'sessions'), { recursive: true });
for (const file of ['auth.json', 'config.toml', 'sessions/rollout.jsonl'])
fs.writeFileSync(path.join(f.config, file), `fixture-${file}`);
const result = f.run();
assert.equal(result.status, 0, result.stderr);
for (const file of ['auth.json', 'config.toml', 'sessions/rollout.jsonl'])
assert.equal(fs.readFileSync(path.join(f.config, file), 'utf8'), `fixture-${file}`);
});
test('keeps a working container-managed release without reading the image seed', (t) => {
const f = fixture(t);
assert.equal(f.run().status, 0);
const custom = path.join(f.standalone, 'releases/custom/bin');
fs.mkdirSync(custom, { recursive: true });
fs.writeFileSync(path.join(custom, 'codex'), '#!/bin/sh\necho codex-custom\n', { mode: 0o755 });
fs.unlinkSync(path.join(f.standalone, 'current'));
fs.symlinkSync(path.dirname(custom), path.join(f.standalone, 'current'));
fs.rmSync(f.seed, { recursive: true });
const result = f.run();
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readlinkSync(path.join(f.standalone, 'current')), path.dirname(custom));
assert.equal(result.stdout, '');
});
test('repairs an installed release that fails its version check', (t) => {
const f = fixture(t);
assert.equal(f.run().status, 0);
fs.writeFileSync(path.join(f.standalone, 'current/bin/codex'), '#!/bin/sh\nexit 1\n');
const result = f.run();
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /codex-image/);
});
test('fails for an incomplete seed and succeeds after it is repaired', (t) => {
const f = fixture(t);
fs.unlinkSync(f.bin);
assert.notEqual(f.run().status, 0);
fs.writeFileSync(f.bin, '#!/bin/sh\necho codex-image\n', { mode: 0o755 });
const result = f.run();
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /codex-image/);
});

64
.devcontainer/tune-9p.sh Normal file
View file

@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Best-effort 9p remount, enabled by default. Requires SYS_ADMIN; failures never
# block startup. A successful mount command does not prove msize changed.
set -u
if [ "${WORKSPACE_9P_TUNE:-1}" != "1" ]; then
exit 0
fi
TARGET_MSIZE="${WORKSPACE_9P_MSIZE:-1048576}"
MOUNTPOINT="${1:-/workspace}"
if ! [[ "$TARGET_MSIZE" =~ ^[1-9][0-9]{0,9}$ ]] || (( TARGET_MSIZE > 2147483647 )); then
echo "[tune-9p] invalid WORKSPACE_9P_MSIZE; use an integer from 1 to 2147483647"
exit 0
fi
if ! command -v findmnt >/dev/null 2>&1; then
echo "[tune-9p] findmnt unavailable; skipping"
exit 0
fi
# Match the mountpoint itself, not the containing filesystem for an arbitrary
# directory. This avoids attempting a remount of the container root.
fstype=$(findmnt -rn --first-only --mountpoint "$MOUNTPOINT" -o FSTYPE 2>/dev/null || true)
if [ "$fstype" != "9p" ]; then
echo "[tune-9p] ${MOUNTPOINT} is ${fstype:-unknown}, not 9p; nothing to do"
exit 0
fi
if ! opts=$(findmnt -rn --first-only --mountpoint "$MOUNTPOINT" -o OPTIONS 2>/dev/null); then
echo "[tune-9p] cannot read mount options; skipping"
exit 0
fi
current=""
if [[ ",$opts," =~ ,msize=([1-9][0-9]{0,9}), ]]; then
current="${BASH_REMATCH[1]}"
fi
echo "[tune-9p] ${MOUNTPOINT} 9p msize=${current:-unknown} (target ${TARGET_MSIZE})"
if [ -n "$current" ] && (( current >= TARGET_MSIZE )); then
echo "[tune-9p] already at or above target"
exit 0
fi
if ! command -v sudo >/dev/null 2>&1 || ! command -v timeout >/dev/null 2>&1; then
echo "[tune-9p] sudo or timeout unavailable; skipping"
exit 0
fi
if ! timeout --kill-after=1s 5s sudo -n mount -o "remount,msize=${TARGET_MSIZE}" "$MOUNTPOINT"; then
echo "[tune-9p] remount failed or timed out; SYS_ADMIN may be missing, or the kernel/server rejected it"
echo "[tune-9p] rebuild the devcontainer to apply its capAdd: [SYS_ADMIN] setting"
exit 0
fi
newopts=$(findmnt -rn --first-only --mountpoint "$MOUNTPOINT" -o OPTIONS 2>/dev/null || true)
new=""
if [[ ",$newopts," =~ ,msize=([1-9][0-9]{0,9}), ]]; then
new="${BASH_REMATCH[1]}"
fi
echo "[tune-9p] after remount msize=${new:-unknown}"
if [ -n "$new" ] && (( new >= TARGET_MSIZE )); then
echo "[tune-9p] verified at or above target"
else
echo "[tune-9p] remount returned success but no increase to the target was verified; the kernel or server may ignore or limit msize"
fi
exit 0

View file

@ -0,0 +1,163 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
function fixture(t, overrides = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-tune-9p-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const log = path.join(root, 'mount-args');
fs.writeFileSync(
path.join(root, 'findmnt'),
`#!/bin/bash
printf '%s\\n' "$@" >> "$TUNE_TEST_ROOT/findmnt-args"
[ "$MOCK_FINDMNT_FAIL" = 0 ] || exit 1
case "$*" in
*FSTYPE) printf '%s\\n' "$MOCK_FSTYPE" ;;
*OPTIONS)
[ "$MOCK_OPTIONS_FAIL" = 0 ] || exit 1
if [ -f "$TUNE_TEST_ROOT/remounted" ]; then
printf '%s\\n' "$MOCK_AFTER_OPTIONS"
else
printf '%s\\n' "$MOCK_OPTIONS"
fi ;;
esac
`,
{ mode: 0o755 },
);
// Never execute mount: record argv and simulate its result, even if tests
// happen to run with SYS_ADMIN on a developer's machine.
fs.writeFileSync(
path.join(root, 'sudo'),
`#!/bin/bash
printf '%s\\n' "$@" > "$TUNE_TEST_ROOT/mount-args"
if [ "$MOCK_HANG" = 1 ]; then sleep 30; fi
[ "$MOCK_REMOUNT_EXIT" = 0 ] || exit "$MOCK_REMOUNT_EXIT"
touch "$TUNE_TEST_ROOT/remounted"
`,
{ mode: 0o755 },
);
const env = {
...process.env,
PATH: `${root}:${process.env.PATH}`,
TUNE_TEST_ROOT: root,
WORKSPACE_9P_TUNE: '1',
WORKSPACE_9P_MSIZE: '1048576',
MOCK_FSTYPE: '9p',
MOCK_OPTIONS: 'rw,relatime,msize=65536,trans=virtio',
MOCK_AFTER_OPTIONS: 'rw,msize=1048576,trans=virtio',
MOCK_REMOUNT_EXIT: '0',
MOCK_FINDMNT_FAIL: '0',
MOCK_OPTIONS_FAIL: '0',
MOCK_HANG: '0',
...overrides,
};
const run = (...args) => {
const result = spawnSync('/bin/bash', [path.join(__dirname, 'tune-9p.sh'), ...args], {
env,
encoding: 'utf8',
timeout: 10000,
});
assert.equal(result.error, undefined, String(result.error));
assert.equal(result.status, 0, result.stderr);
return result.stdout;
};
return { root, log, run };
}
test('tuning is enabled when WORKSPACE_9P_TUNE is unset', (t) => {
const f = fixture(t, { WORKSPACE_9P_TUNE: undefined });
assert.match(f.run(), /verified at or above target/);
assert.ok(fs.existsSync(f.log));
});
test('disabled tuning does not inspect or remount anything', (t) => {
const f = fixture(t, { WORKSPACE_9P_TUNE: '0' });
assert.equal(f.run(), '');
assert.equal(fs.existsSync(path.join(f.root, 'findmnt-args')), false);
assert.equal(fs.existsSync(f.log), false);
});
for (const fstype of ['ext4', 'virtiofs', 'overlay']) {
test(`skips a ${fstype} workspace without remounting`, (t) => {
const f = fixture(t, { MOCK_FSTYPE: fstype });
assert.match(f.run(), /not 9p/);
assert.equal(fs.existsSync(f.log), false);
});
}
test('failed mount lookup does not block startup or remount a containing filesystem', (t) => {
const f = fixture(t, { MOCK_FINDMNT_FAIL: '1' });
assert.match(f.run('/workspace/subdirectory'), /unknown/);
assert.equal(fs.existsSync(f.log), false);
const args = fs.readFileSync(path.join(f.root, 'findmnt-args'), 'utf8');
assert.match(args, /--mountpoint\n\/workspace\/subdirectory\n/);
});
for (const msize of ['1048576', '2097152']) {
test(`keeps an existing msize of ${msize}`, (t) => {
const f = fixture(t, { MOCK_OPTIONS: `rw,msize=${msize}` });
assert.match(f.run(), /already at or above target/);
assert.equal(fs.existsSync(f.log), false);
});
}
test('uses non-interactive sudo, honors custom size/path and verifies the result', (t) => {
const f = fixture(t, {
WORKSPACE_9P_MSIZE: '2097152',
MOCK_AFTER_OPTIONS: 'rw,msize=2097152',
});
assert.match(f.run('/workspace/project with spaces'), /verified at or above target/);
assert.deepEqual(fs.readFileSync(f.log, 'utf8').trimEnd().split('\n'), [
'-n',
'mount',
'-o',
'remount,msize=2097152',
'/workspace/project with spaces',
]);
});
test('a denied remount is non-fatal and explains capability/recreation requirements', (t) => {
const f = fixture(t, { MOCK_REMOUNT_EXIT: '1' });
const output = f.run();
assert.match(output, /remount failed/);
assert.match(output, /SYS_ADMIN/);
assert.match(output, /rebuild the devcontainer/);
});
test('successful remount with unchanged msize does not claim a verified increase', (t) => {
const f = fixture(t, { MOCK_AFTER_OPTIONS: 'rw,msize=65536' });
const output = f.run();
assert.match(output, /no increase to the target was verified/);
assert.doesNotMatch(output, /verified at or above target/);
});
test('unreadable mount options skip remounting', (t) => {
const f = fixture(t, { MOCK_OPTIONS_FAIL: '1' });
assert.match(f.run(), /cannot read mount options/);
assert.equal(fs.existsSync(f.log), false);
});
test('missing msize is reported as unknown without breaking startup', (t) => {
const f = fixture(t, { MOCK_OPTIONS: 'rw', MOCK_AFTER_OPTIONS: 'rw' });
assert.match(f.run(), /after remount msize=unknown/);
});
for (const msize of ['0', '-1', 'abc', '1;exit 1', '999999999999999999999999']) {
test(`invalid msize ${JSON.stringify(msize)} is non-fatal and never reaches mount`, (t) => {
const f = fixture(t, { WORKSPACE_9P_MSIZE: msize });
assert.match(f.run(), /invalid WORKSPACE_9P_MSIZE/);
assert.equal(fs.existsSync(f.log), false);
});
}
test('a hanging remount times out without blocking startup', (t) => {
const f = fixture(t, { MOCK_HANG: '1' });
const start = Date.now();
assert.match(f.run(), /remount failed or timed out/);
assert.ok(Date.now() - start < 9000, 'remount should be bounded by timeout');
});

View file

@ -0,0 +1,63 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { randomUUID } = require('node:crypto');
const { execFileSync } = require('node:child_process');
function verifyGitHooks(root = process.cwd()) {
root = path.resolve(root);
if (process.env.HUSKY === '0') throw new Error('HUSKY=0 disables pre-commit hooks.');
const hooksPath = execFileSync('git', ['config', '--get', 'core.hooksPath'], {
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
if (path.resolve(root, hooksPath) !== path.join(root, '.husky', '_')) {
throw new Error('core.hooksPath must point to .husky/_.');
}
for (const [file, mode] of [
['.husky/pre-commit', fs.constants.R_OK],
['.husky/_/h', fs.constants.R_OK],
['.husky/_/pre-commit', fs.constants.R_OK | fs.constants.X_OK],
]) {
const target = path.join(root, file);
if (!fs.statSync(target).isFile()) throw new Error(`${file} must be a file.`);
fs.accessSync(target, mode);
}
// Exercise the installed Husky helper, including its init.sh, against a
// temporary sentinel hook. Never run the real pre-commit or touch the index.
const probe = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-probe-'));
try {
const token = randomUUID();
fs.mkdirSync(path.join(probe, '_'));
fs.writeFileSync(path.join(probe, 'pre-commit'), `printf '%s\\n' '${token}'\n`);
const output = execFileSync(
'sh',
['-c', '. "$1"', path.join(probe, '_/pre-commit'), path.join(root, '.husky/_/h')],
{ cwd: root, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'] },
);
if (!output.includes(token)) {
throw new Error('Husky startup configuration disables or skips pre-commit hooks.');
}
} finally {
fs.rmSync(probe, { recursive: true, force: true });
}
}
module.exports = { verifyGitHooks };
if (require.main === module) {
try {
verifyGitHooks();
console.log('[install-deps] Husky pre-commit hook is installed and enabled');
} catch (error) {
console.error(`[install-deps] Husky verification failed: ${error.message}`);
console.error('Run npm run prepare at the repository root and keep HUSKY enabled.');
process.exitCode = 1;
}
}

View file

@ -0,0 +1,158 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync, spawnSync } = require('node:child_process');
const verifier = path.join(__dirname, 'verify-git-hooks.cjs');
const husky = path.resolve(__dirname, '../node_modules/husky/bin.js');
function fixture(t, install = true) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-husky-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const globalConfig = path.join(root, 'global-gitconfig');
fs.writeFileSync(globalConfig, '');
const env = {
...process.env,
HUSKY: '',
GIT_CONFIG_GLOBAL: globalConfig,
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_COUNT: '0',
XDG_CONFIG_HOME: path.join(root, 'xdg'),
};
for (const key of ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE'])
delete env[key];
const options = { cwd: root, env, encoding: 'utf8' };
const git = (...args) => execFileSync('git', args, options);
git('init', '-q');
git('config', 'user.name', 'Husky test');
git('config', 'user.email', 'husky-test@example.invalid');
fs.mkdirSync(path.join(root, '.husky'));
fs.copyFileSync(
path.resolve(__dirname, '../.husky/pre-commit'),
path.join(root, '.husky/pre-commit'),
);
if (install) execFileSync(process.execPath, [husky], options);
const verify = (overrides = {}) =>
spawnSync(process.execPath, [verifier], { ...options, env: { ...env, ...overrides } });
const commit = () => spawnSync('git', ['commit', '--allow-empty', '-m', 'hook test'], options);
const tool = (file, status) => {
const target = path.join(root, file);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, `#!/bin/sh\nprintf called > "$0.called"\nexit ${status}\n`, {
mode: 0o755,
});
return `${target}.called`;
};
return { root, git, verify, commit, tool };
}
test('Husky verification accepts a real installation', (t) => {
const repo = fixture(t);
const result = repo.verify();
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /installed and enabled/);
});
test('Husky verification rejects missing Git hook configuration', (t) => {
const repo = fixture(t, false);
assert.equal(repo.verify().status, 1);
});
test('Husky verification rejects a cleared cache without removing the tracked hook', (t) => {
const repo = fixture(t);
fs.rmSync(path.join(repo.root, '.husky/_'), { recursive: true });
assert.equal(repo.verify().status, 1);
assert.ok(fs.existsSync(path.join(repo.root, '.husky/pre-commit')));
});
test('Husky verification rejects hooks redirected elsewhere', (t) => {
const repo = fixture(t);
repo.git('config', 'core.hooksPath', 'other-hooks');
assert.equal(repo.verify().status, 1);
});
test('Husky verification rejects a missing tracked pre-commit script', (t) => {
const repo = fixture(t);
fs.rmSync(path.join(repo.root, '.husky/pre-commit'));
assert.equal(repo.verify().status, 1);
});
test('Husky verification rejects a non-executable Git hook', (t) => {
const repo = fixture(t);
fs.chmodSync(path.join(repo.root, '.husky/_/pre-commit'), 0o644);
assert.equal(repo.verify().status, 1);
});
test('Husky verification rejects an explicitly disabled hook', (t) => {
const repo = fixture(t);
const result = repo.verify({ HUSKY: '0' });
assert.equal(result.status, 1);
assert.match(result.stderr, /HUSKY=0 disables/);
});
for (const init of ['export HUSKY=0\n', 'exit 0\n']) {
test(`Husky verification rejects startup configuration ${init.trim()}`, (t) => {
const repo = fixture(t);
const marker = repo.tool('node_modules/.bin/lint-staged', 1);
fs.mkdirSync(path.join(repo.root, 'xdg/husky'), { recursive: true });
fs.writeFileSync(path.join(repo.root, 'xdg/husky/init.sh'), init);
const result = repo.verify();
assert.equal(result.status, 1);
assert.match(result.stderr, /startup configuration disables or skips/);
// Demonstrate why the verifier must fail: real Git would skip the formatter.
assert.equal(repo.commit().status, 0);
assert.equal(fs.existsSync(marker), false);
});
}
test('Husky probe accepts enabled startup configuration without running the tracked hook', (t) => {
const repo = fixture(t);
const marker = repo.tool('node_modules/.bin/lint-staged', 1);
fs.mkdirSync(path.join(repo.root, 'xdg/husky'), { recursive: true });
fs.writeFileSync(path.join(repo.root, 'xdg/husky/init.sh'), 'export HUSKY=1\nprintf startup\n');
assert.equal(repo.verify().status, 0);
assert.equal(fs.existsSync(marker), false);
assert.equal(repo.commit().status, 1);
assert.equal(fs.existsSync(marker), true);
});
test('Git rejects a commit when the actual pre-commit formatter fails', (t) => {
const repo = fixture(t);
const marker = repo.tool('node_modules/.bin/lint-staged', 1);
assert.equal(repo.verify().status, 0);
const result = repo.commit();
assert.equal(result.status, 1, result.stderr);
assert.match(result.stdout + result.stderr, /pre-commit: formatting staged files/);
assert.equal(fs.readFileSync(marker, 'utf8'), 'called');
assert.throws(() => repo.git('rev-parse', '--verify', 'HEAD'));
});
for (const pkg of ['gitnexus', 'gitnexus-web']) {
test(`Git rejects a commit when the actual ${pkg} pre-commit typecheck fails`, (t) => {
const repo = fixture(t);
repo.tool('node_modules/.bin/lint-staged', 0);
const marker = repo.tool(`${pkg}/node_modules/.bin/tsc`, 1);
fs.writeFileSync(path.join(repo.root, pkg, 'change.ts'), 'export const value = 1;\n');
repo.git('add', `${pkg}/change.ts`);
assert.equal(repo.verify().status, 0);
const result = repo.commit();
assert.equal(result.status, 1, result.stderr);
assert.match(result.stdout + result.stderr, new RegExp(`pre-commit: typechecking ${pkg}`));
assert.equal(fs.readFileSync(marker, 'utf8'), 'called');
assert.throws(() => repo.git('rev-parse', '--verify', 'HEAD'));
});
}
test('Git accepts a commit when the actual pre-commit checks pass', (t) => {
const repo = fixture(t);
repo.tool('node_modules/.bin/lint-staged', 0);
assert.equal(repo.verify().status, 0);
const result = repo.commit();
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout + result.stderr, /pre-commit: all checks passed/);
assert.ok(repo.git('rev-parse', '--verify', 'HEAD').trim());
});

View file

@ -1,10 +1,9 @@
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
# First, unit tests cover host->container config transforms and actual Husky
# pre-commit enforcement in temporary repositories. Second, the 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:
@ -12,10 +11,12 @@ on:
paths:
- '.devcontainer/**'
- '.github/workflows/ci-devcontainer.yml'
- 'gitnexus-web/package-lock.json'
pull_request:
paths:
- '.devcontainer/**'
- '.github/workflows/ci-devcontainer.yml'
- 'gitnexus-web/package-lock.json'
permissions:
contents: read
@ -42,17 +43,28 @@ jobs:
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
- name: Unit-test the host->container config transforms
run: node --test .devcontainer/translate-plugin-registries.test.cjs
# Hook tests install real Husky in temporary repositories.
- name: Install hook test tooling
run: |
npm ci --no-audit --no-fund
node .devcontainer/verify-git-hooks.cjs
- name: Unit-test devcontainer setup and hooks
run: node --test .devcontainer/*.test.cjs
- name: Syntax-check the lifecycle shell scripts
run: |
bash -n .devcontainer/install-deps.sh
bash -n .devcontainer/post-create.sh
bash -n .devcontainer/seed-codex.sh
bash -n .devcontainer/tune-9p.sh
build:
name: Build devcontainer image
name: Build devcontainer image (${{ matrix.profile }})
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
profile: [default, ui]
steps:
# persist-credentials: false — this is a read-only build smoke that
# never pushes. The setting keeps GITHUB_TOKEN out of .git/config,
@ -63,21 +75,19 @@ jobs:
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
- name: Generate UI profile
if: matrix.profile == 'ui'
run: node .devcontainer/profile.cjs ui
# 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
# build.args (including BUN_VERSION), and runs the Dockerfile. This smoke
# catches Dockerfile regressions, including native CLI installation.
# 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`).
# it builds only amd64. Native CLI installers also support arm64, but that
# path is not built here. Add QEMU and a linux/arm64 matrix leg if needed.
#
# The @devcontainers/cli version is pinned on purpose. A bare
# `npx --yes @devcontainers/cli` would resolve @latest at run time. A
@ -109,10 +119,12 @@ jobs:
echo "::warning::failed to pre-pull ${img} after 3 attempts; continuing — build step may still succeed"
exit 1
- name: Build devcontainer via @devcontainers/cli
env:
DEVCONTAINER_CONFIG: ${{ matrix.profile == 'ui' && '.devcontainer/generated/devcontainer.json' || '.devcontainer/devcontainer.json' }}
run: |
set -euo pipefail
for attempt in 1 2; do
if npx --yes @devcontainers/cli@0.87.0 build --workspace-folder .; then
if npx --yes @devcontainers/cli@0.87.0 build --workspace-folder . --config "$DEVCONTAINER_CONFIG" --frozen-lockfile; then
if [ "$attempt" -eq 2 ]; then
echo "::notice::devcontainer build retry succeeded (attempt 2); investigate if this recurs across runs."
fi

View file

@ -1056,6 +1056,13 @@ jobs:
run: node --expose-gc --import tsx bench/cfg/measure.mjs --check
working-directory: gitnexus
- name: Inline handler capture / visibility guards (#3489)
if: ${{ !cancelled() }}
# Build-free: pins inline/returned definitions and private-name refusals,
# then guards capture and fallback-cache scaling.
run: node --import tsx bench/inline-taint/measure.cjs --check
working-directory: gitnexus
- name: Staged publication durability / scaling guards (#3591)
if: ${{ !cancelled() }}
run: node --import tsx bench/staged-publication/measure.cjs --check

View file

@ -0,0 +1,75 @@
name: Publish Development Images
on:
push:
branches: [main]
paths:
- '.devcontainer/**'
- 'gitnexus-web/package-lock.json'
- '.github/workflows/devcontainer-publish.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
publish:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
profile: [core, ui]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
- name: Verify development tooling before publishing
run: |
npm ci --no-audit --no-fund
node --test .devcontainer/*.test.cjs
- uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate toolchain-only configuration
env:
PROFILE: ${{ matrix.profile }}
run: |
if [ "$PROFILE" = ui ]; then
node .devcontainer/profile.cjs image-build --ui
else
node .devcontainer/profile.cjs image-build
fi
- name: Build both architectures, cache and publish
env:
PROFILE: ${{ matrix.profile }}
run: |
set -euo pipefail
image="ghcr.io/${GITHUB_REPOSITORY,,}-devcontainer"
suffix=""
if [ "$PROFILE" = ui ]; then suffix="-ui"; fi
tag="${image}:sha-${GITHUB_SHA}-run-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}${suffix}"
cache="${image}:buildcache-${PROFILE}"
npx --yes @devcontainers/cli@0.87.0 build \
--workspace-folder . --config .devcontainer/generated/devcontainer.json \
--frozen-lockfile --platform linux/amd64,linux/arm64 \
--image-name "$tag" --push true \
--label "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}" \
--cache-from "$cache" --cache-to "type=registry,ref=${cache},mode=max"
docker buildx imagetools create --tag "${image}:main${suffix}" "$tag"
echo "Published ${tag}" >> "$GITHUB_STEP_SUMMARY"

1
.gitignore vendored
View file

@ -87,6 +87,7 @@ gitnexus/test/fixtures/lang-resolution/**/bin
GitNexus.sln
# Git worktrees
.worktrees/
.devcontainer/generated/
# Vendored tree-sitter grammar build artifacts (created at install time,
# never committed). See docs/plans/2026-04-15-002-fix-tree-sitter-proto-vendor-deps-plan.md

View file

@ -2,6 +2,8 @@ dist/
coverage/
gitnexus/vendor/
gitnexus/test/fixtures/
# Line/column-sensitive callable identity and taint regression.
gitnexus/test/integration/cfg/fixtures/inline-taint-repo/
gitnexus-web/test/fixtures/
gitnexus-web/playwright-report/
gitnexus-web/test-results/

View file

@ -0,0 +1,46 @@
{
"inline": {
"scaling_budget": 2,
"small": {
"functions": 96,
"fingerprint": "616f56191848b238238ff190561092a37a0f9648c4e7e7d0a61e9033a71d4885"
},
"large": {
"functions": 384,
"fingerprint": "4321c065614a223eb03dff54632e6133eebc5146edade30a1be963acbb9e5904"
}
},
"returned": {
"scaling_budget": 2,
"small": {
"functions": 192,
"fingerprint": "e353d4c8e19a8984e775527bbbeef2f3e7742f28aa6936a3b36eeae9de0ddd97"
},
"large": {
"functions": 768,
"fingerprint": "e3b3bbe4e022a9e68a5d91c65eb236a9723a9de624e9ec20a42fd6bd07fb31f3"
}
},
"visibility": {
"scaling_budget": 2,
"small": {
"functions": 96,
"fingerprint": "ff8ce299442d36317b3b0050435ad0c4fc967ae8c0d2b4af7a8569ea72a3c7fa"
},
"large": {
"functions": 384,
"fingerprint": "2b0074f92355d2ded979153b2769d0f426b53aed794c52cd1b92319e5fad753c"
}
},
"named": {
"scaling_budget": 2,
"small": {
"functions": 96,
"fingerprint": "e4c4dd74c3426ce3494c8a973577246f8682af0cb89759bbd3823def9e2694ef"
},
"large": {
"functions": 384,
"fingerprint": "4c9bb0e4112c8fcc51a4f175de25d2cc8cc0fd293e66241f9cc77449fb8e0daa"
}
}
}

View file

@ -0,0 +1,133 @@
#!/usr/bin/env node
// Build-free capture/identity benchmark: node --import tsx bench/inline-taint/measure.cjs --check
// BENCH_SOURCE_ROOT selects the unmodified checkout for a before/after run.
const assert = require('node:assert/strict');
const { readFileSync, existsSync } = require('node:fs');
const { join, resolve } = require('node:path');
const { pathToFileURL } = require('node:url');
const { createHash } = require('node:crypto');
const Parser = require('tree-sitter');
const TS = require('tree-sitter-typescript');
const root = process.env.BENCH_SOURCE_ROOT
? join(resolve(process.env.BENCH_SOURCE_ROOT), 'gitnexus')
: resolve(__dirname, '../..');
async function main() {
const { emitTsScopeCaptures } = await import(
pathToFileURL(join(root, 'src/core/ingestion/languages/typescript/captures.ts')).href
);
const report = {};
for (const shape of ['inline', 'returned', 'named']) {
const arms = [];
for (const count of [96, 384]) {
const source = Array.from({ length: count }, (_, i) =>
shape === 'inline'
? `app.get('/${i}', (req) => { forward(req.query.value); });`
: shape === 'returned'
? `function make${i}() { return async function handler${i}(req) { forward(req.body.value); }; }`
: `function handler${i}(req) { forward(req.query.value); }`,
).join('\n');
const parser = new Parser();
parser.setLanguage(TS.typescript);
const tree = parser.parse(source);
let captures;
const times = [];
for (let i = 0; i < 20; i++) {
const start = performance.now();
captures = emitTsScopeCaptures(source, 'routes.ts', tree);
if (i >= 5) times.push(performance.now() - start);
}
const identities = captures
.filter((c) => c['@declaration.function'])
.map((c) => [c['@declaration.name']?.text, c['@declaration.function'].range]);
arms.push({
units: count,
min_ms: Math.min(...times),
functions: identities.length,
fingerprint: createHash('sha256').update(JSON.stringify(identities)).digest('hex'),
});
}
report[shape] = {
small: arms[0],
large: arms[1],
scaling: arms[1].min_ms / arms[0].min_ms / 4,
};
}
const visibilityPath = join(
root,
'src/core/ingestion/languages/javascript/callable-visibility.ts',
);
if (existsSync(visibilityPath)) {
const { isJsSelfBoundCallableFallbackPlausible } = await import(
pathToFileURL(visibilityPath).href
);
const { javascriptProvider } = await import(
pathToFileURL(join(root, 'src/core/ingestion/languages/typescript.ts')).href
);
const { extractParsedFile } = await import(
pathToFileURL(join(root, 'src/core/ingestion/scope-extractor-bridge.ts')).href
);
const arms = [];
for (const count of [96, 384]) {
const source = Array.from(
{ length: count },
(_, i) =>
`function make${i}() { return function handler${i}(req) { sink(req.query.value); }; }`,
).join('\n');
const parsed = extractParsedFile(javascriptProvider, source, 'handlers.js');
const candidates = parsed.localDefs.filter((def) => def.qualifiedName.startsWith('handler'));
const times = [];
let answers;
for (let i = 0; i < 20; i++) {
// A new ParsedFile includes cold index construction in every sample.
const fresh = { ...parsed };
const start = performance.now();
answers = candidates.map((candidate) =>
isJsSelfBoundCallableFallbackPlausible({
callerParsed: fresh,
candidate,
parsedFileOf: () => fresh,
site: { name: candidate.qualifiedName, inScope: fresh.moduleScope },
}),
);
if (i >= 5) times.push(performance.now() - start);
}
arms.push({
units: count,
min_ms: Math.min(...times),
functions: answers.filter((answer) => !answer).length,
fingerprint: createHash('sha256').update(JSON.stringify(answers)).digest('hex'),
});
}
report.visibility = {
small: arms[0],
large: arms[1],
scaling: arms[1].min_ms / arms[0].min_ms / 4,
};
}
if (process.argv.includes('--check')) {
const baseline = JSON.parse(readFileSync(join(__dirname, 'baseline.json'), 'utf8'));
assert.deepEqual(Object.keys(report).sort(), Object.keys(baseline).sort());
for (const [shape, arm] of Object.entries(report)) {
const expected = baseline[shape];
assert(
Number.isFinite(expected.scaling_budget) && expected.scaling_budget > 0,
'missing scaling gate',
);
assert(arm.scaling <= expected.scaling_budget, `${shape}: scaling ${arm.scaling}`);
for (const size of ['small', 'large']) {
assert.equal(arm[size].functions, expected[size].functions, `${shape}/${size}: functions`);
assert.equal(
arm[size].fingerprint,
expected[size].fingerprint,
`${shape}/${size}: identity drift`,
);
}
}
}
console.log(JSON.stringify(report, null, 2));
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View file

@ -213,7 +213,8 @@
},
"typescript": {
"_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.",
"fingerprint": "28cbf71b54870d9b5e4102031d758ca288d7ccc546adc892dae4c237d273b8ae",
"fingerprint": "112cb06df38cb8144aaf4cdb5e1209f5d74ff5fa38005299fb39c853df311a68",
"_rebaselined_3489_inline_handlers": "#3489: Compared main 22a7608 and candidate 7456546 emitters on the same 174-entry TypeScript corpus. Exactly four callback declaration groups were added: two in typescript-hoc-wrapped/src/negative-bare-call.ts, one in typescript-hof-callbacks/src/store.ts, and one in typescript-mcp-tools/src/server.ts. Every added group has @callable.unbound; none was removed or changed. Capture groups 2935 -> 2939. JavaScript is byte-identical. Synthetic counts remain 4503/14403 and scaling budgets are unchanged; CI measured TypeScript 1.048 < 1.5 and JavaScript 0.911 < 1.5.",
"_rebaselined_3446_mcp_tools_fixture": "#3446 adds typescript-mcp-tools/src/{handlers,server,tools}.ts: corpus growth only, with fixture_count 171 -> 174 and capture_groups_fp 2753 -> 2894. Excluding only that fixture directory restores the prior fingerprint 77c9b4ea654123a64972db8348190ec250b669472b150db65ea8c7f20b355467 exactly. The scope emitter, synthetic capture counts (4503/14403), scaling budget, and other language baselines are unchanged.",
"_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.",
"scaling_budget": 1.5,

View file

@ -65,8 +65,8 @@ Main at `50aa4be3b2c2c9a1561fc44878e5d8f87b99d16e` repaired three #3487 route
checks and all four #3499 checks, and their allowances were removed: **18/31
pass, 13 fail**.
The JSON report records every desired and actual answer independently of gate
status. Callback and guard taint findings are currently documented limitations;
their stronger desired answers remain visible as known gaps.
status. Inline-callback taint now passes and its allowance is removed. Guard
taint remains a documented limitation and a visible known gap.
`known-gaps.json` is an explicit, reviewed list of failing check IDs with issue,
reason and observed baseline answers. There is no wildcard, skip, automatic

View file

@ -14,12 +14,6 @@
"reason": "Leading base-URL interpolation prevents wrapper consumer linking (#3488).",
"baselineActual": []
},
{
"id": "explain.inline-flow",
"issue": 3489,
"reason": "Inline route callbacks lack the cross-function source summary (#3489).",
"baselineActual": []
},
{
"id": "explain.guard",
"issue": 3490,

View file

@ -44,6 +44,12 @@
* partition depend on the very machine load it is trying to protect against.
*/
export const WINDOWS_WEIGHTS_SEC: Readonly<Record<string, number>> = {
// Provisional Windows estimates, not Windows measurements: these native
// inline-handler proofs took 7.25/6.86/6.52s on Node24 Linux. Apply the
// runner's documented ~5x slowdown and round up; keep the watchdog unchanged.
'test/integration/cfg/interproc-taint.test.ts': 40,
'test/integration/function-local-identity.test.ts': 35,
'test/integration/taint-explain.test.ts': 35,
// Conservative estimates from Windows run 38144297115: persisted
// rename cases include native indexing and take up to ~25s each. Keep
// each language together while charging its complete fixture inventory.

View file

@ -161,6 +161,9 @@ const PLATFORM_LOGIC = [
// N-API addon which has known platform-specific behavior (Windows
// file-lock lag after close, macOS N-API destructor segfaults)
const LBUG_NATIVE = [
// Inline-handler findings must survive native persistence and cold explain
// readers on each OS; passing the Ubuntu-only taint suite is insufficient.
'test/integration/taint-explain.test.ts',
// Real indexing/MCP previews include tracked/untracked edits, file-symlink
// retargets and staging failures; the helper runs in a separate native process.
'test/integration/rename-preview-plan.test.ts',
@ -334,6 +337,9 @@ const SPAWN_CLI = [
// Worker threads tests — exercise real worker_threads which have
// platform-specific behavior (thread spawning, IPC, exit handling)
const WORKER_THREADS = [
// Native callback parsing and exact caller/summary ownership (#3489).
'test/integration/cfg/interproc-taint.test.ts',
'test/integration/function-local-identity.test.ts',
'test/unit/community-processor.test.ts',
'test/integration/worker-pool.test.ts',
'test/integration/parse-impl-quarantine-cache-skip.test.ts',

View file

@ -0,0 +1,56 @@
import type { ParsedFile, ScopeId } from 'gitnexus-shared';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
interface SelfBoundCallables {
readonly owners: ReadonlyMap<string, ScopeId>;
readonly parents: ReadonlyMap<ScopeId, ScopeId | null>;
readonly unbound: ReadonlySet<string>;
}
const indexes = new WeakMap<ParsedFile, SelfBoundCallables>();
/** Named expressions bind privately; anonymous display names are never bindings. */
export const isJsSelfBoundCallableFallbackPlausible: NonNullable<
ScopeResolver['isGlobalNameFallbackPlausible']
> = ({ callerParsed, candidate, parsedFileOf, site }) => {
const parsed = parsedFileOf(candidate.filePath);
if (parsed === undefined) return true;
let index = indexes.get(parsed);
if (index === undefined) {
const owners = new Map<string, ScopeId>();
const parents = new Map<ScopeId, ScopeId | null>();
const bound = new Set<string>();
const callables = new Set<string>();
for (const scope of parsed.scopes) {
parents.set(scope.id, scope.parent);
const ownCallables = new Set(
scope.kind === 'Function'
? scope.ownedDefs.filter((def) => def.type === 'Function').map((def) => def.nodeId)
: [],
);
for (const id of ownCallables) callables.add(id);
for (const bindings of scope.bindings.values()) {
for (const binding of bindings) {
bound.add(binding.def.nodeId);
if (ownCallables.has(binding.def.nodeId)) owners.set(binding.def.nodeId, scope.id);
}
}
}
for (const id of bound) callables.delete(id);
index = { owners, parents, unbound: callables };
indexes.set(parsed, index);
}
if (index.unbound.has(candidate.nodeId)) return false;
const owner = index.owners.get(candidate.nodeId);
if (owner === undefined) return true;
if (callerParsed.filePath !== candidate.filePath) return false;
if (site.inScope === undefined) return true;
for (
let scope: ScopeId | null | undefined = site.inScope;
scope != null;
scope = index.parents.get(scope)
) {
if (scope === owner) return true;
}
return false;
};

View file

@ -41,6 +41,7 @@ import { splitImportStatement } from '../typescript/import-decomposer.js';
import { getJsParser, getJsScopeQuery, jsCachedTreeMatchesGrammar } from './query.js';
import { computeTsArityMetadata } from '../typescript/arity-metadata.js';
import { synthesizeTsReceiverBinding } from '../typescript/receiver-binding.js';
import { unboundCallableName } from '../typescript/unbound-callables.js';
import { isArrayMethodCallbackArrow } from '../typescript/array-callback.js';
import { isStaticClassFieldBinding } from '../typescript/captures.js';
import { reducesToContainedType } from '../typescript/interpret.js';
@ -988,13 +989,28 @@ export function emitJsScopeCaptures(
// untouched, so inner-call attribution falls through to the enclosing
// scope instead of a phantom Function.
const fnDeclAnchor = grouped['@declaration.function'];
const unbound = groupedNodes['@callable.unbound'];
if (unbound !== undefined) {
grouped['@declaration.name'] = syntheticCapture(
'@declaration.name',
unbound,
unboundCallableName(unbound)!,
);
if (unbound.childForFieldName('name') === null) {
grouped['@declaration.binding-name'] = syntheticCapture(
'@declaration.binding-name',
unbound,
'',
);
}
}
if (fnDeclAnchor !== undefined) {
const arrowNode = findFunctionNode(
tree.rootNode,
fnDeclAnchor.range,
groupedNodes['@declaration.function'],
);
if (arrowNode !== null && isArrayMethodCallbackArrow(arrowNode)) {
if (unbound === undefined && arrowNode !== null && isArrayMethodCallbackArrow(arrowNode)) {
continue;
}
if (arrowNode !== null && isBlockedDefaultExportHoc(arrowNode)) {

View file

@ -47,6 +47,7 @@
*/
import Parser from 'tree-sitter';
import { unboundCallableQuery } from '../typescript/unbound-callables.js';
import JS from 'tree-sitter-javascript';
import {
ARRAY_METHOD_NOT_ANY_OF_PREDICATE,
@ -61,6 +62,7 @@ function isJsxFile(filePath: string): boolean {
}
export const JAVASCRIPT_SCOPE_QUERY = `
${unboundCallableQuery('declaration.function')}
;; Scopes — module / class-likes / function-likes
(program) @scope.module

View file

@ -47,6 +47,7 @@ import { makeJsResolveImportTarget } from './import-target.js';
import { loadTsconfigIndex } from '../typescript/tsconfig.js';
import { typescriptModuleExports } from '../typescript/module-exports.js';
import { loadNodeWorkspacePackages } from '../../import-resolvers/node-workspace-packages.js';
import { isJsSelfBoundCallableFallbackPlausible } from './callable-visibility.js';
async function loadResolutionConfig(repoPath: string, fresh = false) {
return {
@ -111,6 +112,7 @@ const javascriptScopeResolver: ScopeResolver = {
// explicit imports at the call site. Workspace-wide unique-name fallback
// recovers these edges.
allowGlobalFreeCallFallback: true,
isGlobalNameFallbackPlausible: isJsSelfBoundCallableFallbackPlausible,
// Same ECMAScript `export *` exclusivity as TypeScript: a name declared by
// two wildcard sources is refused, not guessed.

View file

@ -7,6 +7,7 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { unboundCallableName } from './typescript/unbound-callables.js';
import type { CaptureMatch, NodeLabel } from 'gitnexus-shared';
import { defineLanguage } from '../language-provider.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
@ -176,18 +177,26 @@ const pairKeyName = (keyNode: SyntaxNode | null | undefined): string | null => {
* applied to all `useCallback` / `useMemo` callbacks bound to a
* const — the sole way to give them a named caller anchor.
*
* Returns `null` for funcName when the arrow lives in a context that has
* no static name — bare call arguments (not bound to a const), computed
* keys, return-from-arrow positions. The parent walk in
* Bare expression-statement callbacks and returned function expressions get
* their own name or a position name. Other contexts without a static name
* (computed keys, return-from-arrow positions) return null. The parent walk in
* findEnclosingFunctionId then continues up to the next named ancestor
* (or to the file).
*/
const tsExtractFunctionName = (
node: SyntaxNode,
filePath?: string,
): { funcName: string | null; label: NodeLabel } | null => {
): { funcName: string | null; label: NodeLabel; identityName?: string | null } | null => {
if (node.type !== 'arrow_function' && node.type !== 'function_expression') return null;
const unboundName = unboundCallableName(node);
if (unboundName !== undefined)
return {
funcName: unboundName,
label: 'Function',
...(node.childForFieldName('name') === null ? { identityName: null } : {}),
};
const parent = node.parent;
if (!parent) return null;

View file

@ -37,6 +37,7 @@ import { splitImportStatement } from './import-decomposer.js';
import { getTsParser, getTsScopeQuery, tsCachedTreeMatchesGrammar } from './query.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
import { synthesizeTsReceiverBinding } from './receiver-binding.js';
import { unboundCallableName } from './unbound-callables.js';
import { computeTsArityMetadata } from './arity-metadata.js';
import { isArrayMethodCallbackArrow } from './array-callback.js';
import {
@ -520,13 +521,28 @@ export function emitTsScopeCaptures(
// untouched, so inner-call attribution falls through to the enclosing
// scope instead of a phantom Function.
const fnDeclAnchor = grouped['@declaration.function'];
const unbound = groupedNodes['@callable.unbound'];
if (unbound !== undefined) {
grouped['@declaration.name'] = syntheticCapture(
'@declaration.name',
unbound,
unboundCallableName(unbound)!,
);
if (unbound.childForFieldName('name') === null) {
grouped['@declaration.binding-name'] = syntheticCapture(
'@declaration.binding-name',
unbound,
'',
);
}
}
if (fnDeclAnchor !== undefined) {
const arrowNode = findFunctionNode(
tree.rootNode,
fnDeclAnchor.range,
groupedNodes['@declaration.function'],
);
if (arrowNode !== null && isArrayMethodCallbackArrow(arrowNode)) {
if (unbound === undefined && arrowNode !== null && isArrayMethodCallbackArrow(arrowNode)) {
continue;
}
if (arrowNode !== null && isBlockedDefaultExportHoc(arrowNode)) {

View file

@ -51,6 +51,7 @@
*/
import Parser from 'tree-sitter';
import { unboundCallableQuery } from './unbound-callables.js';
import TS from 'tree-sitter-typescript';
import {
ARRAY_METHOD_NOT_ANY_OF_PREDICATE,
@ -79,6 +80,7 @@ function isTsxFile(filePath: string): boolean {
}
export const TYPESCRIPT_SCOPE_QUERY = `
${unboundCallableQuery('declaration.function')}
;; Scopes — module / namespace / class-likes / function-likes
(program) @scope.module

View file

@ -42,6 +42,9 @@ export function tsBindingScopeFor(
innermost: Scope,
tree: ScopeTree,
): ScopeId | null {
// A named function expression binds only inside itself. Anonymous callables
// explicitly suppress their binding through @declaration.binding-name.
if (decl['@callable.unbound'] !== undefined) return innermost.id;
// Method return type: hoist to Module (mirrors csharpBindingScopeFor).
if (decl['@type-binding.return'] !== undefined) {
return walkToScope(innermost, tree, 'Module');

View file

@ -0,0 +1,33 @@
import type { SyntaxNode } from '../../utils/ast-helpers.js';
/** Bare callback/return expressions have a function identity, but no outer binding. */
export function unboundCallableName(node: SyntaxNode): string | undefined {
if (node.type !== 'arrow_function' && node.type !== 'function_expression') return undefined;
const parent = node.parent;
const isCallback =
parent?.type === 'arguments' &&
parent.parent?.type === 'call_expression' &&
parent.parent.parent?.type === 'expression_statement';
if (parent?.type !== 'return_statement' && !isCallback) return undefined;
return (
node.childForFieldName('name')?.text ??
// Display names also ride the identifier-only taint-path codec. Internal
// anonymous identity remains positional through the provider identity hook.
`fn_${node.startPosition.row}_${node.startPosition.column}`
);
}
// Shared by the TS and JS grammars, with the same INNER anchor as the CFG.
export function unboundCallableQuery(
anchor: 'definition.function' | 'declaration.function',
): string {
const marker = anchor === 'declaration.function' ? ' @callable.unbound' : '';
return `
(expression_statement
(call_expression
arguments: (arguments
[(arrow_function) (function_expression)] @${anchor}${marker})))
(return_statement
[(arrow_function) (function_expression)] @${anchor}${marker})
`;
}

View file

@ -79,7 +79,12 @@ export interface MethodExtractor {
extractFunctionName?(
node: SyntaxNode,
filePath?: string,
): { funcName: string | null; label: import('gitnexus-shared').NodeLabel } | null;
): {
funcName: string | null;
label: import('gitnexus-shared').NodeLabel;
/** Source identity name; null preserves anonymous positional identity when funcName is synthetic. */
identityName?: string | null;
} | null;
}
export interface MethodExtractionConfig {
@ -124,5 +129,9 @@ export interface MethodExtractionConfig {
extractFunctionName?: (
node: SyntaxNode,
filePath?: string,
) => { funcName: string | null; label: import('gitnexus-shared').NodeLabel } | null;
) => {
funcName: string | null;
label: import('gitnexus-shared').NodeLabel;
identityName?: string | null;
} | null;
}

View file

@ -897,8 +897,11 @@ function parseJsonStringArrayCapture(
}
function deriveDeclarationName(match: CaptureMatch, def: SymbolDefinition): string | undefined {
// An explicit empty binding override retains the declaration's graph identity
// without inventing a source-level name (for example, an anonymous callable).
const bindingName = match['@declaration.binding-name'];
if (bindingName !== undefined) return bindingName.text || undefined;
const nameCap =
match['@declaration.binding-name'] ??
match['@declaration.name'] ??
match[
Object.keys(match).find((k) => k.startsWith('@declaration.') && k.endsWith('.name')) ?? ''

View file

@ -20,12 +20,10 @@
* `FunctionCfg.functionStartLine` is 1-based (the TS visitor's `row + 1`);
* `Function`/`Method` node `startLine` is 0-based (`startPosition.row`). The
* join therefore looks up node start line `functionStartLine - 1`
* ({@link NODE_TO_CFG_LINE_OFFSET}). Function nodes carry no start column, so a
* `(filePath, startLine)` collision — two functions opening on one line,
* `{ a: () => x(), b: () => y() }` — is ambiguous: the CFG disambiguates with
* `functionStartColumn` but the node does not, so a colliding anchor is DROPPED
* (counted as `unresolved`) rather than risk attaching a summary to the wrong
* function. Rare in practice; the alternative (cross-wired summaries) is unsound.
* ({@link NODE_TO_CFG_LINE_OFFSET}). Callable graph nodes carry their column
* during ingestion, so same-line functions join by exact position. Providers
* without column evidence retain the unique-line fallback; ambiguous anchors
* remain unresolved rather than attaching a summary to a different function.
*/
import type { ParsedFile, ParsedImport, GraphNode } from 'gitnexus-shared';
@ -60,10 +58,15 @@ const FUNCTIONISH_LABELS = new Set(['Function', 'Method']);
* Built ONCE per scope-resolution pass (the graph is whole-repo); reused across
* every file's harvest.
*/
export type FunctionNodeIndex = ReadonlyMap<string, ReadonlyMap<number, readonly string[]>>;
export type FunctionNodeIndex = ReadonlyMap<string, ReadonlyMap<number, readonly string[]>> & {
readonly byPosition?: ReadonlyMap<string, ReadonlyMap<string, readonly string[]>>;
readonly columnAnchoredIds?: ReadonlySet<string>;
};
export function buildFunctionNodeIndex(graph: KnowledgeGraph): FunctionNodeIndex {
const index = new Map<string, Map<number, string[]>>();
const byPosition = new Map<string, Map<string, string[]>>();
const columnAnchoredIds = new Set<string>();
const add = (node: GraphNode): void => {
if (!FUNCTIONISH_LABELS.has(node.label)) return;
const filePath = node.properties.filePath;
@ -77,18 +80,35 @@ export function buildFunctionNodeIndex(graph: KnowledgeGraph): FunctionNodeIndex
const ids = byLine.get(startLine);
if (ids) ids.push(node.id);
else byLine.set(startLine, [node.id]);
const startColumn = node.properties.startColumn;
if (typeof startColumn === 'number') {
columnAnchoredIds.add(node.id);
let positions = byPosition.get(filePath);
if (!positions) {
positions = new Map();
byPosition.set(filePath, positions);
}
const key = `${startLine}:${startColumn}`;
const samePosition = positions.get(key);
if (samePosition) samePosition.push(node.id);
else positions.set(key, [node.id]);
}
};
for (const node of graph.iterNodes()) add(node);
return index;
return Object.assign(index, { byPosition, columnAnchoredIds });
}
/** Resolve a CFG anchor to a unique functionish node id, or undefined. */
function resolveFnId(fnIndex: FunctionNodeIndex, cfg: FunctionCfg): string | undefined {
const exact = fnIndex.byPosition
?.get(cfg.filePath)
?.get(`${cfg.functionStartLine - NODE_TO_CFG_LINE_OFFSET}:${cfg.functionStartColumn}`);
if (exact !== undefined) return exact.length === 1 ? exact[0] : undefined;
const byLine = fnIndex.get(cfg.filePath);
if (!byLine) return undefined;
const ids = byLine.get(cfg.functionStartLine - NODE_TO_CFG_LINE_OFFSET);
// Unique match only — a same-line collision is unresolvable (no node column).
return ids && ids.length === 1 ? ids[0] : undefined;
// Legacy/provider fallback only when the line has exactly one callable.
return ids && ids.length === 1 && !fnIndex.columnAnchoredIds?.has(ids[0]) ? ids[0] : undefined;
}
export interface FileSummaryResult {

View file

@ -15,9 +15,11 @@ import {
ARRAY_METHOD_NOT_ANY_OF_PREDICATE,
DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE,
} from './ts-js-hoc-utils.js';
import { unboundCallableQuery } from './languages/typescript/unbound-callables.js';
// TypeScript queries - works with tree-sitter-typescript
export const TYPESCRIPT_QUERIES = `
${unboundCallableQuery('definition.function')}
(class_declaration
name: (type_identifier) @name) @definition.class
@ -789,6 +791,7 @@ export const TYPESCRIPT_QUERIES = `
// JavaScript queries - works with tree-sitter-javascript
export const JAVASCRIPT_QUERIES = `
${unboundCallableQuery('definition.function')}
(class_declaration
name: (identifier) @name) @definition.class

View file

@ -604,7 +604,11 @@ export function getLabelFromCaptures(
!captureMap['definition.constructor'] &&
!captureMap['definition.class'] &&
!captureMap['definition.struct'] &&
!hasDefaultExportHocNameSeed
!hasDefaultExportHocNameSeed &&
!(
captureMap['definition.function'] &&
provider.methodExtractor?.extractFunctionName?.(captureMap['definition.function'])?.funcName
)
)
return null;

View file

@ -999,7 +999,10 @@ const callableOwnQualifiedName = (
// An anonymous callable has no name of its own, so it IS its position: the
// `ownName === null` branch below carries the position INSTEAD of a name,
// never in addition to one, so the two spellings cannot stack.
const ownName = efnResult?.funcName ?? genericFuncName(fnNode) ?? null;
const ownName =
efnResult?.identityName !== undefined
? efnResult.identityName
: (efnResult?.funcName ?? genericFuncName(fnNode) ?? null);
let finalLabel = efnResult?.label ?? inferFunctionLabel(fnNode.type);
if (provider.labelOverride) {
const override = provider.labelOverride(fnNode, finalLabel);
@ -1097,13 +1100,15 @@ const findEnclosingFunctionId = (
// nested prefix exists it IS `ownerName`, so this branch and the
// owner branch below cannot disagree about which prefix applies.
const qualifiedName =
nestedPrefix !== undefined
? nestedCallableQualifiedName(nestedPrefix, current, funcName)
: ownerName
? `${ownerName}.${funcName}`
: needsArrayPosition
? positionQualifiedCallableName(funcName, current.startPosition)
: funcName;
efnResult?.identityName === null
? callableOwnQualifiedName(current, filePath, provider)
: nestedPrefix !== undefined
? nestedCallableQualifiedName(nestedPrefix, current, funcName)
: ownerName
? `${ownerName}.${funcName}`
: needsArrayPosition
? positionQualifiedCallableName(funcName, current.startPosition)
: funcName;
// Include #<arity> suffix to match definition-phase Method/Constructor IDs.
// Use the same MethodExtractor (getMethodInfo) as the definition phase.
// When same-arity collisions exist, also append ~type1,type2.
@ -2370,6 +2375,11 @@ const processFileGroup = (
const cjsDefaultExportName = isCjsDefaultExport
? (cjsDefaultExportOwnName ?? deriveDefaultExportHocName(file.path))
: null;
const inferredFunction =
!nameNode && nodeLabel === 'Function' && definitionNode
? provider.methodExtractor?.extractFunctionName?.(definitionNode, file.path)
: undefined;
const inferredFunctionName = inferredFunction?.funcName;
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
if (
@ -2377,7 +2387,8 @@ const processFileGroup = (
nodeLabel !== 'Constructor' &&
!extractedClassSymbol &&
!defaultExportHocName &&
!cjsDefaultExportName
!cjsDefaultExportName &&
!inferredFunctionName
)
continue;
@ -2385,6 +2396,7 @@ const processFileGroup = (
extractedClassSymbol?.name ??
defaultExportHocName ??
cjsDefaultExportName ??
inferredFunctionName ??
(nameNode ? nameNode.text : 'init');
// Dedup: variable captures (Const/Static/Variable) may overlap with higher-priority
// captures (e.g. `const fn = () => {}` matches both @definition.function and @definition.const).
@ -2433,6 +2445,7 @@ const processFileGroup = (
// (ids still use `definitionNode` via `localIdentity`).
const startPosition =
definitionNode &&
!inferredFunctionName &&
(nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor')
? boundCallableStartPosition(
definitionNode,
@ -2638,27 +2651,29 @@ const processFileGroup = (
: undefined;
const qualifiedNameBeforeModScope =
rustImplQualifiedName !== undefined
? rustImplQualifiedName
: // #1991: LOCKSTEP — include Trait so a Ruby mixin module's qualified
// scope id keys the worker-path node, matching the sequential path.
(isClassLikeLabel || isQualifiableScopeLabel(nodeLabel)) &&
provider.classExtractor?.qualifiedNodeId === true &&
qualifiedTypeName !== undefined
? qualifiedTypeName
: nestedCallablePrefix !== undefined && definitionNode
? nestedCallableQualifiedName(nestedCallablePrefix, definitionNode, nodeName)
: enclosingClassInfo
? `${enclosingClassInfo.className}.${nodeName}`
: // A member whose owner is named by the assignment rather than
// by an enclosing container (`Foo.prototype.bar = …`) qualifies
// by that owner, so two constructors in one file that both
// define `bar` stay distinct nodes.
objectLiteralOwnerInfo?.ownerName !== undefined
? `${objectLiteralOwnerInfo.ownerName}.${nodeName}`
: isArrayContainedObjectCallable
? positionQualifiedCallableName(nodeName, startPosition)
: nodeName;
inferredFunction?.identityName === null && definitionNode
? callableOwnQualifiedName(definitionNode, file.path, provider)
: rustImplQualifiedName !== undefined
? rustImplQualifiedName
: // #1991: LOCKSTEP — include Trait so a Ruby mixin module's qualified
// scope id keys the worker-path node, matching the sequential path.
(isClassLikeLabel || isQualifiableScopeLabel(nodeLabel)) &&
provider.classExtractor?.qualifiedNodeId === true &&
qualifiedTypeName !== undefined
? qualifiedTypeName
: nestedCallablePrefix !== undefined && definitionNode
? nestedCallableQualifiedName(nestedCallablePrefix, definitionNode, nodeName)
: enclosingClassInfo
? `${enclosingClassInfo.className}.${nodeName}`
: // A member whose owner is named by the assignment rather than
// by an enclosing container (`Foo.prototype.bar = …`) qualifies
// by that owner, so two constructors in one file that both
// define `bar` stay distinct nodes.
objectLiteralOwnerInfo?.ownerName !== undefined
? `${objectLiteralOwnerInfo.ownerName}.${nodeName}`
: isArrayContainedObjectCallable
? positionQualifiedCallableName(nodeName, startPosition)
: nodeName;
// #2742: qualify by the enclosing `mod` chain, so two same-named items at
// different module depths in one file are DISTINCT nodes. Without this,
@ -3026,10 +3041,7 @@ const processFileGroup = (
name: nodeName,
filePath: file.path,
startLine,
...(shouldObjectOwnerQualifyCallable(nodeLabel) &&
(objectLiteralBindingInfo?.ownerName || isArrayContainedObjectCallable)
? { startColumn }
: {}),
...(shouldObjectOwnerQualifyCallable(nodeLabel) ? { startColumn } : {}),
endLine: definitionNode ? mapRow(definitionNode.endPosition.row) : startLine,
language: language,
isExported,

View file

@ -6215,7 +6215,7 @@ export class LocalBackend {
findings: pageFindings,
totalFindings: totalFindings + interprocTotal,
...(truncated ? { truncated: true } : {}),
note: 'Intra-procedural (TAINTED, statement hops) AND cross-function (TAINT_PATH, function hops, `interprocedural: true`) flows are modeled. Closure/callback, property/field, and implicit flows are NOT modeled; absence of a finding is not proof of safety. Cross-function findings are context-insensitive and may over-attribute among same-named callees. SANITIZES (kill) edges are queryable via cypher.',
note: 'Intra-procedural (TAINTED, statement hops) AND cross-function (TAINT_PATH, function hops, `interprocedural: true`) flows are modeled, including sources read inside inline callbacks and returned handlers. Captured closure-variable, property/field, and implicit flows are NOT modeled; absence of a finding is not proof of safety. Cross-function findings are context-insensitive and may over-attribute among same-named callees. SANITIZES (kill) edges are queryable via cypher.',
};
}

View file

@ -718,7 +718,7 @@ ANCHORED ("target" = file path or symbol/function name): full hop detail for tha
CONTRACT CAVEATS (absent flows are NOT proof of safety):
- Cross-function flows ARE modeled (#2084 M4): a source flowing through helper functions into a sink is found, via summary composition over the call graph (context-insensitive — return/call-site merging is accepted).
- Cross-function matching is by callee NAME (context-insensitive): when one caller invokes two distinct same-named callees, a flow into one over-attributes to both — a cross-function finding does not prove the taint reached every same-named function (sound over-report, never a missed flow).
- Closure/callback flows are invisible in both directions (e.g. arr.forEach(() => sink(y))) — the largest false-negative class.
- Sources read inside inline callbacks and returned handlers can flow through helper functions. Captured closure-variable flows are not modeled (e.g. const y = source(); arr.forEach(() => sink(y))).
- Property/field flows are not tracked (obj.x = taint; sink(obj.y) has no chain).
- Guard-style sanitizers (if (isValid(x))) and implicit/control-dependence flows are not modeled.
- CommonJS aliasing is partially modeled (require('<literal>') joins resolve; dynamic requires do not).

View file

@ -1,4 +1,44 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const PACKAGE_ROOT = fileURLToPath(new URL('../../', import.meta.url));
// Ubuntu coverage run 39060755951: sequential suites taking >=30 seconds,
// rounded up. These committed hints keep every runner's partition identical.
// File size alone misses generated suites: a 100-byte rename wrapper can run
// for 98 seconds, while a 17KB database suite finishes in one second. Refresh
// from CI measurements when suites grow; these are not timing assertions.
const SEQUENTIAL_COVERAGE_SECONDS: Readonly<Record<string, number>> = {
'test/integration/fts-extension-e2e.test.ts': 62,
'test/integration/mcp-index-generation.test.ts': 46,
'test/integration/parse-cache-identity-lifecycle.test.ts': 147,
'test/integration/parse-cache-identity-package.test.ts': 67,
'test/integration/rename-provider-c.test.ts': 52,
'test/integration/rename-provider-cpp.test.ts': 88,
'test/integration/rename-provider-csharp.test.ts': 71,
'test/integration/rename-provider-dart.test.ts': 98,
'test/integration/rename-provider-go.test.ts': 79,
'test/integration/rename-provider-java.test.ts': 36,
'test/integration/rename-provider-javascript.test.ts': 39,
'test/integration/rename-provider-kotlin.test.ts': 93,
'test/integration/rename-provider-objective-c.test.ts': 82,
'test/integration/rename-provider-python.test.ts': 91,
'test/integration/rename-provider-swift.test.ts': 72,
'test/integration/rename-provider-typescript.test.ts': 173,
'test/integration/rename-provider-vue.test.ts': 48,
'test/integration/rename-provider-zig.test.ts': 36,
'test/integration/shared-store-adoption.test.ts': 147,
'test/integration/shared-store-analyze.test.ts': 70,
'test/integration/shared-store-clean.test.ts': 41,
'test/integration/shared-store-clone-optin.test.ts': 103,
'test/integration/shared-store-seed.test.ts': 74,
'test/integration/skills-e2e.test.ts': 71,
'test/integration/skip-fts.test.ts': 44,
'test/unit/incremental-index-extension-dml-gate.test.ts': 67,
'test/unit/incremental-orchestration.test.ts': 488,
'test/unit/incremental-write-integrity.test.ts': 114,
};
/**
* Minimal shape of a vitest `TestSpecification` that {@link specWeight} needs.
@ -15,20 +55,22 @@ export interface WeightableSpec {
*
* The spawn-heavy suites are already isolated into `fileParallelism: false`
* projects (`cli-e2e`, `lbug-db`) — they run one file at a time and dominate
* wall-clock, so they carry the heavy base weight. File size is a cheap
* (stat-only, no parse) secondary signal for finer balance and a stable
* tiebreak. Deterministic: the same repo checkout yields identical weights on
* every shard runner, which is what keeps sharding a complete partition.
* wall-clock. Charge measured slow suites proportionally, with the existing
* 1000-point fallback representing 20 seconds for unmeasured sequential work.
* File size remains a cheap secondary signal. Deterministic: the same checkout
* yields identical weights on every runner, keeping a complete partition.
*/
export function specWeight(spec: WeightableSpec): number {
const sequential = spec.project.config.fileParallelism === false;
const relativePath = path.relative(PACKAGE_ROOT, spec.moduleId).replaceAll('\\', '/');
const seconds = SEQUENTIAL_COVERAGE_SECONDS[relativePath];
let sizeKb = 0;
try {
sizeKb = fs.statSync(spec.moduleId).size / 1024;
} catch {
/* virtual / unresolved module id → treat as size 0 */
}
return (sequential ? 1000 : 1) + sizeKb;
return (sequential ? (seconds ?? 20) * 50 : 1) + sizeKb;
}
/**

View file

@ -0,0 +1,63 @@
import { execSync } from 'child_process';
function runGit(value: string) { return execSync(value); }
function forward(value: string) { return runGit(value); }
export function namedHandler(req: any) {
forward(req.query.command);
}
export function mount(app: any, req: any) {
app.get('/inline', (req: any) => {
forward(req.query.command);
});
app.get('/expression', function (req: any) {
forward(req.query.command);
});
app.get('/async', async (req: any) => {
forward(req.body.command);
});
app.get('/same-line', (req: any) => { forward(req.query.command); }); app.get('/constant', () => { forward('safe'); });
const captured = req.query.command;
app.get('/capture', () => { forward(captured); });
}
export function makeNamed() {
return async function returnedHandler(req: any) {
forward(req.query.command);
};
}
export function makeAnonymous() {
return async (req: any) => {
forward(req.body.command);
};
}
export function makeExpression() {
return function (req: any) {
forward(req.query.command);
};
}
import fs from 'fs';
function readSnippet(value: string) { return fs.readFileSync(value, 'utf8'); }
export function namedSnippet(req: any) { readSnippet(req.query.path); }
export function mountSnippet(app: any) { app.get('/snippet', (req: any) => { readSnippet(req.query.path); }); }
// @ts-expect-error A named function expression binds only inside its handler.
export function outsideReturned(req: any) { returnedHandler(req.query.command); }
export function mountIdentity(app: any) {
app.get('/identity', () => {
function nestedIdentity() { return 1; }
nestedIdentity();
});
}
function compileFilter(expr: string) { return new Function('row', 'return (' + expr + ');'); }
function filterRows(expr: string) { return compileFilter(expr); }
export function makeFilterHandler() {
return async function handleFilter(req: any) {
filterRows(req.body.filter);
};
}

View file

@ -14,6 +14,7 @@
import { describe, it, expect } from 'vitest';
import path from 'path';
import fs from 'fs';
import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../../src/types/pipeline.js';
import { decodeTaintPath } from '../../../src/core/ingestion/taint/path-codec.js';
@ -29,6 +30,130 @@ function taintPaths(result: PipelineResult) {
}
describe('U9 — end-to-end interprocedural taint (--pdg)', () => {
it.each(['ts', 'js'])(
'does not bind or resolve anonymous %s display names as callable identifiers (#3489)',
async (extension) => {
const repoDir = repos.dir();
fs.writeFileSync(
path.join(repoDir, `callbacks.${extension}`),
`function fn_1_13() { return 1; }
app.get('/', () => { fn_1_13(); });
app.get('/', () => { fn_2_13(); });
function outside() { fn_2_13(); }
function make() { return function handler() { handler(); app.get('/', () => handler()); }; }
function outsideNamed() { handler(); }
const bound = () => { bound(); };
function callBound() { bound(); }
`,
);
fs.writeFileSync(
path.join(repoDir, `other.${extension}`),
'function other() { fn_2_13(); handler(); }',
);
const result = await runPipelineFromRepo(repoDir, () => {}, { pdg: true });
const nodes = [...result.graph.iterNodes()];
const calls = [...result.graph.iterRelationships()].filter((r) => r.type === 'CALLS');
const named = result.graph.getNode(`Function:callbacks.${extension}:fn_1_13`)!;
const anonymous = result.graph.getNode(`Function:callbacks.${extension}:fn@1:13`)!;
const unbound = nodes.find((n) => n.properties.name === 'fn_2_13')!;
expect(named).toBeDefined();
expect(anonymous).toBeDefined();
expect(unbound).toBeDefined();
expect(calls.some((r) => r.sourceId === anonymous.id && r.targetId === named.id)).toBe(true);
expect(calls.some((r) => r.targetId === anonymous.id || r.targetId === unbound.id)).toBe(
false,
);
const handler = nodes.find((n) => n.properties.name === 'handler')!;
const outside = nodes.filter((n) => ['outsideNamed', 'other'].includes(n.properties.name!));
expect(calls.some((r) => r.sourceId === handler.id && r.targetId === handler.id)).toBe(true);
expect(calls.some((r) => r.targetId === handler.id && r.sourceId !== handler.id)).toBe(true);
expect(
calls.some((r) => r.targetId === handler.id && outside.some((n) => n.id === r.sourceId)),
).toBe(false);
const bound = nodes.find((n) => n.properties.name === 'bound' && n.label === 'Function')!;
const caller = nodes.find((n) => n.properties.name === 'callBound')!;
expect(calls.some((r) => r.sourceId === bound.id && r.targetId === bound.id)).toBe(true);
expect(calls.some((r) => r.sourceId === caller.id && r.targetId === bound.id)).toBe(true);
},
);
it.each(['ts', 'js'])(
'keeps %s inline and returned handlers as distinct callers and taint summary owners (#3489)',
async (extension) => {
const fixture = path.join(__dirname, 'fixtures', 'inline-taint-repo');
const repoDir = repos.fromFixture(fixture);
if (extension === 'js') {
const tsFile = path.join(repoDir, 'routes.ts');
// The fixture uses only these two annotations; JS exercises its own grammar.
fs.writeFileSync(
path.join(repoDir, 'routes.js'),
fs.readFileSync(tsFile, 'utf8').replaceAll(': any', '').replaceAll(': string', ''),
);
fs.unlinkSync(tsFile);
}
const result = await runPipelineFromRepo(repoDir, () => {}, { pdg: true });
const nodes = [...result.graph.iterNodes()];
const findings = taintPaths(result).filter(
(p) => result.graph.getNode(p.targetId)?.properties.name === 'runGit',
);
const sourceLines = findings
.map((p) => result.graph.getNode(p.sourceId)!.properties.startLine)
.sort((a, b) => Number(a) - Number(b));
expect(sourceLines).toEqual([5, 10, 13, 16, 19, 25, 31, 37]);
expect(new Set(findings.map((p) => p.sourceId)).size).toBe(8);
for (const finding of findings) {
const source = result.graph.getNode(finding.sourceId)!;
expect(
[...result.graph.iterRelationships()].some(
(r) =>
r.type === 'CALLS' &&
r.sourceId === source.id &&
result.graph.getNode(r.targetId)?.properties.name === 'forward',
),
).toBe(true);
}
const mount = nodes.find((n) => n.label === 'Function' && n.properties.name === 'mount')!;
expect(
[...result.graph.iterRelationships()].filter(
(r) =>
r.type === 'CALLS' &&
r.sourceId === mount.id &&
result.graph.getNode(r.targetId)?.properties.name === 'forward',
),
).toEqual([]);
const sameLine = nodes.filter((n) => n.label === 'Function' && n.properties.startLine === 19);
expect(sameLine).toHaveLength(2);
expect(new Set(sameLine.map((n) => n.id)).size).toBe(2);
expect(nodes.find((n) => n.properties.name === 'returnedHandler')).toBeDefined();
expect(nodes.find((n) => n.properties.name === 'nestedIdentity')?.id).toBe(
`Function:routes.${extension}:mountIdentity.fn@50:23.nestedIdentity@51:4`,
);
const outside = nodes.find((n) => n.properties.name === 'outsideReturned')!;
expect(
[...result.graph.iterRelationships()].some(
(r) =>
r.type === 'CALLS' &&
r.sourceId === outside.id &&
result.graph.getNode(r.targetId)?.properties.name === 'returnedHandler',
),
).toBe(false);
const snippets = taintPaths(result).filter(
(p) => result.graph.getNode(p.targetId)?.properties.name === 'readSnippet',
);
expect(snippets).toHaveLength(2);
for (const p of snippets) {
const decoded = decodeTaintPath(p.reason);
expect(decoded.ok && decoded.kind).toBe('path-traversal');
expect(result.graph.getNode(p.sourceId)?.properties.name).not.toBe('mountSnippet');
}
const filters = taintPaths(result).filter(
(p) => result.graph.getNode(p.targetId)?.properties.name === 'compileFilter',
);
expect(filters).toHaveLength(1);
expect(result.graph.getNode(filters[0].sourceId)?.properties.name).toBe('handleFilter');
const decodedFilter = decodeTaintPath(filters[0].reason);
expect(decodedFilter.ok && decodedFilter.kind).toBe('code-injection');
},
);
it('with --pdg: composes a cross-file source→sink into a TAINT_PATH edge', async () => {
const result = await runPipelineFromRepo(freshRepo(), () => {}, { pdg: true });
const paths = taintPaths(result);

View file

@ -168,7 +168,7 @@ describeIfWorkerBuilt('a function-local callable does not collide with a file-le
// gives it an environment record, so its `save` is a genuinely different
// binding from the file-level one. The anonymous link becomes `fn@1:9` —
// unique by construction, since two functions cannot start at one offset.
const { calls } = await analyze(
const { calls, nodes } = await analyze(
'anon.ts',
[
'export function outer() {',
@ -181,7 +181,12 @@ describeIfWorkerBuilt('a function-local callable does not collide with a file-le
].join('\n'),
);
expect(calls).toEqual(['Function:anon.ts:outer -> Function:anon.ts:outer.fn@1:9.save@2:4']);
expect(nodes).toEqual(['Function:anon.ts:outer.fn@1:9.save@2:4', 'Function:anon.ts:save']);
// The returned callable owns its call; it must not borrow `outer`'s ID.
// The existing nested and file-level `save` IDs remain unchanged.
expect(calls).toEqual([
'Function:anon.ts:outer.fn@1:9 -> Function:anon.ts:outer.fn@1:9.save@2:4',
]);
});
it('sibling BLOCKS hold different bindings, and each call reaches its own', async () => {

View file

@ -429,6 +429,80 @@ withTestLbugDB(
const INTERPROC_FIXTURE = path.join(__dirname, 'cfg', 'fixtures', 'interproc-repo');
// Real pipeline → COPY persistence → a cold reader → public explain dispatch.
withTestLbugDB(
'taint-inline-handlers',
(handle) => {
it('reports inline and factory handlers, without attributing callbacks to their factories (#3489)', async () => {
const backend = (handle as typeof handle & { _backend: LocalBackend })._backend;
const first = await backend.callTool('explain', { target: 'routes.ts', limit: 100 });
const second = await backend.callTool('explain', { target: 'routes.ts', limit: 100 });
expect(second).toEqual(first);
const findings = first.findings.filter((f: any) => f.interprocedural);
expect(findings).toHaveLength(11);
for (const finding of findings) {
expect(finding.pathIncomplete).toBeUndefined();
expect(finding.hops.length).toBeGreaterThanOrEqual(2);
expect(finding.hops[0].function).toBe(finding.source.function);
expect(finding.hops.at(-1).function).toBe(finding.sink.function);
}
expect(
findings.every(
(f: any) =>
(f.sinkKind === 'command-injection' && f.sink.function === 'runGit') ||
(f.sinkKind === 'path-traversal' && f.sink.function === 'readSnippet') ||
(f.sinkKind === 'code-injection' && f.sink.function === 'compileFilter'),
),
).toBe(true);
expect(findings.map((f: any) => f.source.function)).toContain('returnedHandler');
expect(
findings.some((f: any) =>
[
'mount',
'makeNamed',
'makeAnonymous',
'makeExpression',
'makeFilterHandler',
'mountSnippet',
'mountIdentity',
].includes(f.source.function),
),
).toBe(false);
expect(new Set(findings.map((f: any) => f.source.function)).size).toBe(11);
});
},
{
poolAdapter: true,
beforeFTS: async (dbPath) => {
const fixture = path.join(__dirname, 'cfg', 'fixtures', 'inline-taint-repo');
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-inline-taint-'));
try {
fs.cpSync(fixture, repoDir, { recursive: true });
const result = await runPipelineFromRepo(repoDir, () => {}, { pdg: true });
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.loadGraphToLbug(result.graph, repoDir, path.dirname(dbPath));
} finally {
fs.rmSync(repoDir, { recursive: true, force: true });
}
},
afterSetup: async (handle) => {
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'inline-taint-repo',
path: '/inline-taint/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'inline001',
stats: { files: 1, nodes: 1, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as typeof handle & { _backend: LocalBackend })._backend = backend;
},
},
);
withTestLbugDB(
'taint-explain-interproc',
(handle) => {

View file

@ -630,19 +630,17 @@ describe('issue #1166 follow-up — HOC-wrapped variable declarations', () => {
expect(findCall(sites, 'doB')?.attributedTo).toBe('handleSubmit');
});
it('does NOT name a bare statement-level HOC call (unbound result)', () => {
it('keeps a bare HOC call at module level and attributes its callback by position', () => {
// `useCallback(() => doStuff(), [])` at statement level (result thrown
// away). The walk climbs `arguments → call_expression → expression_statement`,
// which is NOT `variable_declarator`, so the branch returns null and the
// arrow stays anonymous. Calls inside attribute to no enclosing function.
// away) supplies no binding name. The callback has a positional identity
// so its calls belong to it without borrowing an authored name (#3489).
const sites = collectCallAttributions(`
useCallback(() => {
doSomething();
}, []);
`);
// The `useCallback` call itself is module-level → null. The `doSomething`
// call is inside an unnamed arrow → null. Both must NOT borrow a name.
expect(findCall(sites, 'doSomething')?.attributedTo).toBeNull();
// The callback's exact position stays distinct from the module-level call.
expect(findCall(sites, 'doSomething')?.attributedTo).toBe('fn_1_18');
expect(findCall(sites, 'useCallback')?.attributedTo).toBeNull();
});

View file

@ -21,7 +21,11 @@ import {
weightOf,
WINDOWS_WEIGHTS_SEC,
} from '../../scripts/cross-platform-shard.js';
import { ALL_CROSS_PLATFORM, WORKER_THREADS } from '../../scripts/cross-platform-tests.js';
import {
ALL_CROSS_PLATFORM,
LBUG_NATIVE,
WORKER_THREADS,
} from '../../scripts/cross-platform-tests.js';
// Replay the actual CI partition, including shard-count changes as the suite grows.
const workflow = readFileSync(
@ -35,6 +39,19 @@ const allShards = (files: readonly string[], total: number): readonly (readonly
Array.from({ length: total }, (_unused, i) => shardFiles(files, i + 1, total));
describe('cross-platform shard partition', () => {
it('selects the native inline-handler proofs exactly once across the CI shards', () => {
const proofs = [
[LBUG_NATIVE, 'test/integration/taint-explain.test.ts'],
[WORKER_THREADS, 'test/integration/cfg/interproc-taint.test.ts'],
[WORKER_THREADS, 'test/integration/function-local-identity.test.ts'],
] as const;
const selected = allShards(ALL_CROSS_PLATFORM, SHARD_TOTAL).flat();
for (const [category, file] of proofs) {
expect(category.filter((candidate) => candidate === file)).toEqual([file]);
expect(selected.filter((candidate) => candidate === file)).toEqual([file]);
}
});
it('selects both real-worker local-import proofs exactly once across the CI shards', () => {
const proofs = [
'test/integration/local-import-worker-parity.test.ts',

View file

@ -19,6 +19,9 @@ import { dartIsGlobalNameFallbackPlausible } from '../../../src/core/ingestion/l
import { rustIsGlobalNameFallbackPlausible } from '../../../src/core/ingestion/languages/rust/name-fallback-visibility.js';
import { swiftIsGlobalNameFallbackPlausible } from '../../../src/core/ingestion/languages/swift/name-fallback-visibility.js';
import { rubyIsGlobalNameFallbackPlausible } from '../../../src/core/ingestion/languages/ruby/name-fallback-visibility.js';
import { isJsSelfBoundCallableFallbackPlausible } from '../../../src/core/ingestion/languages/javascript/callable-visibility.js';
import { javascriptProvider } from '../../../src/core/ingestion/languages/typescript.js';
import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js';
import {
directoryOf,
modulePathReaches,
@ -33,6 +36,53 @@ const namedImport = (targetRaw: string, localName = 'x'): ParsedImport => ({
targetRaw,
});
describe('JavaScript: named function-expression visibility (#3489)', () => {
it('allows recursive/descendant calls and refuses callers outside the expression scope', () => {
const parsed = extractParsedFile(
javascriptProvider,
`function make() {
return function handler(req) {
handler(req);
(() => handler(req))();
};
}
function outside(req) { handler(req); }`,
'handlers.js',
)!;
const candidate = parsed.localDefs.find((def) => def.qualifiedName === 'handler')!;
const sites = parsed.referenceSites.filter((site) => site.name === 'handler');
expect(sites).toHaveLength(3);
expect(
sites.map((site) =>
isJsSelfBoundCallableFallbackPlausible({
callerParsed: parsed,
candidate,
parsedFileOf: () => parsed,
site,
}),
),
).toEqual([true, true, false]);
const outside = sites.at(-1)!;
const topLevel = parsed.localDefs.find((def) => def.qualifiedName === 'make')!;
expect(
isJsSelfBoundCallableFallbackPlausible({
callerParsed: parsed,
candidate: topLevel,
parsedFileOf: () => parsed,
site: outside,
}),
).toBe(true);
expect(
isJsSelfBoundCallableFallbackPlausible({
callerParsed: parsed,
candidate,
parsedFileOf: () => undefined,
site: outside,
}),
).toBe(true);
});
});
const mkCaller = (
filePath: string,
imports: readonly ParsedImport[] = [],

View file

@ -213,6 +213,36 @@ describe('Pass 1: scope tree', () => {
// ─── §Pass 2: declarations + local bindings ────────────────────────────────
describe('Pass 2: declarations + local bindings', () => {
it.each([undefined, 'visible', ''])(
'retains graph identity with binding-name override %j',
(bindingName) => {
const extras =
bindingName === undefined
? {}
: {
'@declaration.binding-name': cap(
'@declaration.binding-name',
5,
0,
10,
0,
bindingName,
),
};
const result = extract(
[scopeMatch('module', 1, 0, 100, 0), declMatch('function', 'display', 5, 0, 10, 0, extras)],
'a.ts',
mockProvider(),
);
const module = result.scopes[0]!;
expect(result.localDefs).toHaveLength(1);
expect(module.ownedDefs).toEqual(result.localDefs);
expect(result.localDefs[0]!.nodeId).toBe('def:a.ts#5:0:Function:display');
expect([...module.bindings.keys()]).toEqual(
bindingName === '' ? [] : [bindingName ?? 'display'],
);
},
);
it('routes one multi-topic match through both scope and declaration passes', () => {
const result = extract(
[

View file

@ -5,6 +5,11 @@
* CI runs — so that is asserted directly, plus balance and determinism.
*/
import { describe, it, expect } from 'vitest';
import { globSync } from 'glob';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import vitestConfig from '../../vitest.config.js';
import { assignShards, specWeight } from '../helpers/shard-balance.js';
const key = (s: { id: string; w: number }) => s.id;
@ -12,6 +17,55 @@ const weight = (s: { id: string; w: number }) => s.w;
const make = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `f${i}`, w: (i % 5) + 1 }));
describe('assignShards', () => {
it('does not pack the slow database suites onto the same coverage shard', () => {
const root = fileURLToPath(new URL('../../', import.meta.url));
const specs = (vitestConfig.test?.projects ?? []).flatMap((project) => {
if (typeof project !== 'object' || !('test' in project) || !project.test) {
throw new Error('Expected inline Vitest project configuration');
}
const config = project.test;
return globSync(config.include ?? [], {
cwd: root,
absolute: true,
nodir: true,
ignore: config.exclude,
}).map((moduleId) => ({ moduleId: path.resolve(moduleId), project: { config } }));
});
const workflow = readFileSync(path.join(root, '../.github/workflows/ci-tests.yml'), 'utf8');
const count = Number(workflow.match(/^\s+COV_TOTAL=(\d+)\b/m)?.[1]);
expect(count).toBeGreaterThan(0);
const bins = assignShards(specs, count, specWeight, (spec) => spec.moduleId);
const files = bins.flat().map((spec) => spec.moduleId);
expect(files.slice().sort()).toEqual(specs.map((spec) => spec.moduleId).sort());
expect(new Set(files).size).toBe(files.length);
// Observed sequential time in run 39060755951, independent of scheduling
// weights. The old size heuristic put all seven on shard 1 (1027 seconds),
// before its remaining database tests and 149 parallel test files.
const observedSeconds: Record<string, number> = {
'test/unit/incremental-orchestration.test.ts': 487.202,
'test/unit/incremental-write-integrity.test.ts': 113.583,
'test/integration/shared-store-clone-optin.test.ts': 102.187,
'test/integration/rename-provider-dart.test.ts': 97.973,
'test/integration/rename-provider-objective-c.test.ts': 81.122,
'test/integration/rename-provider-go.test.ts': 78.185,
'test/integration/parse-cache-identity-package.test.ts': 66.863,
};
for (const file of Object.keys(observedSeconds)) {
expect(files).toContain(path.join(root, file));
}
const loads = bins.map((bin) =>
bin.reduce(
(sum, spec) =>
sum + (observedSeconds[path.relative(root, spec.moduleId).replaceAll('\\', '/')] ?? 0),
0,
),
);
// Leave room for the rest of the suite and coverage in the unchanged
// 25-minute job. This is a timing-profile replay, not a wall-clock test.
expect(Math.max(...loads)).toBeLessThan(10 * 60);
});
it('assigns every spec exactly once across all shards (disjoint + complete)', () => {
const specs = make(37);
const shards = [1, 2, 3].map((i) => assignShards(specs, 3, weight, key)[i - 1]);

View file

@ -6,11 +6,14 @@
// constructor never receives a CALL_SUMMARY edge (FUNCTIONISH_LABELS).
import { describe, it, expect } from 'vitest';
import { cfgOf } from '../../helpers/ts-cfg-harness.js';
import { cfgOf, cfgsOf } from '../../helpers/ts-cfg-harness.js';
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
import { computeReachingDefs } from '../../../src/core/ingestion/cfg/reaching-defs.js';
import { harvestCallSummary } from '../../../src/core/ingestion/taint/call-summary-harvest.js';
import { buildFunctionNodeIndex } from '../../../src/core/ingestion/taint/summary-harvest-driver.js';
import {
buildFunctionNodeIndex,
harvestFileCallSummaries,
} from '../../../src/core/ingestion/taint/summary-harvest-driver.js';
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
describe('CALL_SUMMARY language coverage (U10)', () => {
@ -29,6 +32,32 @@ describe('CALL_SUMMARY language coverage (U10)', () => {
});
describe('buildFunctionNodeIndex — Constructor exclusion (U10)', () => {
it('joins same-line functions by column and never aliases a missing callable to its neighbor (#3489)', () => {
const cfgs = cfgsOf(
'function first(v: string) { return v; } function second(v: string) { return v; }',
);
const graph = createKnowledgeGraph();
for (const [i, cfg] of cfgs.entries()) {
graph.addNode({
id: `Function:${i}`,
label: 'Function',
properties: {
name: `fn${i}`,
filePath: cfg.filePath,
startLine: cfg.functionStartLine - 1,
startColumn: cfg.functionStartColumn,
},
});
}
const all = harvestFileCallSummaries(buildFunctionNodeIndex(graph), cfgs);
expect(all.unresolved).toBe(0);
expect(all.summaries.map((s) => s.fnId).sort()).toEqual(['Function:0', 'Function:1']);
const partial = createKnowledgeGraph();
partial.addNode(graph.getNode('Function:0')!);
const missing = harvestFileCallSummaries(buildFunctionNodeIndex(partial), cfgs);
expect(missing.unresolved).toBe(1);
expect(missing.summaries.map((s) => s.fnId)).toEqual(['Function:0']);
});
it('indexes Function and Method nodes but NOT Constructor (no return-value ascent)', () => {
const graph = createKnowledgeGraph();
graph.addNode({

View file

@ -484,7 +484,10 @@ describe('GITNEXUS_TOOLS', () => {
expect(d).toContain('--pdg');
expect(d).toContain('intra-procedural');
// The named blind-spot classes (plan KTD10) must reach the consumer.
expect(d.toLowerCase()).toContain('closure/callback');
expect(d.toLowerCase()).toContain('captured closure-variable flows are not modeled');
expect(d.toLowerCase()).toContain(
'sources read inside inline callbacks and returned handlers can flow through helper functions',
);
expect(d.toLowerCase()).toContain('property/field');
expect(d.toLowerCase()).toContain('guard-style');
expect(d.toLowerCase()).toContain('cross-function');