diff --git a/.github/e2e/csharp/expect b/.github/e2e/csharp/expect new file mode 100644 index 000000000..88cf48ce5 --- /dev/null +++ b/.github/e2e/csharp/expect @@ -0,0 +1 @@ +ENTRY=Entry HELPER=Helper LEAF=Leaf TEST=ServiceTests LEAF_FILE=src/App/Service.cs diff --git a/.github/e2e/csharp/src/App/Service.cs b/.github/e2e/csharp/src/App/Service.cs new file mode 100644 index 000000000..a52344870 --- /dev/null +++ b/.github/e2e/csharp/src/App/Service.cs @@ -0,0 +1,11 @@ +namespace App +{ + public class Service + { + public int Entry() { return Helper() + 1; } + + int Helper() { return Leaf() * 2; } + + int Leaf() { return 41; } + } +} diff --git a/.github/e2e/csharp/tests/App.Tests/ServiceTests.cs b/.github/e2e/csharp/tests/App.Tests/ServiceTests.cs new file mode 100644 index 000000000..483883fff --- /dev/null +++ b/.github/e2e/csharp/tests/App.Tests/ServiceTests.cs @@ -0,0 +1,10 @@ +using Xunit; + +namespace App.Tests +{ + public class ServiceTests + { + [Fact] + public void EntryWorks() { new App.Service().Entry(); } + } +} diff --git a/.github/e2e/java/expect b/.github/e2e/java/expect new file mode 100644 index 000000000..51fac2eb8 --- /dev/null +++ b/.github/e2e/java/expect @@ -0,0 +1 @@ +ENTRY=entry HELPER=helper LEAF=leaf TEST=ServiceTest LEAF_FILE=src/main/java/app/Service.java diff --git a/.github/e2e/java/src/main/java/app/Service.java b/.github/e2e/java/src/main/java/app/Service.java new file mode 100644 index 000000000..13576f359 --- /dev/null +++ b/.github/e2e/java/src/main/java/app/Service.java @@ -0,0 +1,9 @@ +package app; + +public class Service { + public int entry() { return helper() + 1; } + + int helper() { return leaf() * 2; } + + int leaf() { return 41; } +} diff --git a/.github/e2e/java/src/test/java/app/ServiceTest.java b/.github/e2e/java/src/test/java/app/ServiceTest.java new file mode 100644 index 000000000..039f3a91f --- /dev/null +++ b/.github/e2e/java/src/test/java/app/ServiceTest.java @@ -0,0 +1,8 @@ +package app; + +import org.junit.jupiter.api.Test; + +class ServiceTest { + @Test + void entryWorks() { new Service().entry(); } +} diff --git a/.github/e2e/javascript/expect b/.github/e2e/javascript/expect new file mode 100644 index 000000000..116ddc88d --- /dev/null +++ b/.github/e2e/javascript/expect @@ -0,0 +1 @@ +ENTRY=entry HELPER=helper LEAF=leaf TEST=service.test LEAF_FILE=src/service.js diff --git a/.github/e2e/javascript/src/service.js b/.github/e2e/javascript/src/service.js new file mode 100644 index 000000000..1c4555319 --- /dev/null +++ b/.github/e2e/javascript/src/service.js @@ -0,0 +1,5 @@ +export function leaf() { return 41; } + +export function helper() { return leaf() * 2; } + +export function entry() { return helper() + 1; } diff --git a/.github/e2e/javascript/test/service.test.js b/.github/e2e/javascript/test/service.test.js new file mode 100644 index 000000000..1d7a3ad36 --- /dev/null +++ b/.github/e2e/javascript/test/service.test.js @@ -0,0 +1,3 @@ +import { entry } from '../src/service'; + +test('entry', () => { entry(); }); diff --git a/.github/e2e/python/app/__init__.py b/.github/e2e/python/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/.github/e2e/python/app/service.py b/.github/e2e/python/app/service.py new file mode 100644 index 000000000..fd2228d9e --- /dev/null +++ b/.github/e2e/python/app/service.py @@ -0,0 +1,10 @@ +def leaf(): + return 41 + + +def helper(): + return leaf() * 2 + + +def entry(): + return helper() + 1 diff --git a/.github/e2e/python/expect b/.github/e2e/python/expect new file mode 100644 index 000000000..0b5c27a40 --- /dev/null +++ b/.github/e2e/python/expect @@ -0,0 +1 @@ +ENTRY=entry HELPER=helper LEAF=leaf TEST=test_service LEAF_FILE=app/service.py diff --git a/.github/e2e/python/tests/test_service.py b/.github/e2e/python/tests/test_service.py new file mode 100644 index 000000000..d62a3216d --- /dev/null +++ b/.github/e2e/python/tests/test_service.py @@ -0,0 +1,5 @@ +from app.service import entry + + +def test_entry(): + assert entry() == 83 diff --git a/.github/e2e/typescript/expect b/.github/e2e/typescript/expect new file mode 100644 index 000000000..ea656b85f --- /dev/null +++ b/.github/e2e/typescript/expect @@ -0,0 +1 @@ +ENTRY=entry HELPER=helper LEAF=leaf TEST=service.test LEAF_FILE=src/service.ts diff --git a/.github/e2e/typescript/src/service.ts b/.github/e2e/typescript/src/service.ts new file mode 100644 index 000000000..c9a8e0192 --- /dev/null +++ b/.github/e2e/typescript/src/service.ts @@ -0,0 +1,5 @@ +export function leaf(): number { return 41; } + +export function helper(): number { return leaf() * 2; } + +export function entry(): number { return helper() + 1; } diff --git a/.github/e2e/typescript/test/service.test.ts b/.github/e2e/typescript/test/service.test.ts new file mode 100644 index 000000000..1d7a3ad36 --- /dev/null +++ b/.github/e2e/typescript/test/service.test.ts @@ -0,0 +1,3 @@ +import { entry } from '../src/service'; + +test('entry', () => { entry(); }); diff --git a/.github/scripts/e2e-install.sh b/.github/scripts/e2e-install.sh index 5205c0ab9..9c732f03e 100755 --- a/.github/scripts/e2e-install.sh +++ b/.github/scripts/e2e-install.sh @@ -8,7 +8,13 @@ # + /ENGINE_ID). This packs @axiomcode/code-graph and that platform's engine # package exactly as publish-npm.yml would, installs both into an empty project, and # runs `axiomcode` on with souffle NOT on PATH. It passes only if the -# installed package found its engine package, used it, and wrote a graph with edges. +# installed package found its engine package, used it, and wrote a graph with edges — +# and then, on .github/e2e/, every query verb returned the answer that tiny +# project makes true (e2e-queries.sh), from the query programs the engine package ships. +# +# CODEGRAPH_TGZ, when set, is an already packed @axiomcode/code-graph to install instead of +# packing this checkout: the tarball is platform-independent, so ci.yml packs it once and every +# platform installs the same file a user would download. # # The test suites cannot see any of this: they run from the checkout, where a missing # `files` entry, a broken bin, or an engine package the driver does not find all go @@ -29,8 +35,12 @@ bash "$root/packaging/assemble-engine-package.sh" "$platform" "$version" "$engin || fail "the engine package did not assemble" mkdir -p "$W/tgz" ( cd "$W/engine" && npm pack --silent --pack-destination "$W/tgz" >/dev/null ) || fail "npm pack of the engine package failed" -# --ignore-scripts: the tree is already built; the tarball must carry what the build produced -( cd "$root" && npm pack --silent --ignore-scripts --pack-destination "$W/tgz" >/dev/null ) || fail "npm pack of code-graph failed" +if [ -n "${CODEGRAPH_TGZ:-}" ]; then + cp "$CODEGRAPH_TGZ" "$W/tgz/" || fail "no code-graph tarball at $CODEGRAPH_TGZ" +else + # --ignore-scripts: the tree is already built; the tarball must carry what the build produced + ( cd "$root" && npm pack --silent --ignore-scripts --pack-destination "$W/tgz" >/dev/null ) || fail "npm pack of code-graph failed" +fi ls -1 "$W/tgz" | sed 's/^/ /' echo "── installing into an empty project" @@ -58,4 +68,11 @@ edges="$(node -e ' console.log(db.prepare("SELECT COUNT(*) AS n FROM call_edges").get().n); ' "$db" 2>/dev/null)" [ -n "$edges" ] && [ "$edges" -gt 0 ] || fail "the graph has no call edges (${edges:-unreadable})" -echo "e2e: ok — installed from the tarballs, used the packaged $platform engine, $edges call edges" + +echo "── every query verb on the $lang fixture, answers checked, no souffle" +PATH="$SANDBOX_PATH" bash "$root/.github/scripts/e2e-queries.sh" "$bin" "$root/.github/e2e/$lang" "$W/q" || fail "a query verb answered wrongly (above)" +# every program from the engine package: not compiled here (no souffle), not left to the interpreter +ready="$(grep 'datalog rules ready' "$W/q/.index.log" || true)" +echo "$ready" | grep -Eq 'ready: ([0-9]+)/\1 compiled' && ! echo "$ready" | grep -Eq '=(cached|interpreter)' \ + || fail "the query programs did not all come from the engine package: $ready" +echo "e2e: ok — installed from the tarballs, used the packaged $platform engine, $edges call edges; every query verb answered correctly from the package" diff --git a/.github/scripts/e2e-queries.sh b/.github/scripts/e2e-queries.sh new file mode 100755 index 000000000..14edbb9d9 --- /dev/null +++ b/.github/scripts/e2e-queries.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# Every query verb returns the RIGHT answer on a tiny project whose answers are known. +# +# e2e-queries.sh +# +# is .github/e2e//: entry() calls helper() calls leaf(), one test +# calls entry(), and `expect` names the four in that language's spelling. The project is +# copied to , committed, indexed, and asked: +# +# impact LEAF lists HELPER, the method that calls it +# path ENTRY LEAF reached, through HELPER +# path ENTRY LEAF the same from the shipped Datalog programs (AXIOMCODE_DATALOG=1) +# path … --every at least one route listed, both backends +# context LEAF names LEAF +# leaf's 41 becomes 42, then +# changed names LEAF +# test-impact selects TEST +# +# Running is not passing: each answer has to contain what the project makes true, and no +# answer may carry a Python traceback. +# ───────────────────────────────────────────────────────────────────────────── +set -uo pipefail +bin="${1:?usage: e2e-queries.sh }"; fx="${2:?fixture-dir}"; R="${3:?work-dir}" +fail() { echo "::error::e2e queries ($(basename "$fx")): $*"; exit 1; } +# shellcheck disable=SC2046 +eval "$(cat "$fx/expect")" +rm -rf "$R"; mkdir -p "$R"; cp -R "$fx"/. "$R"/; rm -f "$R/expect" +git -C "$R" init -q && git -C "$R" add -A && git -C "$R" -c user.email=e2e@axiomcode -c user.name=e2e commit -qm base \ + || fail "could not commit the fixture" + +"$bin" index "$R" > "$R/.index.log" 2>&1 || { tail -20 "$R/.index.log"; fail "axiomcode index exited non-zero"; } +grep 'datalog rules ready' "$R/.index.log" | sed 's/^/ /' + +run() { # : the answer lands in $R/.q.log, a non-zero exit or a traceback fails + local env="$1"; shift; LABEL="$* ${env:+($env)}" + ( cd "$R" && env $env "$bin" "$@" ) > "$R/.q.log" 2>&1; local rc=$? + if [ "$rc" -ne 0 ] || grep -q 'Traceback (most recent call last)' "$R/.q.log"; then + sed 's/^/ /' "$R/.q.log" | head -25; fail "$LABEL: rc=$rc" + fi +} +must() { # the last answer has to match, and why + grep -Eq -- "$1" "$R/.q.log" || { sed 's/^/ /' "$R/.q.log" | head -25; fail "$LABEL: expected /$1/ ($2)"; } + echo " ok $LABEL — $2" +} +DL="AXIOMCODE_DATALOG=1" +run "" impact "$LEAF"; must "\[resolved\] ([A-Za-z_.]*\.)?$HELPER .*calls it" "$HELPER is a resolved caller" +for e in "" "$DL"; do + run "$e" path "$ENTRY" "$LEAF"; must "reached" "a chain exists" + must "→ .*([A-Za-z_.]*\.)?$HELPER " "the chain goes through $HELPER" + run "$e" path "$ENTRY" "$LEAF" --every; must "[0-9]+ hop\(s\): .*$HELPER.* → .*$LEAF" "a route through $HELPER to $LEAF is listed" +done +run "" context "$LEAF"; must "^ +([A-Za-z_.]*\.)?$LEAF +" "$LEAF is an entry point" + +# a real edit to leaf's body: what changed, and which tests have to run for it +sed 's/41/42/' "$R/$LEAF_FILE" > "$R/.edit" && mv "$R/.edit" "$R/$LEAF_FILE" +git -C "$R" diff --quiet && fail "the edit to $LEAF_FILE changed nothing" +run "" changed; must "([A-Za-z_.]*\.)?$LEAF\b" "$LEAF is reported changed" +run "" test-impact; must "^tests to run: [1-9]" "a test reaches the change through the graph" + must "^ +\S*$TEST\S* +\(" "$TEST is the test selected" +echo "e2e queries: every verb answered correctly for $(basename "$fx")" diff --git a/.github/scripts/query-smoke.sh b/.github/scripts/query-smoke.sh new file mode 100755 index 000000000..76a358c1c --- /dev/null +++ b/.github/scripts/query-smoke.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# The query programs answer the same compiled as interpreted, on every platform. +# +# query-smoke.sh expect (generate job, with Soufflé) +# query-smoke.sh check (each platform, no Soufflé) +# +# `expect` writes a small call graph as facts (fixture/) and what the Soufflé +# interpreter derives from it for every .dl (expected//). `check` runs +# each platform's axiomcode-query- on the same facts and compares, relation +# by relation. Starting on empty inputs would pass a binary that answers nothing; +# this fails one whose answers differ, and fails if the fixture reaches nothing. +# ───────────────────────────────────────────────────────────────────────────── +set -euo pipefail +mode="${1:?usage: query-smoke.sh expect|check [bin-dir]}"; G="${2:?gen-queries-dir}" +EXE=""; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) EXE=".exe";; esac +inputs() { sed -n 's/.*\.input \([A-Za-z0-9_]*\).*/\1/p' "$1"; } +norm() { for f in "$1"/*.csv; do [ -e "$f" ] || continue; printf '== %s\n' "$(basename "$f")"; tr -d '\r' < "$f" | LC_ALL=C sort; done; } + +case "$mode" in + expect) + F="$G/fixture"; rm -rf "$F"; mkdir -p "$F" + for dl in "$G"/*.dl; do inputs "$dl" | while read -r r; do : > "$F/$r.facts"; done; done + # t -> a -> b -> c, and x -> c by name only; one path query from t to c + printf 'm:t\tm:a\tcall\nm:a\tm:b\tcall\nm:b\tm:c\tcall\n' > "$F/edge.facts" + printf 't\tm:t\na\tm:a\nb\tm:b\nc\tm:c\nx\tm:x\n' > "$F/named.facts" + printf 'm:x\tc\n' > "$F/byname.facts" + printf 'q\tm:t\n' > "$F/src.facts" + printf 'q\tm:c\n' > "$F/dst.facts" + # impact: what changing c reaches, through the same chain's resolved call sites + printf 'q\tmethod\tm:c\tc\n' > "$F/target.facts" + printf 'm:t\tm:a\tclient\tT.java\t3\nm:a\tm:b\tclient\tA.java\t4\nm:b\tm:c\tclient\tB.java\t5\n' > "$F/calls.facts" + printf 'm:a\tmethod\nm:b\tmethod\nm:c\tmethod\nm:t\tmethod\n' > "$F/kind.facts" + for dl in "$G"/*.dl; do + q="$(basename "$dl" .dl)"; E="$G/expected/$q"; rm -rf "$E"; mkdir -p "$E" + souffle -F "$F" -D "$E" "$dl" + n="$(cat "$E"/*.csv 2>/dev/null | wc -l | tr -d ' ')" + echo "query $q: interpreter derived $n rows" + done + for q in impact path; do + [ "$(cat "$G"/expected/$q/*.csv | wc -l)" -gt 0 ] || { echo "::error::the fixture reaches nothing in $q.dl"; exit 1; } + done + ;; + check) + B="${3:?bin-dir}"; W="$(mktemp -d)"; trap 'rm -rf "$W"' EXIT; bad=0 + for dl in "$G"/*.dl; do + q="$(basename "$dl" .dl)"; bin="$B/axiomcode-query-$q$EXE" + [ -f "$bin" ] || { echo "::error::no binary for $q"; bad=1; continue; } + chmod +x "$bin" 2>/dev/null || true + mkdir -p "$W/$q"; "$bin" -F "$G/fixture" -D "$W/$q" + if diff <(norm "$G/expected/$q") <(norm "$W/$q") > "$W/$q.diff"; then + echo "query $q: ok ($(norm "$W/$q" | grep -vc '^==') rows, same as the interpreter)" + else + echo "::error::query $q: the compiled program answers differently"; head -40 "$W/$q.diff"; bad=1 + fi + done + exit "$bad" + ;; + *) echo "unknown mode $mode" >&2; exit 2;; +esac diff --git a/.github/scripts/version.mjs b/.github/scripts/version.mjs index 7d5cbc2a3..13331f5ac 100755 --- a/.github/scripts/version.mjs +++ b/.github/scripts/version.mjs @@ -4,9 +4,10 @@ // // The release version is package.json's `version`. It is repeated in the engine // pins (optionalDependencies — each engine package is published under the same -// version as this one), the vendored parser, and every agent plugin manifest a -// marketplace reads. A release where they disagree ships a plugin that reports a -// version nobody can install, or an install that pins engines that were never +// version as this one) and every agent plugin manifest a marketplace reads. The +// vendored parser keeps its own version: it moves only when the parser changes. +// A release where they disagree ships a plugin that reports a version nobody can +// install, or an install that pins engines that were never // published. // // node .github/scripts/version.mjs check every manifest agrees @@ -24,7 +25,6 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); // reading the file, so a fifth platform is covered without editing this list. const MANIFESTS = [ 'package.json', - 'parser/package.json', 'gemini-extension.json', 'plugins/axiomcode/.claude-plugin/plugin.json', 'plugins/axiomcode/.codex-plugin/plugin.json', diff --git a/.github/workflows/build-engines.yml b/.github/workflows/build-engines.yml index 167fbf1f1..fcb18e83d 100644 --- a/.github/workflows/build-engines.yml +++ b/.github/workflows/build-engines.yml @@ -9,7 +9,12 @@ # one self-contained executable per language per platform, linked against nothing but the # C++ runtime. Same flags as the local compile (no OpenMP/zlib/sqlite), portable targets. # -# Artifacts: engines-/ holding /axiomcode-engine-[.exe] + /ENGINE_ID +# Artifacts: engines-/ holding /axiomcode-engine-[.exe] + /ENGINE_ID, +# and queries/axiomcode-query-[.exe] + queries/.id for every query program the CLI +# runs (plugins/axiomcode/skills/axiomcode/scripts/dl/.dl: impact, path, path-opt, +# path-every). Without those, `axiomcode impact` needs Soufflé and a C++ compiler on the user's +# machine (#1330). A query binary's id is dl_program.py's own key, so the CLI finds it by the +# same hash it would cache a local compile under. # Platforms are named the npm way (process.platform-process.arch): darwin-arm64, darwin-x64, # linux-x64, linux-arm64, win32-x64, each on a standard GitHub-hosted runner (free for a # public repository; macOS builds on macos-15 for Apple Silicon and macos-15-intel for Intel). @@ -71,9 +76,21 @@ jobs: souffle -I graph -g "gen/$lang.cpp" "gen/$lang.dl" 2> "gen/$lang.gen.log" || { cat "gen/$lang.gen.log"; exit 1; } awk '/No rules\/facts defined/{skip=2;next} skip>0{skip--;next} {print}' "gen/$lang.gen.log" done + # the query programs: self-contained (no #include), keyed by dl_program.py itself + mkdir -p gen/queries + for dl in plugins/axiomcode/skills/axiomcode/scripts/dl/*.dl; do + q="$(basename "$dl" .dl)" + python3 plugins/axiomcode/skills/axiomcode/scripts/dl_program.py --print-id "$dl" | tr -d '\n' > "gen/queries/$q.id" + echo "query $q: $(cat "gen/queries/$q.id")" + cp "$dl" "gen/queries/$q.dl" + souffle -g "gen/queries/$q.cpp" "$dl" 2> "gen/queries/$q.gen.log" || { cat "gen/queries/$q.gen.log"; exit 1; } + done + # the fixture and what the interpreter derives from it; each platform's binaries must match + bash .github/scripts/query-smoke.sh expect gen/queries + cp .github/scripts/query-smoke.sh gen/queries/smoke.sh cp -r /usr/include/souffle gen/souffle # one key for the whole set; a partial match restores the previous set - echo "key=$(cat gen/*.id | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT" + echo "key=$(cat gen/*.id gen/queries/*.id | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT" # The compile flags live in THIS file and ENGINE_ID does not cover them, so its # hash prefixes the key AND the restore prefix: a flag change restores nothing. # Computed here because the build jobs never check the repository out. @@ -112,6 +129,13 @@ jobs: c++ -std=c++17 -O3 -w -static-libstdc++ -static-libgcc -I gen "gen/$lang.cpp" -o "engines/$lang/axiomcode-engine-$lang" cp "gen/$lang.id" "engines/$lang/ENGINE_ID" done + mkdir -p engines/queries + for cpp in gen/queries/*.cpp; do + q="$(basename "$cpp" .cpp)" + if cmp -s "gen/queries/$q.id" "engines/queries/$q.id"; then echo "query $q: cached, rules unchanged"; continue; fi + c++ -std=c++17 -O3 -w -static-libstdc++ -static-libgcc -I gen "$cpp" -o "engines/queries/axiomcode-query-$q" + cp "gen/queries/$q.id" "engines/queries/$q.id" + done ls -la engines/*; ldd engines/java/axiomcode-engine-java || true - uses: ilammy/msvc-dev-cmd@v1 if: startsWith(matrix.target.platform, 'win32') @@ -131,6 +155,17 @@ jobs: echo %%L: cached, rules unchanged ) ) + if not exist engines\queries mkdir engines\queries + for %%Q in (gen\queries\*.cpp) do ( + fc /b gen\queries\%%~nQ.id engines\queries\%%~nQ.id >nul 2>&1 + if errorlevel 1 ( + cl /nologo /std:c++17 /O2 /EHsc /bigobj /w /permissive- /Zc:__cplusplus /D_CRT_SECURE_NO_WARNINGS /DNOMINMAX /DUSE_CUSTOM_GETOPTLONG /I gen %%Q /Fe:engines\queries\axiomcode-query-%%~nQ.exe + if errorlevel 1 exit /b 1 + copy /y gen\queries\%%~nQ.id engines\queries\%%~nQ.id + ) else ( + echo query %%~nQ: cached, rules unchanged + ) + ) dir /s engines - name: Smoke — every binary starts on empty inputs shell: bash @@ -143,6 +178,7 @@ jobs: "./$bin" -F "facts-$lang" -D "out-$lang" echo "$lang: ok ($(ls out-$lang | wc -l) relations written)" done + bash gen/queries/smoke.sh check gen/queries engines/queries - name: save the engines for the next run if: ${{ !inputs.fresh && steps.restore.outputs.cache-hit != 'true' }} uses: actions/cache/save@v4 @@ -187,6 +223,13 @@ jobs: c++ -std=c++17 -O3 -w -arch "$ARCH" -mmacosx-version-min=12.0 -I gen "gen/$lang.cpp" -o "engines/$lang/axiomcode-engine-$lang" cp "gen/$lang.id" "engines/$lang/ENGINE_ID" done + mkdir -p engines/queries + for cpp in gen/queries/*.cpp; do + q="$(basename "$cpp" .cpp)" + if cmp -s "gen/queries/$q.id" "engines/queries/$q.id"; then echo "query $q: cached, rules unchanged"; continue; fi + c++ -std=c++17 -O3 -w -arch "$ARCH" -mmacosx-version-min=12.0 -I gen "$cpp" -o "engines/queries/axiomcode-query-$q" + cp "gen/queries/$q.id" "engines/queries/$q.id" + done file engines/java/axiomcode-engine-java otool -L engines/java/axiomcode-engine-java - name: Smoke — every binary starts on empty inputs @@ -197,6 +240,7 @@ jobs: sed -n 's/^\.input \([A-Za-z0-9_]*\)(.*/\1/p' "gen/$lang.dl" | while read -r r; do : > "facts-$lang/$r.facts"; done "./engines/$lang/axiomcode-engine-$lang" -F "facts-$lang" -D "out-$lang" done + bash gen/queries/smoke.sh check gen/queries engines/queries - name: save the engines for the next run if: ${{ !inputs.fresh && steps.restore.outputs.cache-hit != 'true' }} uses: actions/cache/save@v4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5623fa166..c4eba024a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,8 +95,8 @@ jobs: code="$code$(printf '%s\n' "$files" | grep -E '^(graph|parser)/' || true)" # ENGINES: what the platform build compiles or packages. engines="$(printf '%s\n' "$files" | grep -E \ - -e '\.dl$' -e '^graph/pipeline/' -e '^packaging/' \ - -e '^\.github/workflows/build-engines\.yml$' -e '^package\.json$' || true)" + -e '\.dl$' -e '^graph/pipeline/' -e '^packaging/' -e 'scripts/dl_program\.py$' -e '^\.github/scripts/query-smoke\.sh$' \ + -e '^\.github/workflows/build-engines\.yml$' -e '^package\.json$' -e '^\.github/scripts/e2e-' -e '^\.github/e2e/' || true)" [ -n "$code" ] && echo "code=true" >> "$GITHUB_OUTPUT" || echo "code=false" >> "$GITHUB_OUTPUT" # Only a pull request INTO main builds the platform engines; into dev they wait. [ "${{ github.base_ref }}" = main ] || engines="" @@ -397,6 +397,70 @@ jobs: with: fresh: ${{ inputs.fresh == true }} + # THE RELEASE GATE: what a user installs, on every platform, answers every verb. The engines + # job only proves each binary compiles and starts; the suites run from the checkout. Neither + # installs the packages, so a missing `files` entry, an engine package the CLI does not find, + # a query program that needs Soufflé, or a verb that only breaks on Windows reached users. + # Runs wherever the platform engines are built: on the way into main, and in the nightly. + pack: + name: pack @axiomcode/code-graph + needs: [build, changes] + if: needs.changes.outputs.engines == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: npm install --no-audit --no-fund + # --ignore-scripts: `prepare` already built it; the tarball carries what the build produced + - run: mkdir -p tgz && npm pack --ignore-scripts --pack-destination tgz && ls -la tgz + - uses: actions/upload-artifact@v4 + with: { name: code-graph-tgz, path: tgz, retention-days: 3, if-no-files-found: error } + + e2e: + name: e2e on ${{ matrix.target.platform }} + needs: [changes, engines, pack] + if: needs.changes.outputs.engines == 'true' + strategy: + fail-fast: false + matrix: + target: + - { os: ubuntu-24.04, platform: linux-x64 } + - { os: ubuntu-24.04-arm, platform: linux-arm64 } + - { os: windows-2025, platform: win32-x64 } + - { os: macos-15, platform: darwin-arm64 } + - { os: macos-15-intel, platform: darwin-x64 } + runs-on: ${{ matrix.target.os }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: actions/download-artifact@v4 + with: { name: 'engines-${{ matrix.target.platform }}', path: artifacts/engines } + - uses: actions/download-artifact@v4 + with: { name: code-graph-tgz, path: artifacts/tgz } + - name: installed from the tarballs, no Soufflé, every language, every verb + shell: bash + env: + PLATFORM: ${{ matrix.target.platform }} + run: | + set -euo pipefail + command -v souffle && { echo "::error::this runner has souffle; the gate would not prove anything"; exit 1; } + export CODEGRAPH_TGZ="$(ls "$PWD"/artifacts/tgz/*.tgz)" + for lang in java typescript python javascript csharp; do + case_dir="$(ls -d graph/test/$lang/cases/*/src | head -1)" + echo "::group::$lang ($case_dir)" + bash .github/scripts/e2e-install.sh artifacts/engines "$PLATFORM" "$lang" "$case_dir" + echo "::endgroup::" + done + # A single job the branch ruleset can require. Without it, every new matrix # entry has to be added to the protection rules by hand, and a matrix job that # fails to start reports nothing at all — which a ruleset reads as "not @@ -404,7 +468,7 @@ jobs: ci: name: CI runs-on: ubuntu-24.04 - needs: [changes, build, hygiene, engine, engines] + needs: [changes, build, hygiene, engine, engines, pack, e2e] if: always() steps: - name: every required job succeeded @@ -431,4 +495,6 @@ jobs: } check "engine suites" "${{ needs.engine.result }}" "${{ needs.changes.outputs.code }}" check "platform engines" "${{ needs.engines.result }}" "${{ needs.changes.outputs.engines }}" + check "pack" "${{ needs.pack.result }}" "${{ needs.changes.outputs.engines }}" + check "e2e on every platform" "${{ needs.e2e.result }}" "${{ needs.changes.outputs.engines }}" echo "all required jobs passed" diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 8651517ad..99625a607 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -103,6 +103,15 @@ jobs: echo "::warning::no engines were built for $platform" missing=1 fi + # and every query program the CLI runs, or impact/path need soufflé there (#1330) + for dl in plugins/axiomcode/skills/axiomcode/scripts/dl/*.dl; do + q="$(basename "$dl" .dl)" + if ! cmp -s <(python3 plugins/axiomcode/skills/axiomcode/scripts/dl_program.py --print-id "$dl") \ + <(cat "artifacts/engines-$platform/queries/$q.id" 2>/dev/null; echo); then + echo "::warning::$platform has no query program for these $q.dl rules" + missing=1 + fi + done done if [ "$missing" = 1 ] && [ "$DRY" != true ]; then echo "::error::a real publish needs every platform package.json pins"; exit 1 diff --git a/README.md b/README.md index ff865b5a5..51cb2e405 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,16 @@ callback, syntax alone cannot decide the target, and every wrong guess is a miss edge is a call the program actually makes. That matters because an agent follows edges several hops deep, and one missed link loses everything beyond it. +

+ axiomcode graph of an open-source TypeScript web framework, 366 files and 8,657 call edges. Source files form the inner ring, test files the outer ring. A change to basicAuth reaches 7 test files through resolved calls (solid blue); the other 130 test files have no chain to it (dashed red). basicAuth calls a shared compare function (green) that 11 other files also reach (gold). +

+ +*`axiomcode graph` on an open-source TypeScript web framework, asked which tests a change to `basicAuth` can +affect. Source files form the inner ring and test files the outer one. The solid blue paths are chains of resolved +calls from `basicAuth` to the 7 test files that must run; the dashed red ones mark the other 130, which have no +chain to it and can be skipped. Green is the shared `compare` that `basicAuth` calls, and gold the 11 other files +that also reach it.* + AI agents work from an incomplete picture of a codebase, and the reason is structural: what a call reaches is usually decided somewhere else. The type comes from another file, the implementation from another module, the binding from a dependency or a configuration key. Reading the file in front of you cannot show any of that, so a diff --git a/bin/axiomcode.js b/bin/axiomcode.js index 299605fef..4a0f6c735 100755 --- a/bin/axiomcode.js +++ b/bin/axiomcode.js @@ -13,11 +13,16 @@ // // node resolves this file through the .bin symlink before setting __dirname, so bin/axiomcode is // found beside it and its own root walk (#886) starts inside the package, as before. +// +// Python is chosen here too (#1331): the query verbs call `python3`, which a python.org install on Windows does +// not provide. find-python.js hands bash a python3 that runs whichever interpreter answered. A build needs no +// Python, so none found is not an error here; a verb that needs it says so from scripts/axiomcode. // ───────────────────────────────────────────────────────────────────────────── 'use strict'; const { spawnSync } = require('child_process'); const path = require('path'); const { findBash } = require('../plugins/axiomcode/mcp/find-bash.js'); +const { findPython, withPython } = require('../plugins/axiomcode/mcp/find-python.js'); function fail(msg) { process.stderr.write(`❌ ${msg}\n`); @@ -26,7 +31,10 @@ function fail(msg) { const { bash, error } = findBash(); if (error) fail(error); -const r = spawnSync(bash, [path.join(__dirname, 'axiomcode'), ...process.argv.slice(2)], { stdio: 'inherit' }); +const py = findPython(); +// AXIOMCODE_BASH too, for the builds Python starts: a bare `bash` from Python on Windows is WSL's or nothing. +const env = { ...(py.exe ? withPython(process.env, py) : process.env), AXIOMCODE_BASH: bash }; +const r = spawnSync(bash, [path.join(__dirname, 'axiomcode'), ...process.argv.slice(2)], { stdio: 'inherit', env }); if (r.error) fail(`could not start bash: ${r.error.message}`); // Die of the same signal the CLI died of, so a caller sees what really happened. if (r.signal) process.kill(process.pid, r.signal); diff --git a/docs/images/impact-graph.png b/docs/images/impact-graph.png new file mode 100644 index 000000000..35cf02c17 Binary files /dev/null and b/docs/images/impact-graph.png differ diff --git a/gemini-extension.json b/gemini-extension.json index aeb7b99bb..bdc7980f4 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.0", + "version": "0.1.1", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", "contextFileName": "plugins/axiomcode/AGENTS.md", "mcpServers": { diff --git a/hooks/hooks.json b/hooks/hooks.json index 8b96b0ec4..160dad33f 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -3,9 +3,9 @@ "BeforeAgent": [ { "hooks": [ - { "type": "command", "command": "python3 \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}changes.py\"", "timeout": 25000 }, - { "type": "command", "command": "python3 \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}orient.py\"", "timeout": 20000 }, - { "type": "command", "command": "python3 \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}refresh.py\"", "timeout": 5000 } + { "type": "command", "command": "node \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}run.js\" changes.py", "timeout": 25000 }, + { "type": "command", "command": "node \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}run.js\" orient.py", "timeout": 20000 }, + { "type": "command", "command": "node \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}run.js\" refresh.py", "timeout": 5000 } ] } ], @@ -13,7 +13,7 @@ { "matcher": "replace|write_file", "hooks": [ - { "type": "command", "command": "python3 \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}changes.py\"", "timeout": 25000 } + { "type": "command", "command": "node \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}run.js\" changes.py", "timeout": 25000 } ] } ], @@ -21,27 +21,27 @@ { "matcher": "read_file|grep_search|glob|run_shell_command|replace|write_file", "hooks": [ - { "type": "command", "command": "python3 \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}enrich.py\"", "timeout": 20000 } + { "type": "command", "command": "node \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}run.js\" enrich.py", "timeout": 20000 } ] }, { "matcher": "run_shell_command", "hooks": [ - { "type": "command", "command": "python3 \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}changes.py\"", "timeout": 25000 } + { "type": "command", "command": "node \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}run.js\" changes.py", "timeout": 25000 } ] }, { "matcher": "replace|write_file|run_shell_command", "hooks": [ - { "type": "command", "command": "python3 \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}refresh.py\"", "timeout": 5000 } + { "type": "command", "command": "node \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}run.js\" refresh.py", "timeout": 5000 } ] } ], "AfterAgent": [ - { "hooks": [ { "type": "command", "command": "python3 \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}refresh.py\"", "timeout": 5000 } ] } + { "hooks": [ { "type": "command", "command": "node \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}run.js\" refresh.py", "timeout": 5000 } ] } ], "SessionStart": [ - { "hooks": [ { "type": "command", "command": "python3 \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}refresh.py\"", "timeout": 5000 } ] } + { "hooks": [ { "type": "command", "command": "node \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}run.js\" refresh.py", "timeout": 5000 } ] } ] } } diff --git a/package.json b/package.json index aee53b533..7315e0658 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@axiomcode/code-graph", - "version": "0.1.0", + "version": "0.1.1", "description": "AxiomCode Graph: a resolved call graph of your codebase grounded in formal methods, so you and your coding agents can see who calls what, what a change breaks, and which tests it reaches.", "repository": { "type": "git", @@ -30,11 +30,11 @@ ], "license": "FSL-1.1-Apache-2.0", "optionalDependencies": { - "@axiomcode/engine-darwin-arm64": "0.1.0", - "@axiomcode/engine-darwin-x64": "0.1.0", - "@axiomcode/engine-linux-x64": "0.1.0", - "@axiomcode/engine-linux-arm64": "0.1.0", - "@axiomcode/engine-win32-x64": "0.1.0" + "@axiomcode/engine-darwin-arm64": "0.1.1", + "@axiomcode/engine-darwin-x64": "0.1.1", + "@axiomcode/engine-linux-x64": "0.1.1", + "@axiomcode/engine-linux-arm64": "0.1.1", + "@axiomcode/engine-win32-x64": "0.1.1" }, "bin": { "axiomcode": "bin/axiomcode.js" @@ -47,6 +47,7 @@ "plugins/axiomcode/", "!plugins/axiomcode/validate/", "!plugins/**/__pycache__/", + "!plugins/**/dl/.cache/", "parser/dist/", "parser/package.json", "README.md", diff --git a/packaging/assemble-engine-package.sh b/packaging/assemble-engine-package.sh index df1c20787..baa887365 100755 --- a/packaging/assemble-engine-package.sh +++ b/packaging/assemble-engine-package.sh @@ -2,7 +2,8 @@ # Assemble one @axiomcode/engine-- npm package from the compiled engines. # assemble-engine-package.sh # holds /axiomcode-engine-[.exe] and /ENGINE_ID for every -# language CI built for that platform. The package carries them verbatim plus a package.json +# language CI built for that platform, and queries/axiomcode-query-[.exe] + queries/.id +# for every query program the CLI runs (impact, path …). The package carries them verbatim plus a package.json # whose os/cpu fields let npm install it only on a matching machine. set -eu platform="$1"; version="$2"; src="$3"; out="$4" @@ -11,7 +12,8 @@ HERE="$(cd "$(dirname "$0")" && pwd)" . "$HERE/../graph/pipeline/engine.conf" rm -rf "$out"; mkdir -p "$out" cp -R "$src"/. "$out/" -langs="$(ls -d "$out"/*/ | xargs -n1 basename | tr '\n' ' ')" +langs="$(ls -d "$out"/*/ | xargs -n1 basename | grep -vx queries | tr '\n' ' ')" +queries="$(ls "$out"/queries/*.id 2>/dev/null | xargs -n1 basename 2>/dev/null | sed 's/\.id$//' | tr '\n' ' ')" case "$platform" in darwin-arm64) label="macOS (Apple Silicon)";; darwin-x64) label="macOS (Intel)";; linux-x64) label="Linux x64";; linux-arm64) label="Linux arm64";; win32-x64) label="Windows x64";; @@ -28,6 +30,10 @@ cp "$HERE/../LICENSE.md" "$out/LICENSE.md" echo; echo "Languages: ${langs% }." echo; echo "Rules each engine was built from:"; echo for l in $langs; do echo "- $l: \`$(cat "$out/$l/ENGINE_ID")\`"; done + if [ -n "$queries" ]; then + echo; echo "Query programs (\`axiomcode impact\`, \`axiomcode path\` …), by the rules each was built from:"; echo + for q in $queries; do echo "- $q: \`$(cat "$out/queries/$q.id")\`"; done + fi } > "$out/README.md" -chmod +x "$out"/*/axiomcode-engine-* 2>/dev/null || true +chmod +x "$out"/*/axiomcode-engine-* "$out"/queries/axiomcode-query-* 2>/dev/null || true echo "assembled $out: $(ls "$out" | tr '\n' ' ')" diff --git a/packaging/engine-package.json b/packaging/engine-package.json index b8fc3b53d..976d8ec35 100644 --- a/packaging/engine-package.json +++ b/packaging/engine-package.json @@ -5,7 +5,7 @@ "license": "FSL-1.1-Apache-2.0", "os": ["@@OS@@"], "cpu": ["@@CPU@@"], - "files": ["*/axiomcode-engine-*", "*/ENGINE_ID", "README.md", "LICENSE.md"], + "files": ["*/axiomcode-engine-*", "*/ENGINE_ID", "queries/axiomcode-query-*", "queries/*.id", "README.md", "LICENSE.md"], "repository": { "type": "git", "url": "git+https://github.com/AxiomCodeAI/axiomcodegraph.git" }, "publishConfig": { "access": "public" } } diff --git a/parser/README.md b/parser/README.md index b03a4adaa..c6c5517ab 100644 --- a/parser/README.md +++ b/parser/README.md @@ -6,19 +6,21 @@ Python    -TypeScript +TypeScript    JavaScript    Java    +C# +   XML    YAML    Gradle -Full semantic resolution for Python and Java. JavaScript with a binder and JSDoc as its type channel. TypeScript in development. Build-graph and dependency resolution for Gradle. Structural extraction for XML, YAML, Properties and META-INF/services. +Full semantic resolution for Python and Java. TypeScript and JavaScript with a binder, from the compiler's syntax layer. C# with each file read under the framework and preprocessor symbols of the project that compiles it. Build-graph and dependency resolution for Gradle. Structural extraction for XML, YAML, Properties and META-INF/services. [What it is](#what-this-is)  |  [The IR](#the-intermediate-representation)  |  @@ -78,7 +80,9 @@ same tables and be compared. | Language | Maturity | Relations | Parser | What is extracted | |---|---|---|---|---| | **Python** | Stable | 19 | tree-sitter-python | Modules, scopes, bindings, types, base classes, methods, parameters, imports, expressions, call sites, type references, fields, decorators and their arguments, blocks, comments, parse gaps, PEP 695 type parameters. | -| **JavaScript** | Stable | 16 | TypeScript compiler API, syntax only | Modules with their module system decided per file, scopes and bindings from a binder that models hoisting and the temporal dead zone, types including constructor functions and prototype members expressed as assignments and calls, methods, parameters, fields, variables, blocks, expressions as a tree, call sites by form, CommonJS and ESM edges wherever they sit, JSDoc types as trees, comments, directives and parse gaps. `.js`, `.mjs`, `.cjs`, `.jsx`. Flow is rejected, not parsed. | +| **JavaScript** | Stable | 17 | TypeScript compiler API, syntax only | Modules with their module system decided per file, scopes and bindings from a binder that models hoisting and the temporal dead zone, types including constructor functions and prototype members expressed as assignments and calls, methods, parameters, fields, variables, blocks, expressions as a tree, call sites by form, CommonJS and ESM edges wherever they sit, JSDoc types as trees, comments, directives and parse gaps. `.js`, `.mjs`, `.cjs`, `.jsx`. Flow is rejected, not parsed. | +| **TypeScript** | Stable | 21 | TypeScript compiler API, syntax only | Modules including `.d.ts`, types, heritage, type parameters, methods and their overload signatures, parameters, fields and their positions, enum members, variables, imports and exports, expressions, call sites with same-file resolution and the evidence for it, type references as trees, decorators and their arguments, `satisfies` checks, blocks, comments and parse gaps. `.ts`, `.tsx`, `.d.ts`. Module specifiers are resolved through the project's `tsconfig.json` paths. | +| **C#** | Stable | 22 | tree-sitter-c-sharp | Modules, types and their heritage, type parameters, methods, parameters, properties, events, fields, enum members, locals, usings, attributes and their arguments, expressions, call sites including user-defined operators and explicit casts, LINQ query clauses, blocks, comments, parse gaps, and preprocessor regions. Classes, structs, records, interfaces, enums, delegates and C# 14 extension blocks. | | **Java** | Stable | 17 | tree-sitter-java | Types, methods, fields, annotations and their arguments, expressions, imports, local variables, blocks, comments, enum constants, generics and type parameters. Covers classes, interfaces, enums, records, and nested types. | | **XML** | Stable | 3 | sax | Element hierarchy with XPath and namespaces, attributes, and value references including property placeholders and SpEL. | | **Properties** | Stable | 2 | custom | Keys and typed value segments, with continuation and comment handling. | @@ -86,7 +90,8 @@ same tables and be compared. | **META-INF/services** | Stable | 2 | custom | Provider-configuration files: the service each file configures, taken from its name, and every implementation class it names, with the file and line. | | **Gradle** | Beta | 8 | tree-sitter-groovy | Scripts and their role in the build, blocks, declarations, dependency coordinates split into group/artifact/version, version catalogs, value references with resolution, comments, and parse gaps. Groovy and Kotlin DSL. | -Python and Java are the two languages with full semantic resolution. The configuration formats are +Python and Java are the two languages with full semantic resolution. TypeScript and C# resolve what +one file decides and leave the cross-file call graph to the engine, as Java does for type references. The configuration formats are extracted structurally so that configuration values can be correlated with the code that reads them. JavaScript is parsed by the TypeScript compiler's syntax layer and never by its type checker: no @@ -102,6 +107,33 @@ from:` edges, `project(':core')` dependencies and version catalog accessors are scripts and entries they name. "Which project declares this dependency, at which version, and where did that version come from" is a join rather than a text search. +TypeScript is read the same way as JavaScript: `ts.createSourceFile`, never a `Program` or a +`TypeChecker`. That also removes tree-sitter's 32,767-character buffer limit, which matters here +because the largest files are the `.d.ts` declarations that hold most of a call graph's leaves. The +exact compiler version is recorded on every module row rather than in any key, so a patch bump does +not rewrite every hash in the fact base. + +### C# is read under the project that compiles it + +A C# file does not mean one thing on its own. `#if NET8_0_OR_GREATER`, `#if DEBUG` and every +`` symbol decide which half of a file is source, and none of them are written in +the file. The C# front end therefore reads each `.csproj` — its imports, `` blocks and the +framework inference the SDK performs — and extracts every file under the target framework and +symbols of the project that governs it. A multi-targeting project is read under one framework, the +newest .NET it targets, so rows do not multiply by the framework count. Inactive `#if` arms are +blanked before parsing, preserving line count, and recorded in the preprocessor region relation, so +"this code is inactive under this build" is a row rather than an absence. + +The published grammar fails some shapes in two ways. Where it produces an `ERROR` node, rows are +lost and a parse gap row says so. Where it produces no error and the wrong tree — a generic creation +in argument position, a ref-returning assignment, an extension header — nothing would report it, so +each such misparse is recognised in one place and rebuilt, and each detector has a negative control +asserting that the legitimate shape it resembles is left alone. What cannot be repaired is enumerated +in `KNOWN_GRAMMAR_LIMITATIONS`. The parser asserts at +startup that every enumerated limitation is still a limitation, because a wrong grammar fails +silently: about 6% of declarations simply are not there. A UTF-8 byte-order mark is stripped before +parsing so that positions match Roslyn's. + ### Gradle is parsed by a grammar that is not its own This is the one front end where the grammar does not match the language. `tree-sitter-groovy` parses @@ -140,10 +172,10 @@ detection -> parsing -> extraction -> models -> resolution -> export | Layer | Directory | Responsibility | |---|---|---| | Detection | `language-detectors/` | Identify which languages and build systems a project uses. | -| Parsing | `parsers//` | Produce a syntax tree. tree-sitter for Java, Python and Gradle; sax for XML; the `yaml` package for YAML; a hand written scanner for Properties and for META-INF/services. | +| Parsing | `parsers//` | Produce a syntax tree. tree-sitter for Java, Python, C# and Gradle; the TypeScript compiler's syntax layer for TypeScript and JavaScript; sax for XML; the `yaml` package for YAML; a hand written scanner for Properties and for META-INF/services. | | Extraction | `parsers//extractors/` | Walk the tree and emit rows. One extractor per relation family, implementing `BaseExtractor`. | | Models | `analysis-types//` | One class per relation. Builder pattern, content addressed key, CSV serialisation. | -| Resolution | `parsers//*-resolution-linker.ts` | Fill in cross entity foreign keys, first within a file and then across the project. | +| Resolution | `parsers//**/*-resolution-linker.ts` | Fill in cross entity foreign keys, first within a file and then across the project. | | Export | `workflows//` | Orchestrate discovery, run extractors, stream rows to disk. | ``` @@ -213,7 +245,10 @@ Every gate therefore uses something not written for this purpose. | CPython bytecode | Every call the compiler emitted, and how each name resolves | The compiler has already decided whether a name is local, global, a cell, or an attribute, and records it in the opcode. | | CPython `sys.settrace` | Which function a call actually reaches | Ground truth for target correctness, not merely call discovery. | | JVM bytecode | Java call edges | The same role for the Java front end. | +| Roslyn, out of process | C# declarations, call sites, positions and preprocessor state | It is the C# compiler. The parser itself runs no .NET; Roslyn adjudicates from outside, so the extractor cannot lean on it. | | TypeScript compiler, with a `Program` | JavaScript call sites, module resolution, JSDoc tag structure, declaration kinds | It is the same compiler the parser reads syntax from, consulted with the type information the parser deliberately does not build. Where it declines — an `any` callee, an uninstalled package — the row is frozen for drift detection and reported separately, never counted as verified. | +| TypeScript compiler, with a `Program` and `TypeChecker` | TypeScript call targets, module resolution, declaration kinds | The same arrangement as JavaScript, with the pinned compiler version the parser records. | +| A runtime tracer | Which TypeScript declaration a call actually reaches | A source rewrite that records, as a project's own test suite runs, the declaration entered at each call site. The compiler says what it resolved; this says what ran, and the two disagree in ways that matter. | | Gradle `projects` | The build's project graph | Gradle is the implementation that decides which projects a settings file creates. On its first real run it found a directory this parser was reporting as a project and Gradle was not. | Call graph quality is measured at three increasing strictnesses, because each answers a question the @@ -263,6 +298,19 @@ with a single module row saying so, because the compiler accepts Flow where it o and mis-parses it where it diverges, silently; a file that is Flow without a pragma is the one case the detector cannot see, and it is recorded as an open exposure rather than a clean result. +**C# call targets.** A call site carries its callee name, receiver shape and argument counts, but +no resolved target. Overload resolution, extension method reduction and `dynamic` are decided by the +compiler with the full reference set, and are left to the engine rather than approximated. + +**Nested type names have more than one spelling.** Every type, method, field and variable row names +a nested type by its full enclosing chain: `B` declared inside `pkg.A` is `pkg.A.B`, its methods are +`pkg.A.B.m`, and a field of that type has `potentialQualifiedName` `pkg.A.B`, in Java and C# alike. +Three other spellings of the same type still reach a query. A library IR extracted before nested +names carried the chain holds the flattened `pkg.B`. A type reference row keeps the name as it was +written, `B` or `A.B`, and is never qualified. A `META-INF/services` file names a nested provider by +its binary name, `pkg.A$B`, as `ServiceLoader` requires. A query that joins on a nested type's name +from any of those sources has to allow for its spelling there rather than assume `pkg.A.B`. + **Grammar level hazards.** tree-sitter-python applies the PEP 695 soft `type` keyword greedily, so `type(obj).attr = value` parses cleanly as a type alias and the call node disappears. That statement is recovered, and the part that cannot be is recorded in the parse gap relation so its absence is @@ -291,18 +339,23 @@ await extractProject({ As a subprocess: ```bash -node dist/index.js [outputDir] +node dist/index.js [outputDir] \ + [--per-language] [--library] ``` Both call the same core in `src/extract.ts`. ### Output -Tab separated files, one per relation. Java relations are named `all-*.csv`, Python relations -`all-python-*.csv`, Gradle relations `all-gradle-*.csv`, and the other configuration formats are -prefixed by format. `skipped-files.csv` and -`skipped-python-files.csv` record every file that was not analysed and why, so a consumer can -distinguish an empty result from an unanalysed one. +Tab separated files, one per relation. Java relations are named `all-*.csv`; every other language +is prefixed by name: `all-python-*.csv`, `all-typescript-*.csv`, `all-javascript-*.csv`, +`all-csharp-*.csv`, `all-gradle-*.csv`, `all-xml-*.csv` and so on. The `skipped-*-files.csv` files +record every file that was not analysed and why, and a file the extractor loses part way leaves a row +saying so, so a consumer can distinguish an empty result from an unanalysed one. + +`--per-language` writes `outputDir//` instead of one flat folder. `--library` marks the tree as +a dependency being staged rather than the project under analysis, so a build output directory its +`package.json` ships from is walked as its source. ## Development @@ -318,9 +371,13 @@ frozen schema, and referential integrity across every foreign key in the emitted ```bash npx tsx src/test/java-extractor-tests.ts npx tsx src/test/python-extractor-tests.ts +npx tsx src/test/typescript-tests.ts npx tsx src/test/javascript-tests.ts +npx tsx src/test/csharp-tests.ts npx tsx src/test/gradle-tests.ts npx tsx src/test/services-tests.ts +npx tsx src/test/discovery-tests.ts +npx tsx src/test/java-gates/source-walk.ts ``` The JavaScript suite is written so that every check can fail, and each was made to fail on purpose @@ -335,11 +392,21 @@ What ships is what Python and TypeScript ship: fixtures, in-repo gates, committe oracle harness that computes an expectation, and the `bless` command that writes it, live in a separate repository so that a suite cannot authorise its own expectations. One consequence is real and is not a defect: a contributor can run every gate and see a red, but cannot add a blessed fixture -without the blessing tool. Java does not have that limitation because it has nothing to bless — its +without the blessing tool. The TypeScript and C# suites follow the same split: TypeScript's +expectations are frozen under `src/test-data/typescript/_oracle` and computed by a `Program` elsewhere, +and C#'s are computed by Roslyn elsewhere; neither suite can rewrite them. Every C# check carries a +negative control that runs in the same pass, so a check that stops being able to fail is reported as +a failure. `src/test/csharp-gates/release-gate.sh` runs the shipping suite, the scrub gate, the +negative-control harness and the derived-column sweep, and prints the line a release merge records. + +Java does not have that limitation because it has nothing to bless — its tests assert properties in code — and for JavaScript the blessed rows are split into those the compiler independently confirmed and those it declined to decide, which are kept for drift detection and carry no authority. +The discovery suite builds throwaway repositories and runs the real scanner over them, because a +language that discovery misses is never extracted and nothing reports it. + The Gradle suite adds a fourth layer: twenty checks written from the Gradle DSL's documented semantics rather than from parser output, so they do not move when the parser does. diff --git a/parser/package.json b/parser/package.json index e3165e5bc..fe7527c54 100644 --- a/parser/package.json +++ b/parser/package.json @@ -1,7 +1,7 @@ { "name": "@axiomcode/parser", "version": "0.1.0", - "description": "AxiomCode Parser — extracts Java/Python/TypeScript/Gradle/XML/YAML/Properties facts from a codebase.", + "description": "AxiomCode Parser — compiles source code and build configuration into a relational intermediate representation.", "main": "dist/extract.js", "types": "dist/extract.d.ts", "scripts": { diff --git a/plugins/axiomcode/.claude-plugin/plugin.json b/plugins/axiomcode/.claude-plugin/plugin.json index 16f99e472..514bde667 100644 --- a/plugins/axiomcode/.claude-plugin/plugin.json +++ b/plugins/axiomcode/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "axiomcode", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", - "version": "0.1.0", + "version": "0.1.1", "author": { "name": "AxiomCode" } diff --git a/plugins/axiomcode/.codex-plugin/plugin.json b/plugins/axiomcode/.codex-plugin/plugin.json index 57d2e99ff..811f1fb2b 100644 --- a/plugins/axiomcode/.codex-plugin/plugin.json +++ b/plugins/axiomcode/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.0", + "version": "0.1.1", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", "author": { "name": "AxiomCode", "url": "https://github.com/AxiomCodeAI/axiomcodegraph" }, "homepage": "https://github.com/AxiomCodeAI/axiomcodegraph", diff --git a/plugins/axiomcode/.cursor-plugin/plugin.json b/plugins/axiomcode/.cursor-plugin/plugin.json index d7a2d38e1..229f4d275 100644 --- a/plugins/axiomcode/.cursor-plugin/plugin.json +++ b/plugins/axiomcode/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.0", + "version": "0.1.1", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed. Java, TypeScript, Python, JavaScript.", "author": { "name": "AxiomCode" }, "homepage": "https://github.com/AxiomCodeAI/axiomcodegraph", diff --git a/plugins/axiomcode/hooks/changes.py b/plugins/axiomcode/hooks/changes.py index 4396f549e..33a5dd408 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -34,7 +34,7 @@ def rel_of(fp): fp = str(fp) for a, b in ((fp, cwd), (os.path.realpath(fp), os.path.realpath(cwd)), (os.path.realpath(fp), cwd), (fp, os.path.realpath(cwd))): r = os.path.relpath(a, b) - if not r.startswith('..'): return r + if not r.startswith('..'): return r.replace(os.sep, '/') # the index stores '/' on every platform return fp def changed(args, timeout=12): diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index 16cb1c261..94ffe51d7 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -22,7 +22,7 @@ def rel_of(fp): fp = str(fp) for a, b in ((fp, cwd), (os.path.realpath(fp), os.path.realpath(cwd)), (os.path.realpath(fp), cwd), (fp, os.path.realpath(cwd))): r = os.path.relpath(a, b) - if not r.startswith('..'): return r + if not r.startswith('..'): return r.replace(os.sep, '/') # the index stores '/' on every platform return fp db = os.path.join(cwd, '.axiomcode', 'out', 'graph.sqlite') if not os.path.exists(db): sys.exit(0) diff --git a/plugins/axiomcode/hooks/hooks.json b/plugins/axiomcode/hooks/hooks.json index 4baf303d7..e7a5ffbcc 100644 --- a/plugins/axiomcode/hooks/hooks.json +++ b/plugins/axiomcode/hooks/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/enrich.py\"", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" enrich.py", "timeout": 20 } ] @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/changes.py\"", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" changes.py", "timeout": 25 } ] @@ -26,7 +26,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/refresh.py\"", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" refresh.py", "timeout": 5 } ] @@ -38,7 +38,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/direct.py\"", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" direct.py", "timeout": 10 } ] @@ -48,7 +48,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/changes.py\"", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" changes.py", "timeout": 20 } ] @@ -59,7 +59,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/changes.py\"", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" changes.py", "timeout": 25 } ] @@ -69,7 +69,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/orient.py\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" orient.py" } ] }, @@ -77,7 +77,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/refresh.py\"", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" refresh.py", "timeout": 5 } ] @@ -88,7 +88,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/refresh.py\"", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" refresh.py", "timeout": 5 } ] @@ -99,7 +99,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/refresh.py\"", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" refresh.py", "timeout": 5 } ] diff --git a/plugins/axiomcode/hooks/run.js b/plugins/axiomcode/hooks/run.js new file mode 100644 index 000000000..f97ca3bbd --- /dev/null +++ b/plugins/axiomcode/hooks/run.js @@ -0,0 +1,36 @@ +// run.js .py — run one hook under the Python find-python.js finds (#1331). +// +// hooks.json used to say `python3 .py`, and on Windows `python3` is the Store placeholder or nothing, +// so every hook failed on every tool call. The command is now `node run.js .py`: node is the same name +// on every platform, and the MCP server already needs it (#1233). +// +// The event arrives on stdin and is passed on whole; stdout, stderr and the exit status come back unchanged, +// because exit 2 is how a hook blocks. A hook must never block a tool for want of an interpreter, so with no +// Python the runner exits 0 and says why on stderr, which the host shows only in its debug output. +// +// The environment it passes carries the python3 bash needs and the bash find-bash.js chose, for the +// background refresh a hook starts: that runs axiomcode-build, a bash script that calls python3. +'use strict'; +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const { findPython, withPython } = require('../mcp/find-python.js'); +const { findBash } = require('../mcp/find-bash.js'); + +const hook = process.argv[2]; +if (!hook) { process.stderr.write('usage: node run.js .py\n'); process.exit(0); } + +const py = findPython(); +if (!py.exe) { process.stderr.write(`axiomcode hook ${hook}: ${py.error}\n`); process.exit(0); } + +let input = ''; +try { input = fs.readFileSync(0); } catch { /* no stdin: the hook reads an empty event */ } + +const env = withPython(process.env, py); +const { bash } = findBash(); +if (bash) env.AXIOMCODE_BASH = bash; + +const r = spawnSync(py.exe, [path.join(__dirname, hook)], { input, env, windowsHide: true, + stdio: ['pipe', 'inherit', 'inherit'] }); +if (r.error) { process.stderr.write(`axiomcode hook ${hook}: could not start ${py.exe}: ${r.error.message}\n`); process.exit(0); } +process.exit(r.status ?? 0); diff --git a/plugins/axiomcode/hooks/validate.py b/plugins/axiomcode/hooks/validate.py index 5c3099f66..8b10e1171 100644 --- a/plugins/axiomcode/hooks/validate.py +++ b/plugins/axiomcode/hooks/validate.py @@ -243,7 +243,7 @@ def main(argv): if not e.get('text') or 'input' not in e: continue inp = e['input'] if e.get('as') == 'Read' and inp.get('file_path'): - rel = os.path.relpath(os.path.realpath(inp['file_path']), V_.repo); a0 = int(inp.get('offset') or 1); V_.check_read(e['text'], rel, a0, a0 + int(inp.get('limit') or 100000)) + rel = os.path.relpath(os.path.realpath(inp['file_path']), V_.repo).replace(os.sep, '/'); a0 = int(inp.get('offset') or 1); V_.check_read(e['text'], rel, a0, a0 + int(inp.get('limit') or 100000)) elif e.get('as') == 'Grep' and inp.get('pattern'): V_.check_grep(e['text'], re.sub(r'\W.*', '', inp['pattern'])) else: files = [r[0] for r in V_.q("SELECT DISTINCT file FROM symbols WHERE method_id IS NOT NULL AND kind <> 'module' AND is_test = 0")] diff --git a/plugins/axiomcode/mcp/find-python.js b/plugins/axiomcode/mcp/find-python.js new file mode 100644 index 000000000..70c9fdd7a --- /dev/null +++ b/plugins/axiomcode/mcp/find-python.js @@ -0,0 +1,53 @@ +// find-python.js — the Python the CLI and the hooks run under, found rather than assumed (#1331). +// +// Every verb and every hook is a Python script, and the bash half of the CLI calls it as `python3`. A +// python.org install on Windows provides python.exe and py.exe and no python3, and on a desktop Windows +// `python3` is the Microsoft Store placeholder, which is on PATH and exits 9009. So the interpreter is +// probed in the order launch.js has always used for the MCP server: AXIOMCODE_PYTHON, python3, python, +// and on Windows `py -3`. A candidate is taken only if it runs, and it reports its own sys.executable, so +// what bash is handed is a real file and not the py launcher or a placeholder. +// +// bash is then given a `python3` that runs it: skills/axiomcode/scripts/pyshim/python3, first on PATH, +// which execs AXIOMCODE_PYTHON_EXE. Python's own children use sys.executable and need nothing. +// +// Used by bin/axiomcode.js (the command npm links), mcp/launch.js (the MCP server) and hooks/run.js. +'use strict'; +const { spawnSync } = require('child_process'); +const path = require('path'); + +const SHIM = path.join(__dirname, '..', 'skills', 'axiomcode', 'scripts', 'pyshim'); + +function candidates() { + return [process.env.AXIOMCODE_PYTHON && [process.env.AXIOMCODE_PYTHON], ['python3'], ['python'], + process.platform === 'win32' && ['py', '-3']].filter(Boolean); +} + +// { cmd, exe } or { error }: cmd is how the candidate was named, exe the interpreter file it runs. +function findPython() { + for (const cmd of candidates()) { + const r = spawnSync(cmd[0], [...cmd.slice(1), '-c', 'import sys; print(sys.executable)'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }); + const exe = r.status === 0 && String(r.stdout).trim(); + if (exe) return { cmd, exe }; + } + return { error: 'axiomcode needs Python 3, and no python3, python' + (process.platform === 'win32' ? ' or py -3' : '') + + ' on PATH runs.\n • install it (https://www.python.org/downloads/), or\n' + + ' • set AXIOMCODE_PYTHON to the full path of a python executable.' }; +} + +// A copy of env in which bash's `python3` is the interpreter findPython chose. On POSIX, when that is +// python3 itself, PATH is left alone. On Windows the shim always goes first: a python3 that answered a +// probe from here can still be a Store alias that Git Bash cannot run. +function withPython(env, py) { + const out = { ...env, AXIOMCODE_PYTHON_EXE: py.exe.replace(/\\/g, '/') }; + // Windows Python writes a pipe in the ANSI code page and opens files in it, so the first → in an answer raised + // UnicodeEncodeError, and a source file in UTF-8 read wrong. UTF-8 mode fixes both; a user's own setting stands. + if (process.platform === 'win32' && out.PYTHONUTF8 === undefined) out.PYTHONUTF8 = '1'; + if (process.platform !== 'win32' && py.cmd.length === 1 && py.cmd[0] === 'python3') return out; + // Windows keeps it as Path, and a second PATH key beside it would be one of two the child picks from. + const key = Object.keys(out).find((k) => k.toUpperCase() === 'PATH') || 'PATH'; + out[key] = out[key] ? SHIM + path.delimiter + out[key] : SHIM; + return out; +} + +module.exports = { candidates, findPython, withPython }; diff --git a/plugins/axiomcode/mcp/launch.js b/plugins/axiomcode/mcp/launch.js index bab079f1c..a5af11456 100644 --- a/plugins/axiomcode/mcp/launch.js +++ b/plugins/axiomcode/mcp/launch.js @@ -23,11 +23,13 @@ // reuses it, on failure the launcher says why and moves on to the fallback. // // The server shells out to the CLI, a bash script, so the bash find-bash.js chose is handed to it as -// AXIOMCODE_BASH; a bare `bash` from server.py would hit the same Windows lookup this file avoids. +// AXIOMCODE_BASH; a bare `bash` from server.py would hit the same Windows lookup this file avoids. That CLI +// calls `python3`, so the server's environment also carries find-python.js's python3 for bash (#1331). 'use strict'; const { spawn, spawnSync } = require('child_process'); const path = require('path'); const { findBash } = require('./find-bash.js'); +const { candidates, findPython, withPython } = require('./find-python.js'); const SERVER = path.join(__dirname, 'server.py'); // Run as `node launch.js …` the rest of the command line is the server's; required from mcp.json's @@ -52,8 +54,7 @@ function uvWorks() { return false; } -const pythons = [process.env.AXIOMCODE_PYTHON && [process.env.AXIOMCODE_PYTHON], ['python3'], ['python'], - process.platform === 'win32' && ['py', '-3']].filter(Boolean); +const pythons = candidates(); function choose() { for (const py of pythons) if (runs(py, ['-c', 'import mcp'])) return [...py, SERVER]; @@ -62,7 +63,8 @@ function choose() { return null; } -const env = { ...process.env }; +const py = findPython(); +const env = py.exe ? withPython(process.env, py) : { ...process.env }; const { bash, error } = findBash(); if (bash) env.AXIOMCODE_BASH = bash; else process.stderr.write(`axiomcode mcp: ${error}\n The server starts, but every tool will say it cannot run the CLI.\n`); diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py index 2a8f095c7..0910ab363 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py @@ -385,7 +385,7 @@ def ensure_graph(repo, db): build = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'axiomcode-build') if not os.path.exists(build): return False print(f"no graph for {repo} yet — building one (this is the only slow call; later ones read it) …", file=sys.stderr) - r = subprocess.run(['bash', build, repo]) + r = subprocess.run([os.environ.get('AXIOMCODE_BASH') or 'bash', build, repo]) return r.returncode == 0 and os.path.exists(db) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index 375638688..175d38172 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -26,7 +26,7 @@ Environment: AXIOMCODE_NO_REFRESH=1 turns every trigger off; AXIOMCODE_REFRESH_DEBOUNCE (seconds, default 2) is the quiet window; AXIOMCODE_FRESH_WAIT (seconds, default 10) is how long a query verb waits.""" -import hashlib, json, os, subprocess, sys, time +import errno, hashlib, json, os, subprocess, sys, time H = os.path.dirname(os.path.abspath(__file__)) @@ -166,7 +166,10 @@ def _flock(fd, block): import msvcrt while True: try: msvcrt.locking(fd, msvcrt.LK_NBLCK, 1); return True - except OSError: + except OSError as e: + # Git Bash's fd 9 is not a descriptor in a native python.exe, which inherits only 0-2 (#1331). There is + # no lock to take; waiting for one looped forever, so the build runs unlocked, as a single build did. + if e.errno == errno.EBADF: return True if not block: return False time.sleep(0.5) except OSError: return False @@ -250,7 +253,7 @@ def worker(repo): t0 = time.time(); write_state(repo, state='building', started=t0, files=sum(len(x) for x in c)) if any(c): print(f"{time.strftime('%H:%M:%S')} refresh: {sum(len(x) for x in c)} file(s) changed ({', '.join((c[0] + c[1] + c[2])[:5])}) — rebuilding", flush=True) else: print(f"{time.strftime('%H:%M:%S')} refresh: HEAD moved — moving the baseline to it", flush=True) - r = subprocess.run(['bash', os.path.join(H, 'axiomcode-build'), repo], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + r = subprocess.run([os.environ.get('AXIOMCODE_BASH') or 'bash', os.path.join(H, 'axiomcode-build'), repo], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) took = round(time.time() - t0, 1) if r.returncode != 0: write_state(repo, state='failed', finished=time.time(), seconds=took, failed_table=fp, diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index e6d608a9a..3c9e75a3d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -47,6 +47,22 @@ # .js test scripts is taken for JavaScript); --src limits the analysed tree (e.g. src); --library names dependency roots. H="$(cd "$(dirname "$0")" && pwd)" +# PYTHON UNDER ANOTHER NAME (#1331). Every verb runs `python3`, which a python.org install on Windows does not +# provide, and which on a desktop Windows is the Store placeholder. Started from node (the `axiomcode` command, the +# MCP server, the hooks) find-python.js has already put pyshim/python3 first on PATH and set AXIOMCODE_PYTHON_EXE. +# Run straight from a shell, as the skill says to when there are no MCP tools, this makes the same choice: the +# first of AXIOMCODE_PYTHON, python and py -3 that runs. Off Windows a python3 on PATH is taken without a probe. +if [ -z "${AXIOMCODE_PYTHON_EXE:-}" ] && + case "${OSTYPE:-}" in msys*|cygwin*) ! python3 -c '' >/dev/null 2>&1;; *) ! command -v python3 >/dev/null 2>&1;; esac; then + for p in "${AXIOMCODE_PYTHON:-}" python py; do + [ -n "$p" ] || continue; a=(); [ "$p" = py ] && a=(-3) + e="$("$p" ${a[@]+"${a[@]}"} -c 'import sys; print(sys.executable)' 2>/dev/null | tr -d '\r')" || e="" + if [ -n "$e" ]; then export AXIOMCODE_PYTHON_EXE="${e//\\//}" PATH="$H/pyshim:$PATH"; break; fi + done +fi +# and in UTF-8 mode, as find-python.js sets it: otherwise a piped answer is written in cp1252 and the first → raises +case "${OSTYPE:-}" in msys*|cygwin*) : "${PYTHONUTF8:=1}"; export PYTHONUTF8;; esac + # A VERB ANSWERS; IT DOES NOT HAND BACK AN INSTRUCTION. Reached through this dispatcher — which is # what `axiomcode` on $PATH and the MCP server both go through — a query on a repository with no # graph builds one and then answers, instead of exiting with "run `axiomcode index` first". That diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index 12ab8e8a9..5a92793d1 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -24,6 +24,9 @@ if [ -z "${AXIOMCODE_ENGINE:-}" ]; then E="$H"; while [ "$E" != "/" ] && [ ! -x # not, since it is not in git -- and that clone was taken over the built engine `npm i -g` installed. An # unbuilt checkout is still used when nothing else is found, so a developer keeps the "parser not built" # advice; an engine named in AXIOMCODE_ENGINE is used as given whenever it is one. +# python3's sqlite3 module, not the sqlite3 CLI: python3 is a stated requirement, the CLI is not, and stock Ubuntu +# and Git for Windows ship without it. +has_symbols() { python3 -c 'import sqlite3, sys; sys.exit(0 if sqlite3.connect(sys.argv[1]).execute("SELECT 1 FROM sqlite_master WHERE name=?", ("symbols",)).fetchone() else 1)' "$1" 2>/dev/null; } engine_ok() { [ -x "$1/bin/axiomcode" ] && [ -d "$1/graph" ] && [ -f "$1/package.json" ]; } engine_built() { engine_ok "$1" && [ -f "$1/parser/dist/index.js" ]; } if ! { if [ -n "$ENGINE_SET" ]; then engine_ok "$AXIOMCODE_ENGINE"; else engine_built "$AXIOMCODE_ENGINE"; fi; }; then @@ -150,11 +153,11 @@ BASE_SAVED="" # from the edit, is. A commit that changed no file moves the stamp, not the graph. if [ -f "$OUT/graph.sqlite" ] && [ -f "$OUT/stamp" ] && python3 "$H/ax_fresh.py" uptodate "$REPO" "$LANG_ARG" "${AXIOMCODE_SRC:-}" "${STAMP#"$PREFIX"}"; then if [ "$(cat "$OUT/stamp")" != "$STAMP" ]; then echo "$STAMP" > "$OUT/stamp"; INDEXED_TREE="$OLD_INDEXED"; set_base "$OLD_INDEXED"; PREV=""; keep_base_graph; commit_base; fi - sqlite3 "$OUT/graph.sqlite" "SELECT 1 FROM sqlite_master WHERE name='symbols'" | grep -q 1 || python3 "$H/axiomcode-index" "$REPO" + has_symbols "$OUT/graph.sqlite" || python3 "$H/axiomcode-index" "$REPO" echo "graph up to date ($LANG_ARG) at $OUT/graph.sqlite"; exit 0 fi if [ ! -f "$OUT/files.json" ] && [ -f "$OUT/stamp" ] && [ "$(cat "$OUT/stamp")" = "$STAMP" ] && [ -f "$OUT/graph.sqlite" ] && [ -z "$(cd "$REPO" && git status --porcelain 2>/dev/null | head -1)" ]; then - sqlite3 "$OUT/graph.sqlite" "SELECT 1 FROM sqlite_master WHERE name='symbols'" | grep -q 1 || python3 "$H/axiomcode-index" "$REPO" + has_symbols "$OUT/graph.sqlite" || python3 "$H/axiomcode-index" "$REPO" python3 "$H/axiomcode-impact" --warm "$REPO" || true # export the graph's facts now (~9 s on 1,373 files): the first # query pays it otherwise, and for the plugin that first query is inside hooks/changes.py's timeout=14, several at once. python3 "$H/dl_program.py" || true # compile the query rules once, here where ~20 s is noise against the build: @@ -214,8 +217,14 @@ AXIOMCODE_INDEX_DB="$DB" python3 "$H/axiomcode-index" "$REPO" || { restore; echo # the tree and commit it describes. Written before the swap: a write to the live graph later would change its mtime, # which keys the impact-facts cache, and cost the next query a re-export. REASON="$(printf %s "${AXIOMCODE_REFRESH_REASON:-axiomcode index}" | tr -d "'")" -sqlite3 "$DB" "DELETE FROM index_meta WHERE key IN ('refreshed_at','refresh_reason','refreshed_tree','refreshed_commit'); - INSERT INTO index_meta VALUES ('refreshed_at','$(date -u +%Y-%m-%dT%H:%M:%SZ)'),('refresh_reason','$REASON'),('refreshed_tree','$INDEXED_TREE'),('refreshed_commit','$HEAD_SHA');" 2>/dev/null || true +python3 - "$DB" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$REASON" "$INDEXED_TREE" "$HEAD_SHA" <<'PY' 2>/dev/null || true +import sqlite3, sys +db, at, reason, tree, commit = sys.argv[1:6] +with sqlite3.connect(db) as c: + c.execute("DELETE FROM index_meta WHERE key IN ('refreshed_at','refresh_reason','refreshed_tree','refreshed_commit')") + c.executemany("INSERT INTO index_meta VALUES (?,?)", [('refreshed_at', at), ('refresh_reason', reason), + ('refreshed_tree', tree), ('refreshed_commit', commit)]) +PY echo "$STAMP" > "$OUT/stamp" if [ -n "$INDEXED_TREE" ]; then echo "$INDEXED_TREE" > "$OUT/indexed-tree"; else rm -f "$OUT/indexed-tree"; fi set_base "$INDEXED_TREE" @@ -223,7 +232,13 @@ point "$DB"; rm -f "$OUT/.live.sqlite" if [ -f "$OUT/.files.json.new" ]; then mv "$OUT/.files.json.new" "$OUT/files.json"; else rm -f "$OUT/files.json"; fi keep_base_graph; commit_base rm -rf "$PREV" -sqlite3 -column "$OUT/graph.sqlite" "SELECT tier, count(*) AS edges FROM call_edges GROUP BY tier ORDER BY edges DESC;" +python3 - "$OUT/graph.sqlite" <<'PY' +import sqlite3, sys +rows = sqlite3.connect(sys.argv[1]).execute("SELECT tier, count(*) FROM call_edges GROUP BY tier ORDER BY 2 DESC").fetchall() +w = max([len('tier')] + [len(t) for t, _ in rows]) +print(f"{'tier':<{w}} edges") +for t, n in rows: print(f"{t:<{w}} {n}") +PY python3 "$H/axiomcode-impact" --warm "$REPO" || true # export the graph's facts now (~9 s on 1,373 files): the first # query pays it otherwise, and for the plugin that first query is inside hooks/changes.py's timeout=14, several at once. python3 "$H/dl_program.py" || true # compile the query rules once, here where ~20 s is noise against the build: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index 69f49a66f..cfd5a4f2c 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -117,7 +117,7 @@ class Changed: def rel(self, f): f = os.path.realpath(f) if os.path.exists(f) else f r = os.path.relpath(f, self.repo) if os.path.isabs(f) else f - return r + return r.replace(os.sep, '/') # ── the two texts of a file ──────────────────────────────────────────────────────────────────────────────── def texts(self, rel, mode, rng): if mode == 'range': diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 4fd8d26c2..da189d673 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -386,7 +386,7 @@ def main(argv): elif a == '--seeds' and i + 1 < len(argv): seeds_wanted = int(argv[i + 1]); i += 2 # --in is REPEATABLE and takes a comma-separated list: a change that spans two roots has to be # askable in one call (#1029). They are combined, never intersected. - elif a == '--in' and i + 1 < len(argv): scopes += [x.strip() for x in argv[i + 1].split(',') if x.strip()]; i += 2 + elif a == '--in' and i + 1 < len(argv): scopes += [x.strip().replace('\\', '/') for x in argv[i + 1].split(',') if x.strip()]; i += 2 # the index stores '/'; a Windows user may type a backslash elif a == '--in-offered': scope_offered = True; i += 1 elif a == '--source': show_source = True; i += 1 elif a == '--json': as_json = True; i += 1 @@ -419,7 +419,7 @@ def main(argv): # does not filter. It does not weight either: boosting seeds inside it and penalising files outside # it was swept over 57 real commits and was worth at most 0.012 recall@10 against not doing it, which # is noise at that sample size. The menu is worth printing for the reader; it is not worth ranking on. - rank = rank_dirs(g, scope_terms(g, task)) if not scopes else None + rank = rank_dirs(g, scope_terms(g, task)) if not scopes else None; flat = False if not scopes: # One candidate is not a choice: take it and SAY SO, rather than refusing and being handed back # the only path there was. This is NOT the offered-scope case above, so it is not marked @@ -430,6 +430,11 @@ def main(argv): if sole: scopes = [sole] print(f"scope: {sole} (the only package this graph indexes; --in overrides)\n") + elif g.sym and not dirs_with_counts(g): + # every indexed file sits at the root, so the root is the one scope there is, and it narrows nothing. + # Refusing here offered an empty menu: a correction the reader could not act on (#1345) + flat = True + print("scope: the repository root (every indexed file sits there; --in overrides)\n") elif rank: # Several candidates, and a caller holding an issue and no symbol — which is the caller this # verb was written for, and by construction the one who cannot name the scope. Refusing here @@ -452,7 +457,7 @@ def main(argv): + ("…" if n > 4 else "") + ")") print(" no --in was given, so NOTHING below is narrowed to it — " "pass --in to narrow.\n") - bad = require_scope(g, scopes, terms, rank=rank, flag=' --in-offered') + bad = None if flat else require_scope(g, scopes, terms, rank=rank, flag=' --in-offered') if bad is not None: return bad # a symbol is kept when it is under ANY of the scopes -- the union, because the caller naming two roots diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph index e96593c58..bd414596c 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph @@ -46,7 +46,7 @@ def export(repo, page=None): def rel(p): a = p if os.path.isabs(p) else os.path.join(src, p) a = os.path.realpath(a) if os.path.exists(a) else os.path.normpath(a) - r = os.path.relpath(a, repo); return p.lstrip('/') if r.startswith('..') else r + r = os.path.relpath(a, repo).replace(os.sep, '/'); return p.lstrip('/') if r.startswith('..') else r # ── declarations: types and methods, with the display names the query skill uses when the index is there ── nodes = {} @@ -187,7 +187,7 @@ def build(repo, page, library=None): cands = [os.path.join(HERE, 'axiomcode-build'), os.path.join(os.environ.get('AXIOMCODE_ENGINE', ''), 'skills', 'axiomcode', 'scripts', 'axiomcode-build')] b = next((os.path.realpath(c) for c in cands if os.path.isfile(c)), None) if not b: sys.exit("axiomcode-build not found beside this script (set AXIOMCODE_ENGINE to the engine checkout)") - r = subprocess.run([b, repo]) + r = subprocess.run([os.environ.get('AXIOMCODE_BASH') or 'bash', b, repo]) # a bash script: Windows cannot exec it itself if r.returncode: sys.exit(r.returncode) export(repo, page) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index c98a9ba84..30f278e10 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -619,7 +619,7 @@ class Impact: with open(fp, 'rb') as fh: head = fh.read(2048) if b'\0' in head: continue except OSError: continue - out.append(os.path.relpath(fp, self.g.repo)) + out.append(os.path.relpath(fp, self.g.repo).replace(os.sep, '/')) if len(out) > 4000: break self._nsf = sorted(out); return self._nsf def nonsource_hits(self, names): @@ -1237,8 +1237,9 @@ class Impact: if os.environ.get('AXIOMCODE_BACKEND'): print('backend=datalog', file=sys.stderr) prog = self.program() # the compiled rules, or the interpreter if prog[0] == 'souffle' and not shutil.which('souffle'): - die("this query needs soufflé and none is installed (brew install souffle-lang/souffle/souffle) — or a compiled\n" - f" rule binary in {os.path.relpath(os.path.join(HERE, 'dl', '.cache'))}/, which runs without it") + die(f"no compiled impact.dl for these rules on this machine: the {dl_program.SCOPE}/engine-{dl_program.npm_platform()} package\n" + " ships one for each release (reinstall @axiomcode/code-graph so it is fetched), or install soufflé\n" + " (brew install souffle-lang/souffle/souffle) to compile or interpret the rules here") prof('query facts written'); r = subprocess.run(prog + ['-F', F, '-D', O], capture_output=True, text=True); prof('souffle returned') if r.returncode: die("souffle failed:\n" + r.stderr[-1200:]) out = {} @@ -1389,7 +1390,7 @@ def main(argv): i = args.index(flag); v = conv(args[i + 1]); del args[i:i + 2] if flag == '--depth': depth = v elif flag == '--limit': limit = v - elif flag == '--in': IN = v + elif flag == '--in': IN = v.replace('\\', '/') # the index stores '/'; a Windows user may type a backslash else: kind = v repo = '.' if args and os.path.isdir(args[-1]) and (len(args) > 1 or from_text is not None or want_warm): repo = args.pop() diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 073b0d16a..14817e42a 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -45,7 +45,7 @@ def rel(p): if not p: return p a = p if os.path.isabs(p) else os.path.join(SRC, p) a = os.path.realpath(a) if os.path.exists(a) else os.path.normpath(a) - r = os.path.relpath(a, REPO) + r = os.path.relpath(a, REPO).replace(os.sep, '/') # '/' on every platform: every reader splits on it return p.lstrip('/') if r.startswith('..') else r # never DEGRADE: an index built from the IR must not be rebuilt without it (the intermediates are routinely cleaned # to save disk). If only newer tables are missing, add them empty and keep everything else. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 976d9ed6b..5bd0c2593 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -462,7 +462,7 @@ class G: if self.has('paths'): r = self.q("SELECT rel FROM paths WHERE raw = ?", raw) if r: return r[0]['rel'] - return os.path.relpath(raw, self.repo) if os.path.isabs(raw) and raw.startswith(self.repo) else raw + return os.path.relpath(raw, self.repo).replace(os.sep, '/') if os.path.isabs(raw) and raw.startswith(self.repo) else raw # ── facts: exported once per graph, reused while graph.sqlite is unchanged ─────────────────────────────── def export(self): @@ -554,7 +554,8 @@ def run_dl(g, queries, programs=('path.dl',)): # is only needed for the ones that fall back to the interpreter. progs = {p: dl_program.program(p) for p in programs} if any(v[0] == 'souffle' for v in progs.values()) and not shutil.which('souffle'): - die("souffle is not installed (brew install souffle-lang/souffle/souffle)") + die(f"no compiled path rules for this version on this machine: the {dl_program.SCOPE}/engine-{dl_program.npm_platform()} package\n" + " ships them for each release (reinstall @axiomcode/code-graph), or install soufflé (brew install souffle-lang/souffle/souffle)") F = tempfile.mkdtemp(prefix='axpath-'); O = tempfile.mkdtemp(prefix='axpath-out-') for n in ('byname', 'named'): os.symlink(os.path.join(g.facts, n + '.facts'), os.path.join(F, n + '.facts')) if g.EXTRA: @@ -617,6 +618,54 @@ def verify(g, chain, srcs, dst, adj): if dst in srcs: found = 0 return bad, found +ROUTE_CAP = 1000 # past this many routes --every says "1000+" and asks for a narrower endpoint + +def k_shortest_routes(E, starts, ends, k): + """the k shortest simple routes from any start to any end, shortest first, and whether more exist (Yen's algorithm). + + Each route costs a bounded number of breadth-first searches over the subgraph, so the work grows with k, not with + the number of routes the subgraph holds. Enumerating partial paths best-first instead kept every one of them + alive: through a cycle they multiply with every hop, and a query with fewer than k short routes ran out of + memory (#1341). A route stops at the first end it reaches, and a start is never a route by itself.""" + import heapq + SRC, DST = '\0src', '\0dst' # never equal to a method id + adj = {n: [b for b, _ in bs] for n, bs in E.items()} + for n in ends: adj[n] = [DST] # a route ends at the first end it reaches + adj[SRC] = list(starts) + def bfs(a, banned_nodes, banned_edges): + prev = {a: None}; frontier = [a] + while frontier: + nxt = [] + for u in frontier: + for v in adj.get(u, ()): + if v in prev or v in banned_nodes or (u, v) in banned_edges: continue + prev[v] = u + if v == DST: + path = [v] + while prev[path[-1]] is not None: path.append(prev[path[-1]]) + return path[::-1] + nxt.append(v) + frontier = nxt + return None + first = bfs(SRC, set(), set()) + if not first: return [], False + A, B, seen = [first], [], {tuple(first)} + real = lambda p: p[1:-1] + found = [real(first)] if len(real(first)) > 1 else [] + while len(found) <= k: + last = A[-1] + for i in range(len(last) - 2): + spur, root = last[i], last[:i + 1] + banned_edges = {(p[i], p[i + 1]) for p in A if len(p) > i + 1 and p[:i + 1] == root} + tail = bfs(spur, set(root[:-1]), banned_edges) + if tail: + cand = root[:-1] + tail + if tuple(cand) not in seen: seen.add(tuple(cand)); heapq.heappush(B, (len(cand), cand)) + if not B: break + _, nxt = heapq.heappop(B); A.append(nxt) + if len(real(nxt)) > 1: found.append(real(nxt)) + return found[:k], len(found) > k + def every_route(g, res, q, srcs, dsts, max_paths, adj): """the subgraph of every method on ANY chain from a source to a target, and the simple paths through it (bounded)""" E = collections.defaultdict(list); nodes = set() @@ -633,16 +682,15 @@ def every_route(g, res, q, srcs, dsts, max_paths, adj): # enumerate simple paths, shortest first, up to max_paths — each one re-checked against the edge set like the nearest chain out = []; edge_set = {(a, b): t for a, b, t in g.edges()} starts = sorted(n for n in nodes if n in srcs); ends = {n for n in nodes if n in dsts} - import heapq - pq = [(0, i, [s0], None) for i, s0 in enumerate(starts)]; cnt = len(starts) - while pq and len(out) < max_paths: - d, _, pth, _ = heapq.heappop(pq) - if pth[-1] in ends and len(pth) > 1: out.append(pth); continue - if pth[-1] in ends and len(pth) == 1 and pth[-1] in dsts and len(starts) == 1: pass - for b, t in E.get(pth[-1], []): - if b in pth: continue # simple: no node twice - cnt += 1; heapq.heappush(pq, (d + 1, cnt, pth + [b], t)) - print(f" {len(out)} simple path(s)" + (f" (first {max_paths}; --paths N for more)" if len(pq) and len(out) >= max_paths else '') + ", shortest first:") + # count the routes up to ROUTE_CAP so the reader knows how many there are, and print the first max_paths of them + cap = max(ROUTE_CAP, max_paths) + found, more = k_shortest_routes(E, starts, ends, cap) + out = found[:max_paths] + if more: + print(f" {cap}+ simple paths — too many to list; narrow an endpoint (Owner.method, file:line or --in ) for a" + f" complete list. The {len(out)} shortest follow (--paths N for more):") + else: + print(f" {len(found)} simple path(s)" + (f", the {len(out)} shortest follow (--paths N for more)" if len(out) < len(found) else '') + ", shortest first:") for pth in out: hops = [(pth[i], pth[i + 1]) for i in range(len(pth) - 1)] bad = [h for h in hops if h not in edge_set] @@ -1065,7 +1113,7 @@ if __name__ == '__main__': limit = 10; IN = None; every = '--every' in args; args = [a for a in args if a != '--every']; max_paths = 20 if '--paths' in args: i = args.index('--paths'); max_paths = int(args[i + 1]); every = True; del args[i:i + 2] if '--limit' in args: i = args.index('--limit'); limit = int(args[i + 1]); del args[i:i + 2] - if '--in' in args: i = args.index('--in'); IN = args[i + 1]; del args[i:i + 2] + if '--in' in args: i = args.index('--in'); IN = args[i + 1].replace('\\', '/'); del args[i:i + 2] if len(args) < 2: die("usage: axiomcode path [] [--all] [--limit N] [--in ]") if args[0] == '*' and args[1] == '*': die("one endpoint must be named: path '*' X (everything that reaches X) or path X '*' (everything X reaches)") diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index 44780f021..c1bcb3745 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -176,7 +176,7 @@ def package_tier(repo, changed_files, limit=6000): dirs[:] = [d for d in dirs if d not in ('node_modules', '.git', 'dist', 'build', 'target', '.axiomcode')] for f in fs: if not f.endswith(('.ts', '.tsx', '.js', '.mjs', '.java', '.py', '.cs')): continue # a list that omits an extension makes both tiers below silently inert for that language (#1083's shape) - rel = os.path.relpath(os.path.join(root, f), repo) + rel = os.path.relpath(os.path.join(root, f), repo).replace(os.sep, '/') if not TESTY.search(rel): continue seen += 1 if seen > limit: return dict(by_pkg), {'files': sorted(by_name)}, pkgs diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py b/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py index 584945e08..d19b61e1c 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py @@ -15,12 +15,14 @@ which was the whole of that benchmark's wall-clock regression. A killed compile leaves no `.nocompile` marker either (it did not fail, it was killed), so it cannot even record its own defeat. """ -import hashlib, os, shutil, subprocess, sys +import hashlib, os, platform, shutil, subprocess, sys HERE = os.path.dirname(os.path.abspath(__file__)) DL_DIR = os.path.join(HERE, 'dl') CACHE = os.path.join(DL_DIR, '.cache') HOOKS_DIR = os.path.abspath(os.path.join(HERE, '..', '..', '..', 'hooks')) +SCOPE = '@axiomcode' # graph/pipeline/engine.conf's ENGINE_PACKAGE_SCOPE +EXE = '.exe' if os.name == 'nt' else '' def souffle_include(): @@ -59,15 +61,71 @@ def resolve(dl): return os.path.join(DL_DIR, dl) +def rules_id(dl): + """what a compiled program is keyed by: its rules, and nothing else. CI names the binaries it ships with this same + function (`dl_program.py --print-id`), so a shipped binary and a local compile agree on which rules they hold.""" + return hashlib.sha1(open(dl, 'rb').read()).hexdigest()[:16] + + +def npm_platform(): + """this machine in npm's spelling (process.platform-process.arch), which is how the engine packages are named""" + o = {'darwin': 'darwin', 'win32': 'win32', 'cygwin': 'win32', 'msys': 'win32'}.get(sys.platform, 'linux' if sys.platform.startswith('linux') else None) + a = {'x86_64': 'x64', 'amd64': 'x64', 'arm64': 'arm64', 'aarch64': 'arm64'}.get(platform.machine().lower()) + return f'{o}-{a}' if o and a else None + + +def engine_roots(): + """where the installed engine package can be found from, in order: this plugin (inside the npm package, or a + checkout with its own node_modules), the engine named by AXIOMCODE_ENGINE, the `axiomcode` on PATH. A plugin a host + copied under its own directory has no node_modules above it, which is why the last two exist.""" + roots = [HERE] + if os.environ.get('AXIOMCODE_ENGINE'): roots.append(os.environ['AXIOMCODE_ENGINE']) + b = shutil.which('axiomcode') + if b: roots.append(os.path.dirname(os.path.dirname(os.path.realpath(b)))) + return roots + + +def packaged(stem, key): + """the query binary the engine package for this machine ships, when it was built from exactly these rules. Walks + up from each root the way node resolves a package, so a local node_modules and a global install both work. A + package holding other rules is reported once and not used — running it would answer from rules this plugin is not.""" + plat = npm_platform() + if not plat: return None + seen = set() + for root in engine_roots(): + d = os.path.abspath(root) + while True: + q = os.path.join(d, 'node_modules', SCOPE, f'engine-{plat}', 'queries') + if q not in seen and os.path.isdir(q): + seen.add(q) + binp = os.path.join(q, f'axiomcode-query-{stem}{EXE}') + try: have = open(os.path.join(q, f'{stem}.id')).read().strip() + except OSError: have = '' + if have == key and os.path.isfile(binp): + if EXE == '' and not os.access(binp, os.X_OK): + try: os.chmod(binp, 0o755) + except OSError: pass + return binp + if have: print(f" ! {SCOPE}/engine-{plat} holds {stem}.dl at {have}, these rules are {key} — not using it", file=sys.stderr) + up = os.path.dirname(d) + if up == d: break + d = up + return None + + def program(dl, verbose=True): - """the argv prefix to run this program: [], or ['souffle',
] when there is no compiler, when a + """the argv prefix to run this program: [] when it was built from these rules (no + soufflé, no compiler — what an npm install gets), else [], or ['souffle',
] when there is no compiler, when a compile has already failed here (the failure is recorded, so a broken toolchain costs one attempt rather than one per query — the visible half of #816), or under AXIOMCODE_INTERPRET.""" dl = resolve(dl) # keyed by the RULES alone. The soufflé runtime is compiled INTO the binary, so the version that generated it does # not matter at run time; and keying on it meant a cached (or shipped) binary was unusable without soufflé present. - key = hashlib.sha1(open(dl, 'rb').read()).hexdigest()[:16] + key = rules_id(dl) stem = os.path.splitext(os.path.basename(dl))[0] + if not os.environ.get('AXIOMCODE_INTERPRET'): + shipped = packaged(stem, key) + if shipped: return [shipped] binp = os.path.join(CACHE, f'{stem}-{key}'); nope = binp + '.nocompile' if os.path.exists(binp): return [binp] if os.path.exists(nope) or not shutil.which('c++') or not shutil.which('souffle') or os.environ.get('AXIOMCODE_INTERPRET'): @@ -109,12 +167,16 @@ def warm(verbose=True): done = [] for dl in all_programs(): pre = program(dl, verbose=False) - done.append((os.path.basename(dl), 'cached' if pre and not pre[0].endswith('souffle') else 'interpreter')) + how = 'interpreter' if not pre or pre[0] == 'souffle' else 'cached' if pre[0].startswith(CACHE) else 'packaged' + done.append((os.path.basename(dl), how)) if verbose: - ok = sum(1 for _, s in done if s == 'cached') + ok = sum(1 for _, s in done if s != 'interpreter') print(f"datalog rules ready: {ok}/{len(done)} compiled (" + ', '.join(f'{n}={s}' for n, s in done) + ")") return done if __name__ == '__main__': - warm() + if sys.argv[1:2] == ['--print-id']: + for a in sys.argv[2:]: print(rules_id(a if os.path.exists(a) else resolve(a))) + else: + warm() diff --git a/plugins/axiomcode/skills/axiomcode/scripts/pyshim/python3 b/plugins/axiomcode/skills/axiomcode/scripts/pyshim/python3 new file mode 100755 index 000000000..0b970a961 --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/pyshim/python3 @@ -0,0 +1,5 @@ +#!/bin/sh +# python3 for a machine whose Python has another name: python.exe or py.exe from python.org on Windows (#1331). +# find-python.js, or the fallback at the head of scripts/axiomcode, puts this directory first on PATH only after +# setting AXIOMCODE_PYTHON_EXE to an interpreter that ran. +exec "${AXIOMCODE_PYTHON_EXE:?pyshim/python3 is on PATH but AXIOMCODE_PYTHON_EXE is not set}" "$@" diff --git a/plugins/axiomcode/skills/axiomcode/scripts/viewer.html b/plugins/axiomcode/skills/axiomcode/scripts/viewer.html index a6ab05c34..4ca6ef0f2 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/viewer.html +++ b/plugins/axiomcode/skills/axiomcode/scripts/viewer.html @@ -139,7 +139,7 @@

focus depth // ── what is shown: type toggles · only-flags · edge toggles · focus depth · search filter ────────────────────── const only = { u: false, e: false, t: false }, edges = { known: true, inf: true, lib: true, ext: true, tree: true }; -let hops = 0, filter = null /* Set of ids from search or focus depth */, sel = null, hover = null, focus = null, layoutSet = null; +let K0 = 1 /* the zoom fitAll chose */, hops = 0, filter = null /* Set of ids from search or focus depth */, sel = null, hover = null, focus = null, layoutSet = null; const isEntry = n => n.e && !(n.e.length === 1 && n.e[0] === 'test'); const passes = n => show[KIND(n)] && (show.test || !n.t) && (!only.u || n.u) && (!only.e || isEntry(n)) && (!only.t || n.t) && (!filter || filter.has(n.id)); let VIS = DATA.nodes, VISSET = null; @@ -169,7 +169,8 @@

focus depth function centre(n, k) { view.k = k || Math.max(view.k, 3); view.x = wrap.clientWidth / 2 - n.x * view.k; view.y = wrap.clientHeight / 2 - n.y * view.k; mark(); } function pick(px, py) { const x = (px - view.x) / view.k, y = (py - view.y) / view.k, gx = (x / CELL) | 0, gy = (y / CELL) | 0; let best = null, bd = 1e9; const tol = 7 / view.k; for (let i = -1; i <= 1; i++) for (let j = -1; j <= 1; j++) for (const n of (grid.get((gx + i) + ',' + (gy + j)) || [])) { const d = Math.hypot(n.x - x, n.y - y) - rad(n); if (d < tol && d < bd) { bd = d; best = n; } } return best; } const neighbours = n => { const s = new Set([n.id]); for (const e of (OUT.get(n.id) || [])) s.add(e.t); for (const e of (IN.get(n.id) || [])) s.add(e.s); for (const e of (LOUT.get(n.id) || [])) s.add(e.t); for (const e of (LIN.get(n.id) || [])) s.add(e.s); for (const t of (XOUT.get(n.id) || [])) s.add(t); for (const t of (XIN.get(n.id) || [])) s.add(t); if (n.p) s.add(n.p); for (const c of (KIDS.get(n.id) || [])) s.add(c.id); return s; }; -function chip(n, k, colour, big) { const t = short(n); ctx.font = `${big ? '600 ' : ''}${(big ? 11.5 : 10.5) / k}px ui-monospace, Menlo, monospace`; const w = ctx.measureText(t).width + 12 / k, h = 16 / k, x = n.x - w / 2, y = n.y - rad(n) - 14 / k - h; +function chip(n, k, colour, big, taken, rim) { const t = short(n); ctx.font = `${big ? '600 ' : ''}${(big ? 11.5 : 10.5) / k}px ui-monospace, Menlo, monospace`; const w = ctx.measureText(t).width + 12 / k, h = 16 / k, x = n.x - w / 2, y = n.y - rad(n) - 14 / k - h; + const g = 3 / k; const hit = r => x < r[2] + g && r[0] < x + w + g && y < r[3] + g && r[1] < y + h + g; if (!big && (taken.some(hit) || (rim && rim.some(hit)))) return; taken.push([x, y, x + w, y + h]); // a label that would cover another is not drawn: the hover and the selection always are, and only ambient chips give way to the rim ctx.strokeStyle = colour; ctx.lineWidth = 1 / k; ctx.beginPath(); ctx.moveTo(n.x, n.y - rad(n)); ctx.lineTo(n.x, y + h); ctx.stroke(); ctx.fillStyle = 'rgba(11,13,20,.92)'; ctx.beginPath(); ctx.roundRect(x, y, w, h, 4 / k); ctx.fill(); ctx.stroke(); ctx.fillStyle = colour; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(t, n.x, y + h / 2); } @@ -200,6 +201,7 @@

focus depth else { ctx.fillStyle = colour; ctx.fill(); } } ctx.globalAlpha = 1; // directory names around the rim of the full disc + const rim = []; if (!filter) { const R = (libs.length ? R4 : R3) + 90, MODS = new Set(dirs.map(d => MOD(d.l))), placed = []; const name = d => { const s = d.vsegs, m = MOD(d.l), rest = m ? s.slice(1) : s; return [MODS.size > 1 && m ? m + ' · ' : '', (rest.slice(-2).join('/') || m || d.l) + (show.test && /\/src\/test/.test('/' + d.l) ? ' (test)' : '')]; }; const cand = dirs.filter(d => on(d.id) && (d.span * R * k >= 12 || d === hover || d === sel)).sort((a, b) => (b === sel || b === hover) - (a === sel || a === hover) || W(b) - W(a)); @@ -207,16 +209,16 @@

focus depth for (const d of cand) { const a = Math.atan2(d.dy, d.dx); const right = Math.cos(a) >= 0, y = R * Math.sin(a) * k; if (placed.some(p => p.right === right && Math.abs(p.y - y) < 14)) continue; let [pre, main] = name(d); const x = R * Math.cos(a) + (right ? 4 : -4) / k, dim = F && !F.has(d.id), sx = view.x + x * k, CW = wrap.clientWidth, fits = t => right ? sx + ctx.measureText(t).width * k < CW - 6 : sx - ctx.measureText(t).width * k > 6; if (!fits(pre + main)) { if (fits(main)) pre = ''; else continue; } placed.push({ right, y }); const wp = ctx.measureText(pre).width, wm = ctx.measureText(main).width; ctx.textAlign = 'left'; - const x0 = right ? x : x - wp - wm; ctx.fillStyle = dim ? 'rgba(129,140,248,.18)' : 'rgba(129,140,248,.45)'; ctx.fillText(pre, x0, R * Math.sin(a)); ctx.fillStyle = dim ? 'rgba(129,140,248,.3)' : 'rgba(165,180,252,.9)'; ctx.fillText(main, x0 + wp, R * Math.sin(a)); } } + const x0 = right ? x : x - wp - wm; ctx.fillStyle = dim ? 'rgba(129,140,248,.18)' : 'rgba(129,140,248,.45)'; ctx.fillText(pre, x0, R * Math.sin(a)); ctx.fillStyle = dim ? 'rgba(129,140,248,.3)' : 'rgba(165,180,252,.9)'; ctx.fillText(main, x0 + wp, R * Math.sin(a)); rim.push([x0, R * Math.sin(a) - 7 / k, x0 + wp + wm, R * Math.sin(a) + 7 / k]); } } // chips: only what matters — the selection and its neighbours, matches, hover, and the big things when zoomed in const chips = []; const TOPLIB = new Set(libs.filter(b => on(b.id)).sort((a, b) => b.sites - a.sites).slice(0, 4)); if (hover && on(hover.id)) chips.push([hover, '#e5e7eb', true]); if (sel && on(sel.id) && sel !== hover) chips.push([sel, '#f59e0b', true]); if (F && (mode === 'select' || mode === 'chain' || (mode === 'impact' && F.size <= 60))) for (const id of F) { if (chips.length > 70) break; const n = N.get(id); if (n !== sel && n !== hover && on(id)) chips.push([n, mode === 'impact' ? '#fb7185' : mode === 'chain' ? '#f59e0b' : '#22d3ee', false]); } if (search && !F) for (const id of search.matches) { if (chips.length > 70) break; const n = N.get(id); if (on(id) && n !== hover) chips.push([n, '#22d3ee', false]); } - if (!F && !search) for (const n of VIS) { if (chips.length > 90) break; if ((n.k === 'dir' && k > 1.2) || (n.k === 'file' && k > 2.2) || (n.k === 'type' && k > 3.2) || (n.k === 'method' && k > 7) || (n.k === 'lib' && (k > 1.6 || TOPLIB.has(n)))) chips.push([n, n.k === 'lib' ? LIBC : colOf(n), false]); } - if (few && !F && !search) for (const n of VIS) { if (chips.length > 120) break; if (!chips.some(c => c[0] === n)) chips.push([n, colOf(n), false]); } - for (const [n, c, big] of chips) chip(n, k, c, big); + const z = k / K0; // zoom relative to the fitted view: absolute thresholds labelled every node of a small repo on open + if (!F && !search) for (const n of VIS) { if (chips.length > 90) break; if ((n.k === 'dir' && z > 1.2) || (n.k === 'file' && z > 2.2) || (n.k === 'type' && z > 3.2) || (n.k === 'method' && z > 7) || (n.k === 'lib' && (z > 1.6 || TOPLIB.has(n)))) chips.push([n, n.k === 'lib' ? LIBC : colOf(n), false, true]); } + const taken = []; for (const [n, c, big, ambient] of chips) chip(n, k, c, big, taken, ambient ? rim : null); status(); } function status() { const S = document.getElementById('status'); if (!focus || focus.mode === 'hover' || focus.mode === 'select') { S.textContent = ''; return; } const n = N.get(focus.id); S.textContent = focus.mode === 'impact' ? `impact of ${n.l}: ${focus.set.size - 1} methods in ${focus.files} files can reach it through resolved calls` + (n.u ? ` · ${n.u} unresolved sites in its body make that a lower bound` : '') : `chain from ${n.l}: ${focus.path.length} hops, the heaviest resolved callee at each`; } function counts() { const c = {}; for (const n of VIS) { c[KIND(n)] = (c[KIND(n)] || 0) + 1; if (n.t) c.test = (c.test || 0) + 1; } document.getElementById('counts').textContent = `${VIS.length} of ${DATA.nodes.length} nodes shown` + (filter ? ' · filtered' : ''); document.querySelectorAll('#types .tog').forEach(t => t.querySelector('.n').textContent = c[t.dataset.k] || 0); } @@ -310,7 +312,7 @@

called by (${ins.length})

    ${ins.slice().sort((a, b) => b.w - a.w).sl ED.querySelectorAll('.tog').forEach(t => t.onclick = () => { edges[t.dataset.e] = !edges[t.dataset.e]; t.classList.toggle('off', !edges[t.dataset.e]); mark(); }); const H = document.getElementById('hops'); H.innerHTML = ['all', 1, 2, 3].map(h => `${h === 'all' ? 'all' : h + ' hop' + (h > 1 ? 's' : '')}`).join(''); H.querySelectorAll('span').forEach(s => s.onclick = () => { hops = +s.dataset.h; H.querySelectorAll('span').forEach(x => x.classList.toggle('on', x === s)); applyFilter(); if (!filter) fitAll(); panel(); }); -function fitAll() { for (const n of DATA.nodes) { n.x = n.dx; n.y = n.dy; } rebuildGrid(); const w = wrap.clientWidth, h = wrap.clientHeight, ext = (libs.length ? R4 : R3) + 90; view.k = Math.max(.02, Math.min((w / 2 - Math.min(150, w * .12)) / ext, (h / 2 - 24) / ext)); /* room for the rim labels, drawn at a fixed screen size */ view.x = w / 2; view.y = h / 2; mark(); } +function fitAll() { for (const n of DATA.nodes) { n.x = n.dx; n.y = n.dy; } rebuildGrid(); const w = wrap.clientWidth, h = wrap.clientHeight, ext = (libs.length ? R4 : R3) + 90; view.k = Math.max(.02, Math.min((w / 2 - Math.min(150, w * .12)) / ext, (h / 2 - 24) / ext)); /* room for the rim labels, drawn at a fixed screen size */ view.x = w / 2; view.y = h / 2; K0 = view.k; mark(); } // ── search (right): type to see matches; Enter or a click shows ONLY the matches (and their hops) ───────────── const q = document.getElementById('q'), hits = document.getElementById('hits'), sbox = document.getElementById('search'); let cur = []; diff --git a/tests/manifests.py b/tests/manifests.py index d24caae49..679f91059 100644 --- a/tests/manifests.py +++ b/tests/manifests.py @@ -27,6 +27,7 @@ only in skills/ at that root, so skills/axiomcode/ holds a copy of the skill's text (packaging/copies.py). No server is started by `bash` or `python3`: on Windows a bare `bash` is WSL's or nothing and `python3` is a Store placeholder, and a manifest has no per-platform variant, so every one starts `node` (#1233). + Hooks too: each is `node hooks/run.js .py`, which finds the Python to run it under (#1331). python3 tests/manifests.py """ @@ -34,6 +35,13 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PLUGIN = os.path.join(ROOT, 'plugins', 'axiomcode') +HOOKS = os.path.join(PLUGIN, 'hooks') + + +def runs_a_hook(command, runner, hooks): + """command is `node "" .py`, and the runner and the hook both exist""" + m = re.fullmatch(r'node "' + re.escape(runner) + r'" (\w+\.py)', command) + return bool(m) and os.path.isfile(os.path.join(hooks, 'run.js')) and os.path.isfile(os.path.join(hooks, m.group(1))) def load(*parts): @@ -103,9 +111,9 @@ def gemini_path(value): for event, groups in load('plugins', 'axiomcode', 'hooks', 'hooks.json')['hooks'].items(): for group in groups: for hook in group['hooks']: - script = re.search(r'\$\{CLAUDE_PLUGIN_ROOT\}(/[^"\s]+)', hook['command']) - if not script or not os.path.isfile(PLUGIN + script.group(1)): - bad.append(f"hooks.json {event}: {hook['command']!r} names no script that exists") + if not runs_a_hook(hook['command'], '${CLAUDE_PLUGIN_ROOT}/hooks/run.js', HOOKS): + bad.append(f"hooks.json {event}: {hook['command']!r} is not `node /hooks/run.js .py` " + "naming a hook that exists; python3 is not a program on Windows (#1331)") # Cursor: its marketplace leads to the plugin, and its manifest agrees with the others and names files # that exist once ${CURSOR_PLUGIN_ROOT} is the plugin directory. @@ -146,9 +154,9 @@ def gemini_path(value): for event, groups in gemini_hooks.items(): for group in groups: for hook in group['hooks']: - script = re.search(r'"([^"]+\.py)"', hook['command']) - if not script or not os.path.isfile(gemini_path(script.group(1))): - bad.append(f"hooks/hooks.json {event}: {hook['command']!r} names no script that exists") + if not runs_a_hook(hook['command'], '${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}run.js', HOOKS): + bad.append(f"hooks/hooks.json {event}: {hook['command']!r} is not `node /plugins/axiomcode/hooks/run.js " + ".py` naming a hook that exists; python3 is not a program on Windows (#1331)") # Gemini's copy of the skill and Cursor's rule are current copies of their sources. sync = subprocess.run([sys.executable, os.path.join(ROOT, 'packaging', 'copies.py'), '--check'], diff --git a/tests/python_names.py b/tests/python_names.py new file mode 100644 index 000000000..e22837960 --- /dev/null +++ b/tests/python_names.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""tests/python_names.py — the CLI and the hooks run on a machine whose Python is not called python3 (#1331). + +A python.org install on Windows has python.exe and py.exe and no python3, and on a desktop Windows `python3` is +the Store placeholder, which is on PATH and exits 9009. Both are simulated here: a `python3` that exits 9009, +and a PATH with no python3 at all. `python` is a wrapper that records each call and runs this interpreter. The +launcher asks it for sys.executable and hands bash that file, so what the record shows is that `python` was the +one asked, and the placeholder records that it was never run past the probe that passes over it. + +The real machine is Windows; this pins the resolution order and the hand-off to bash on any platform. +""" +import json, os, shutil, stat, subprocess, sys, tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +AXJS = os.path.join(ROOT, 'bin', 'axiomcode.js') +RUNJS = os.path.join(ROOT, 'plugins', 'axiomcode', 'hooks', 'run.js') +FRONT = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') + +fails, checked = [], [] +def check(why, cond, detail=''): + checked.append(why) + print(('ok ' if cond else 'FAIL ') + why + (f'\n {detail}' if not cond and detail else '')) + if not cond: + fails.append(why) + + +def script(path, text): + with open(path, 'w') as f: + f.write(text) + os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +with tempfile.TemporaryDirectory() as tmp: + log = os.path.join(tmp, 'python.log') + stubs = os.path.join(tmp, 'stubs') # placeholder python3 + recording python, ahead of the real PATH + bare = os.path.join(tmp, 'bare') # only the tools the front end needs, and no python3 at all + os.makedirs(stubs); os.makedirs(bare) + script(os.path.join(stubs, 'python3'), f'#!/bin/sh\necho "placeholder $*" >> "{log}"\necho "Python was not found" >&2\nexit 9009\n') + python = f'#!/bin/sh\necho "$*" >> "{log}"\nexec "{sys.executable}" "$@"\n' + script(os.path.join(stubs, 'python'), python) + script(os.path.join(bare, 'python'), python) + for tool in ('bash', 'sh', 'sed', 'awk', 'head', 'grep', 'tr', 'dirname', 'cat', 'env', 'node'): + found = shutil.which(tool) + if found: os.symlink(found, os.path.join(bare, tool)) + placeholder = dict(os.environ, PATH=stubs + os.pathsep + os.environ['PATH']) + placeholder.pop('AXIOMCODE_PYTHON', None); placeholder.pop('AXIOMCODE_PYTHON_EXE', None) + + open(log, 'w').close() + + def calls(): + try: + with open(log) as f: return f.read() + finally: + open(log, 'w').close() + + # the command npm links: bash's python3 is the placeholder until the launcher puts its own first + r = subprocess.run(['node', AXJS, 'help', 'context'], env=placeholder, capture_output=True, text=True, timeout=60) + check('`axiomcode help context` answers when python3 is the Store placeholder', + r.returncode == 0 and 'context' in r.stdout and 'Python was not found' not in r.stderr, + f'rc={r.returncode} err={r.stderr[-300:]}') + c = calls() + check('...and the Python bash ran is the one `python` named, not the placeholder', + 'import sys' in c and 'ast.get_docstring' not in c, c) + + # a hook: stdin reaches it, and its exit status comes back unchanged (exit 2 is how a hook blocks) + ev = json.dumps({'tool_name': 'Grep', 'tool_input': {'pattern': 'f'}, 'cwd': tmp, 'session_id': 's1'}) + r = subprocess.run(['node', RUNJS, 'direct.py'], input=ev, env=placeholder, capture_output=True, text=True, timeout=60) + check('a hook runs under `python` when python3 is the placeholder', r.returncode == 0 and 'import sys' in calls(), + f'rc={r.returncode} err={r.stderr[-300:]}') + r = subprocess.run(['node', RUNJS, 'no-such-hook.py'], input='{}', env=placeholder, capture_output=True, text=True, timeout=60) + check("a hook's exit status is passed through, not replaced", r.returncode == 2, f'rc={r.returncode}') + calls() + + # AXIOMCODE_PYTHON comes first, as it does for the MCP server + chosen = os.path.join(tmp, 'mine') + script(chosen, f'#!/bin/sh\necho "mine $*" >> "{log}"\nexec "{sys.executable}" "$@"\n') + r = subprocess.run(['node', AXJS, 'help', 'context'], env=dict(placeholder, AXIOMCODE_PYTHON=chosen), + capture_output=True, text=True, timeout=60) + check('AXIOMCODE_PYTHON is the Python bash runs', r.returncode == 0 and 'mine' in calls(), f'rc={r.returncode}') + + # no Python at all: a hook must never block a tool for want of one + only_node = os.path.join(tmp, 'only-node'); os.makedirs(only_node) + os.symlink(shutil.which('node'), os.path.join(only_node, 'node')) + nopy = dict(placeholder, PATH=only_node) + r = subprocess.run([shutil.which('node'), RUNJS, 'direct.py'], input='{}', env=nopy, capture_output=True, text=True, timeout=60) + check('with no Python, a hook exits 0 and says why on stderr', r.returncode == 0 and 'Python' in r.stderr, + f'rc={r.returncode} err={r.stderr[-300:]}') + + # the skill's own fallback, run from a shell with no node in between and no python3 anywhere on PATH + r = subprocess.run(['bash', FRONT, 'help', 'context'], env=dict(placeholder, PATH=bare), + capture_output=True, text=True, timeout=60) + check('scripts/axiomcode run directly finds `python` when there is no python3', + r.returncode == 0 and 'context' in r.stdout and 'import sys' in calls(), + f'rc={r.returncode} err={r.stderr[-300:]}') + +print() +print(f"{len(checked) - len(fails)} of {len(checked)} check(s) held" if not fails else f"{len(fails)} FAILED: " + '; '.join(fails)) +sys.exit(1 if fails else 0)