diff --git a/.github/RELEASING.md b/.github/RELEASING.md index f3fa783b6..da8e1b5e1 100644 --- a/.github/RELEASING.md +++ b/.github/RELEASING.md @@ -42,8 +42,9 @@ it cannot be deleted, so direct pushes are fine too. `main` moves only by promotion: a pull request `dev → main`, which also builds every platform's engines, needs a green `CI`, and only an admin can merge. Every version released is a tag on -`main`. main only squash-merges, so after every push to main the `sync-dev` job in `release.yml` -merges main back into dev; a hotfix that conflicts with dev pushes nothing and opens an issue with +`main`. A promotion is merged with a merge commit, never squashed, so main shares dev's history and +"dev is N commits ahead" counts only unreleased work. Merge work into dev with squash. After every +push to main the `sync-dev` job in `release.yml` merges main back into dev; a hotfix that conflicts with dev pushes nothing and opens an issue with the commands to resolve it by hand. ## Nightly diff --git a/.github/actions/bot/action.yml b/.github/actions/bot/action.yml new file mode 100644 index 000000000..2a51b229e --- /dev/null +++ b/.github/actions/bot/action.yml @@ -0,0 +1,60 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Who the automation writes as. Releases, tags, the main → dev sync merge and the +# issues the nightly and main-guard open are made with the token this returns, so +# they read "axiomcode-bot released this" rather than "github-actions released this". +# +# GITHUB_TOKEN always acts as github-actions[bot]; the name cannot be set. An org-owned +# GitHub App can: its token acts as [bot] with the app's avatar. It is minted +# here per run from the app's id (repository variable AXIOMCODE_BOT_APP_ID) and private +# key (secret AXIOMCODE_BOT_PRIVATE_KEY). Until those exist this returns github.token and +# the github-actions identity, so nothing breaks before the app is set up (#1365). +# +# A push made with an app token starts workflows, which GITHUB_TOKEN's do not. No +# workflow runs on a tag push and a draft release does not trigger publish-npm, so the +# only new run is CI on dev after the sync merge. +# ───────────────────────────────────────────────────────────────────────────── +name: bot +description: The token and git identity automated writes are made with. +inputs: + app-id: + description: the AxiomCode app's id (vars.AXIOMCODE_BOT_APP_ID); empty falls back to github-actions[bot] + default: '' + private-key: + description: the app's private key (secrets.AXIOMCODE_BOT_PRIVATE_KEY) + default: '' +outputs: + token: + description: the token to write with + value: ${{ steps.pick.outputs.token }} + name: + description: the git author name that goes with it + value: ${{ steps.pick.outputs.name }} + email: + description: the git author email that goes with it + value: ${{ steps.pick.outputs.email }} +runs: + using: composite + steps: + - id: app + if: inputs.app-id != '' + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ inputs.private-key }} + - id: pick + shell: bash + env: + APP_TOKEN: ${{ steps.app.outputs.token }} + SLUG: ${{ steps.app.outputs.app-slug }} + FALLBACK: ${{ github.token }} + run: | + set -euo pipefail + if [ -n "$APP_TOKEN" ]; then + # the noreply address GitHub attributes to the app's bot user: +[bot]@… + id="$(GH_TOKEN="$APP_TOKEN" gh api "/users/${SLUG}%5Bbot%5D" --jq .id)" + { echo "token=$APP_TOKEN"; echo "name=${SLUG}[bot]"; echo "email=${id}+${SLUG}[bot]@users.noreply.github.com"; } >> "$GITHUB_OUTPUT" + echo "writing as ${SLUG}[bot]" + else + { echo "token=$FALLBACK"; echo "name=github-actions[bot]"; echo "email=41898282+github-actions[bot]@users.noreply.github.com"; } >> "$GITHUB_OUTPUT" + echo "writing as github-actions[bot] (AXIOMCODE_BOT_APP_ID is not set)" + fi diff --git a/.github/scripts/e2e-queries.sh b/.github/scripts/e2e-queries.sh index 14edbb9d9..7dc7ff593 100755 --- a/.github/scripts/e2e-queries.sh +++ b/.github/scripts/e2e-queries.sh @@ -13,8 +13,16 @@ # 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 +# impact the same caller, by the graph's own spelling (src/service#leaf), by the +# dotted one every language accepts (src.service.leaf), and by file:line +# impact LEAF --json parses, and names HELPER (hooks and MCP read this) +# path '*' LEAF ENTRY reaches it +# impact, path (Datalog) with os.symlink refused, as for an unelevated Windows user (#1363) +# Windows: impact run from a directory holding a git.exe, python.exe and py.exe (#1332) +# graph --out, help impact, --version # leaf's 41 becomes 42, then # changed names LEAF +# changed --impact names HELPER as reached # test-impact selects TEST # # Running is not passing: each answer has to contain what the project makes true, and no @@ -52,10 +60,56 @@ for e in "" "$DL"; do done run "" context "$LEAF"; must "^ +([A-Za-z_.]*\.)?$LEAF +" "$LEAF is an entry point" +# ── the same declaration in every spelling a user or an agent writes it (#1360) ────────────── +# read from the graph, not written into the fixture: whatever the language calls it, the native +# spelling, the dotted one and file:line must each answer for it +win=""; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) win=1;; esac +native_path() { if [ -n "$win" ]; then cygpath -w "$1"; else printf '%s' "$1"; fi; } +read -r QLEAF LEAF_AT < <(node -e ' + const { DatabaseSync } = require("node:sqlite"); + const db = new DatabaseSync(process.argv[1], { readOnly: true }); + const r = db.prepare("SELECT qualified_name q, file f, line l FROM symbols WHERE name = ? AND method_id IS NOT NULL AND kind <> ? ORDER BY length(qualified_name) LIMIT 1").get(process.argv[2], "module"); + if (r) console.log(r.q, `${r.f}:${r.l}`); +' "$(native_path "$R/.axiomcode/out/graph.sqlite")" "$LEAF" 2>/dev/null) +[ -n "${QLEAF:-}" ] || fail "the graph has no declaration named $LEAF" +DOTTED="$(printf '%s' "$QLEAF" | sed -e 's/::/./g' -e 's/[\/\\#$]/./g' -e 's/\.\.*/./g' -e 's/^\.//' -e 's/\.$//')" +for t in "$QLEAF" "$DOTTED" "$LEAF_AT"; do + run "" impact "$t"; must "\[resolved\] ([A-Za-z_.]*\.)?$HELPER .*calls it" "$HELPER is a resolved caller" +done +run "" impact "$LEAF" --json; must "\"$HELPER\"|[.#/]$HELPER\"" "the JSON names $HELPER" +node -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' "$(native_path "$R/.q.log")" \ + || { head -c 600 "$R/.q.log"; fail "impact --json is not one JSON document"; } +run "" path '*' "$LEAF"; must "([A-Za-z_.]*\.)?$ENTRY\b" "$ENTRY reaches $LEAF" + +# ── a user who may not create symlinks, as on Windows without elevation (#1363) ─────────────── +NOSYM="$R.nosymlink"; mkdir -p "$NOSYM" +printf 'import os\ndef _deny(*a, **k):\n raise OSError(1314, "A required privilege is not held by the client")\nos.symlink = _deny\n' > "$NOSYM/sitecustomize.py" +NS="PYTHONPATH=$(native_path "$NOSYM")" +run "$NS" impact "$LEAF"; must "\[resolved\] ([A-Za-z_.]*\.)?$HELPER .*calls it" "$HELPER is a caller without symlinks" +run "$NS $DL" path "$ENTRY" "$LEAF"; must "reached" "the Datalog path answers without symlinks" + +# ── Windows: a git.exe / python.exe / py.exe in the working directory is not the one run (#1332) ── +if [ -n "$win" ]; then + TRAP="$R.trap"; mkdir -p "$TRAP" + for n in git python python3 py; do cp "$(cygpath -u "${SYSTEMROOT:-C:\\Windows}")/System32/cmd.exe" "$TRAP/$n.exe"; done + LABEL="impact $LEAF from a directory holding git.exe, python.exe and py.exe" + ( cd "$TRAP" && "$bin" impact "$LEAF" "$R" ) > "$R/.q.log" 2>&1 \ + || { sed 's/^/ /' "$R/.q.log" | head -25; fail "$LABEL: rc=$?"; } + must "\[resolved\] ([A-Za-z_.]*\.)?$HELPER .*calls it" "the programs in the working directory were not run" +fi + +# ── the rest of the CLI: graph, help, --version ─────────────────────────────────────────────── +run "" graph --out "$R/.graph.html" +[ -s "$R/.graph.html" ] || fail "graph --out wrote no page"; echo " ok graph --out — $(wc -c < "$R/.graph.html" | tr -d ' ') bytes" +run "" help impact; must "axiomcode impact" "help describes impact" +want="$(node -p 'require(process.argv[1]).version' "$(native_path "$(dirname "$bin")/../@axiomcode/code-graph/package.json")" 2>/dev/null)" +run "" --version; must "^${want//./\\.}\$" "--version is the installed version ($want)" + # 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 "" changed --impact; must "([A-Za-z_.]*\.)?$HELPER\b" "the edit hook's answer reaches $HELPER" 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/protect-main.sh b/.github/scripts/protect-main.sh index 6a7fa16e8..ec73a7bd5 100755 --- a/.github/scripts/protect-main.sh +++ b/.github/scripts/protect-main.sh @@ -14,7 +14,8 @@ # - the `CI` check must pass, evaluated against an up-to-date branch # - only the repository ADMIN role may merge into main (ruleset main-merge-admins): # anyone can open a pull request, an admin merges it, their own included -# - linear history: squash or rebase, no merge bubbles +# - a promotion lands as a merge commit, never a squash: main then shares dev's history, so +# "dev is N commits ahead" counts only what is not on main yet # - a release tag (v*) can be created but never moved or deleted: npm will not # republish a version, so a tag that moved would name a tree nobody installed # @@ -41,7 +42,6 @@ payload="$(cat <<'JSON' "rules": [ { "type": "deletion" }, { "type": "non_fast_forward" }, - { "type": "required_linear_history" }, { "type": "pull_request", "parameters": { @@ -51,7 +51,7 @@ payload="$(cat <<'JSON' "require_last_push_approval": false, "require_extra_approval_for_unattributed_changes": false, "required_review_thread_resolution": true, - "allowed_merge_methods": ["squash", "rebase"] + "allowed_merge_methods": ["merge"] } }, { @@ -156,12 +156,13 @@ apply_ruleset main-merge-admins "$merge_payload" apply_ruleset protect-dev "$dev_payload" apply_ruleset protect-release-tags "$tag_payload" -# Merge-method hygiene lives on the repository, not the ruleset: squash-only, and +# Merge-method hygiene lives on the repository: squash for work into dev, merge commits allowed so that +# protect-main can require them for a promotion (a repository setting cannot differ per branch), and # delete the branch once it has landed so the branch list stops accumulating the # stale aliases this repo has collected before. gh api -X PATCH "repos/$REPO" \ -F allow_squash_merge=true \ - -F allow_merge_commit=false \ + -F allow_merge_commit=true \ -F allow_rebase_merge=false \ -F delete_branch_on_merge=true \ -F allow_auto_merge=true >/dev/null diff --git a/.github/workflows/build-engines.yml b/.github/workflows/build-engines.yml index fcb18e83d..9ebdd1fba 100644 --- a/.github/workflows/build-engines.yml +++ b/.github/workflows/build-engines.yml @@ -185,10 +185,12 @@ jobs: with: path: engines key: engines-${{ matrix.target.platform }}-${{ needs.generate.outputs.flags }}-${{ needs.generate.outputs.key }} + # publish-npm ships these binaries from the release's CI run, whenever the draft is published - uses: actions/upload-artifact@v4 with: name: engines-${{ matrix.target.platform }} path: engines + retention-days: 90 if-no-files-found: error build-macos: @@ -248,4 +250,4 @@ jobs: path: engines key: engines-${{ matrix.target.platform }}-${{ needs.generate.outputs.flags }}-${{ needs.generate.outputs.key }} - uses: actions/upload-artifact@v4 - with: { name: 'engines-${{ matrix.target.platform }}', path: engines, if-no-files-found: error } + with: { name: 'engines-${{ matrix.target.platform }}', path: engines, retention-days: 90, if-no-files-found: error } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4eba024a..e09200258 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,20 @@ jobs: echo "code=true" >> "$GITHUB_OUTPUT"; echo "engines=false" >> "$GITHUB_OUTPUT" echo "push to dev: suites run, platform engines wait for main"; exit 0 fi + # A push to main is a release when its version has no tag yet: that commit, and only that one, + # builds every platform and runs the five-platform e2e, and release.yml drafts from it once + # it is green. A push whose version is already tagged released nothing new and builds nothing. + if [ "${{ github.event_name }}" = push ] && [ "${{ github.ref }}" = refs/heads/main ]; then + v="$(node .github/scripts/version.mjs get)" + if git ls-remote --exit-code --tags origin "refs/tags/v$v" >/dev/null; then + echo "code=true" >> "$GITHUB_OUTPUT"; echo "engines=false" >> "$GITHUB_OUTPUT" + echo "push to main at v$v, already tagged: suites run, nothing to release" + else + echo "code=true" >> "$GITHUB_OUTPUT"; echo "engines=true" >> "$GITHUB_OUTPUT" + echo "push to main at v$v, not yet tagged: the release build runs on every platform" + fi + exit 0 + fi if [ "${{ github.event_name }}" != pull_request ]; then echo "code=true" >> "$GITHUB_OUTPUT"; echo "engines=true" >> "$GITHUB_OUTPUT" echo "${{ github.event_name }} on ${{ github.ref }}: everything runs"; exit 0 @@ -98,8 +112,9 @@ jobs: -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="" + # No pull request builds the platform engines: a release builds them once, on the push that + # lands it on main, and publishes exactly that build (#1350). + engines="" [ -n "$engines" ] && echo "engines=true" >> "$GITHUB_OUTPUT" || echo "engines=false" >> "$GITHUB_OUTPUT" echo "suites: $([ -n "$code" ] && echo run || echo skip) platform engines: $([ -n "$engines" ] && echo run || echo skip)" @@ -206,6 +221,25 @@ jobs: if: github.event_name == 'pull_request' run: bash .github/scripts/version-gate.sh "origin/${{ github.base_ref }}" + # A push to main builds and releases only when its version is new (#1350). The gate above already + # refuses a pull request that changes what users get without a new version; a CI or docs change + # may keep main's version and then builds nothing when it lands. What is left to refuse here is a + # new version that was already released: its tag exists, so the push would build nothing and the + # change would never ship. + - name: a pull request into main does not reuse a released version + if: github.event_name == 'pull_request' && github.base_ref == 'main' + run: | + set -euo pipefail + head="$(node .github/scripts/version.mjs get)" + base="$(git show origin/main:package.json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).version))')" + if [ "$head" = "$base" ]; then + echo "main stays at $base: nothing in this change is released, and landing it builds nothing"; exit 0 + fi + if git ls-remote --exit-code --tags origin "refs/tags/v$head" >/dev/null; then + echo "::error::v$head is already released — pick the next version"; exit 1 + fi + echo "main $base -> $head: landing this builds every platform and drafts v$head" + engine: name: engine (${{ matrix.lang }}) needs: [changes] @@ -385,10 +419,9 @@ jobs: AXIOM_SOUFFLE_CACHE: ${{ github.workspace }}/.souffle-cache run: bash .github/scripts/run-suite.sh ${{ matrix.lang }} ${{ matrix.oracle }} - # Every language's engine, every platform: the reusable build that publish-npm - # ships from, run here WITHOUT publishing. A rule that solves on Ubuntu but does - # not compile with MSVC, or that no longer generates for a language, fails the - # gate here rather than at release time. + # Every language's engine, every platform, built once per release: on the push that + # lands a new version on main (and in the nightly). publish-npm ships these very + # artifacts, so what the e2e below tested is what users install (#1350). engines: name: engines build on every platform needs: [build, changes] @@ -401,7 +434,7 @@ jobs: # 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. + # Runs wherever the platform engines are built: on the push that lands a release on main, and in the nightly. pack: name: pack @axiomcode/code-graph needs: [build, changes] @@ -416,8 +449,9 @@ jobs: - 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 + # kept as long as the engines: publish-npm ships this exact tarball, whenever the draft is published - uses: actions/upload-artifact@v4 - with: { name: code-graph-tgz, path: tgz, retention-days: 3, if-no-files-found: error } + with: { name: code-graph-tgz, path: tgz, retention-days: 90, if-no-files-found: error } e2e: name: e2e on ${{ matrix.target.platform }} diff --git a/.github/workflows/main-guard.yml b/.github/workflows/main-guard.yml index 040023dff..28c3319c3 100644 --- a/.github/workflows/main-guard.yml +++ b/.github/workflows/main-guard.yml @@ -67,10 +67,18 @@ jobs: echo "::error::${#orphans[@]} commit(s) reached main without a pull request" exit 1 + - name: the bot this job writes as + id: bot + if: failure() && steps.check.outputs.found == '1' + uses: ./.github/actions/bot + with: + app-id: ${{ vars.AXIOMCODE_BOT_APP_ID }} + private-key: ${{ secrets.AXIOMCODE_BOT_PRIVATE_KEY }} + - name: record it as an issue if: failure() && steps.check.outputs.found == '1' env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.bot.outputs.token }} run: | set -uo pipefail title="Direct push to main on $(date -u +%Y-%m-%d)" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 4a089ff80..0e595e499 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -161,8 +161,19 @@ jobs: permissions: issues: write steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: .github/actions + + - name: the bot this job writes as + id: bot + uses: ./.github/actions/bot + with: + app-id: ${{ vars.AXIOMCODE_BOT_APP_ID }} + private-key: ${{ secrets.AXIOMCODE_BOT_PRIVATE_KEY }} + - env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.bot.outputs.token }} GH_REPO: ${{ github.repository }} CI_RESULT: ${{ needs.ci.result }} E2E_RESULT: ${{ needs.e2e.result }} diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 99625a607..25ce21ec7 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -67,17 +67,16 @@ jobs: echo "dist_tag=$dist_tag" >> "$GITHUB_OUTPUT" echo "publishing $version as '$dist_tag'" - engines: - needs: check - uses: ./.github/workflows/build-engines.yml - with: - fresh: true # what ships is compiled from scratch, never restored - + # Nothing is compiled here. The push that landed this version on main built every platform's + # engines, packed @axiomcode/code-graph and ran the five-platform e2e on exactly those files; + # release.yml drafted this release only after that run was green. Publishing ships that build, + # so what was tested is what users install, and a release costs one build, not three (#1350). publish: - needs: [check, engines] + needs: [check] runs-on: ubuntu-24.04 permissions: contents: write # attach the tarballs to the release + actions: read # the release's CI run and its artifacts env: VERSION: ${{ needs.check.outputs.version }} DIST_TAG: ${{ needs.check.outputs.dist_tag }} @@ -88,8 +87,33 @@ jobs: with: node-version: '22' registry-url: 'https://registry.npmjs.org' + - name: the green CI run that built and tested this commit + id: run + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + sha="$(git rev-parse HEAD)" + run="$(gh run list --workflow ci.yml --commit "$sha" --event push --branch main --status success \ + --limit 1 --json databaseId --jq '.[0].databaseId // empty')" + if [ -z "$run" ]; then + echo "::error::no green CI run of a push to main for ${sha::8}: nothing built and tested this commit, so nothing is published" + exit 1 + fi + echo "id=$run" >> "$GITHUB_OUTPUT" + echo "shipping the build of CI run $run ($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$run)" - uses: actions/download-artifact@v4 - with: { pattern: engines-*, path: artifacts } + with: + pattern: engines-* + path: artifacts + run-id: ${{ steps.run.outputs.id }} + github-token: ${{ github.token }} + - uses: actions/download-artifact@v4 + with: + name: code-graph-tgz + path: artifacts/tgz + run-id: ${{ steps.run.outputs.id }} + github-token: ${{ github.token }} # A real release ships every platform the root package pins. Missing one # means some machine installs a version whose engine does not exist. @@ -126,10 +150,13 @@ jobs: cat "packages/engine-$platform/package.json" done - # `prepare` builds the parser and the driver, so the packed tarball holds - # parser/dist and dist exactly as a user installs them. - - name: build @axiomcode/code-graph - run: npm install --no-audit --no-fund + - name: the tested @axiomcode/code-graph tarball is this version + run: | + set -euo pipefail + tgz="$(ls artifacts/tgz/*.tgz)" + got="$(tar -xOzf "$tgz" package/package.json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).version))')" + [ "$got" = "$VERSION" ] || { echo "::error::the tested tarball is $got, this release is $VERSION"; exit 1; } + echo "TGZ=$tgz" >> "$GITHUB_ENV" - name: publish (or dry-run) env: @@ -150,7 +177,14 @@ jobs: && npm publish --access public --tag "$DIST_TAG" $flag ) } for p in packages/engine-*; do publish "$p"; done - publish . + # the tarball the e2e installed, published as it is rather than packed again + if [ -z "$flag" ] && npm view "@axiomcode/code-graph@$VERSION" version >/dev/null 2>&1; then + echo "══ @axiomcode/code-graph@$VERSION is already on the registry — skipped" + else + echo "══ @axiomcode/code-graph@$VERSION tag=$DIST_TAG $flag" + cp "$TGZ" tarballs/ + npm publish "$TGZ" --access public --tag "$DIST_TAG" $flag + fi ls -la tarballs if [ "$DRY" = true ]; then echo "DRY RUN — nothing was uploaded." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72de687a3..0aad6f515 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,23 +2,26 @@ # Every version that lands on main gets a tag and a draft release, and dev is # brought up to date with main (the sync-dev job at the end). # -# The version gate (ci.yml) makes a pull request that reaches users bump the -# version. When that bump merges, this workflow tags the merge commit v -# and opens a DRAFT GitHub release with notes generated from the pull requests -# since the previous tag. Nothing is published here. +# A push to main whose version has no tag yet is a release. CI on that push builds +# the engines on all five platforms and runs the five-platform e2e; only when that +# run is GREEN does this workflow tag the commit it tested v and open a +# DRAFT GitHub release with notes generated +# from the pull requests since the previous tag. Nothing is published here. # # Publishing is a person's decision: review the draft, then press Publish. That -# `release: published` event is what runs publish-npm.yml, which builds the -# engines, checks that the tag and every manifest agree, publishes to npm and -# attaches the tarballs to the release. +# `release: published` event runs publish-npm.yml, which ships the binaries and +# the tarball of that same green CI run: nothing is compiled twice (#1350). # -# A push that does not move the version finds its tag already present and does -# nothing, so this runs on every push to main without a path filter. +# A version that is already tagged finds its tag present and does nothing. # ───────────────────────────────────────────────────────────────────────────── name: release on: push: + branches: [main] # sync-dev + workflow_run: # tag: after CI on main finished + workflows: [CI] + types: [completed] branches: [main] workflow_dispatch: @@ -31,12 +34,27 @@ permissions: jobs: tag: + # the CI run of a PUSH to main, and only a green one: a red release build drafts nothing + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'push' && + github.event.workflow_run.conclusion == 'success') runs-on: ubuntu-24.04 + env: + SHA: ${{ github.event.workflow_run.head_sha || github.sha }} steps: - uses: actions/checkout@v4 with: + ref: ${{ env.SHA }} fetch-depth: 0 + - name: the bot this job writes as + id: bot + uses: ./.github/actions/bot + with: + app-id: ${{ vars.AXIOMCODE_BOT_APP_ID }} + private-key: ${{ secrets.AXIOMCODE_BOT_PRIVATE_KEY }} + - uses: actions/setup-node@v4 with: node-version: '22' @@ -46,7 +64,9 @@ jobs: - name: tag and draft the release env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.bot.outputs.token }} + BOT_NAME: ${{ steps.bot.outputs.name }} + BOT_EMAIL: ${{ steps.bot.outputs.email }} run: | set -euo pipefail version="$(node .github/scripts/version.mjs get)" @@ -63,24 +83,24 @@ jobs: # The tag is pushed here rather than left to the release: a DRAFT release # does not create its tag until it is published, so the check above would # never see it and every later push would draft again. A tag pushed with - # GITHUB_TOKEN starts no workflow, which is intended: publishing waits for - # a person to publish the draft. - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git tag -a "$tag" -m "$tag" "$GITHUB_SHA" - git push origin "refs/tags/$tag" + # the bot's token starts no workflow here either: nothing runs on a tag push, and + # publishing waits for a person to publish the draft. + git config user.name "$BOT_NAME" + git config user.email "$BOT_EMAIL" + git tag -a "$tag" -m "$tag" "$SHA" + git -c http.https://github.com/.extraheader= push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/$tag" gh release create "$tag" --draft --verify-tag --title "$tag" \ --generate-notes $start $prerelease echo "::notice::drafted $tag — review it under Releases and publish it to ship to npm" # ── keep dev on top of main ───────────────────────────────────────────────── - # main only takes squash merges, so a promotion from dev lands on main as a NEW - # commit that dev does not have; without this, the next dev→main pull request - # shows the old work again. Merging main back into dev records that it is - # already there. A promotion merges cleanly (same content both sides); a hotfix + # A promotion from dev lands on main as a merge commit that dev does not have + # yet, and a hotfix straight to main is a commit dev lacks too; merging main + # back into dev records that they are already there. A promotion merges cleanly (same content both sides); a hotfix # that went straight to main and touches lines dev has since changed does not, # and then nothing is pushed: an issue says how to resolve it by hand. sync-dev: + if: github.event_name == 'push' runs-on: ubuntu-24.04 permissions: contents: write @@ -91,18 +111,29 @@ jobs: ref: dev fetch-depth: 0 + - name: the bot this job writes as + id: bot + uses: ./.github/actions/bot + with: + app-id: ${{ vars.AXIOMCODE_BOT_APP_ID }} + private-key: ${{ secrets.AXIOMCODE_BOT_PRIVATE_KEY }} + - name: merge main into dev id: merge + env: + BOT_TOKEN: ${{ steps.bot.outputs.token }} + BOT_NAME: ${{ steps.bot.outputs.name }} + BOT_EMAIL: ${{ steps.bot.outputs.email }} run: | set -uo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config user.name "$BOT_NAME" + git config user.email "$BOT_EMAIL" git fetch origin main if git merge-base --is-ancestor origin/main HEAD; then echo "dev already contains main"; exit 0 fi if git merge --no-edit -m "Merge main into dev (${GITHUB_SHA::8})" origin/main; then - git push origin HEAD:dev + git -c http.https://github.com/.extraheader= push "https://x-access-token:${BOT_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:dev echo "dev now contains main at ${GITHUB_SHA::8}" else git diff --name-only --diff-filter=U > /tmp/conflicts.txt @@ -116,7 +147,7 @@ jobs: - name: say how to resolve it if: failure() && steps.merge.outputs.conflict == '1' env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.bot.outputs.token }} run: | set -uo pipefail title="main does not merge cleanly into dev" diff --git a/bin/axiomcode b/bin/axiomcode index 08ac8014d..649bdad65 100755 --- a/bin/axiomcode +++ b/bin/axiomcode @@ -33,6 +33,8 @@ # Run the test suites. # mcp Serve the graph to an agent as MCP tools over stdio. Any MCP client # can start it as: npx -y @axiomcode/code-graph mcp +# --version Print the installed version. (After , --version V is the +# build option above.) # ───────────────────────────────────────────────────────────────────────────── set -eu # RESOLVE $0 THROUGH SYMLINKS BEFORE THE WALK. npm installs a `bin` entry as a link in @@ -77,7 +79,7 @@ QUERY="$ROOT/plugins/axiomcode/skills/axiomcode/scripts/axiomcode" query_verbs(){ [ -f "$QUERY" ] && bash "$QUERY" --verbs 2>/dev/null | grep -vx tests || true; } is_query_verb(){ query_verbs | grep -qx -- "$1"; } usage(){ - sed -n '3,35p' "$0" | sed 's/^# \{0,1\}//' + awk 'NR > 2 && /^# ─/ {exit} NR > 2' "$0" | sed 's/^# \{0,1\}//' if [ -f "$QUERY" ]; then echo echo 'ASKING THE GRAPH — `axiomcode help ` for any one of them:' @@ -88,6 +90,11 @@ die(){ echo "axiomcode: $*" >&2; exit 2; } need_parser(){ [ -f "$PARSER" ] || { echo "axiomcode: parser not built at $PARSER — run: npm install && npm run build" >&2; exit 1; }; } cmd="${1:-}" +# Alone, --version asks which release this is. With other arguments it stays the build option that stamps the +# source version, which a build takes after , so the two never meet. +if [ "$cmd" = --version ] && [ $# -eq 1 ]; then + sed -n 's/^ "version": "\(.*\)",$/\1/p' "$ROOT/package.json"; exit 0 +fi # A query verb goes to the frontend before anything else. `help ` too, so one `help` covers # both halves of the command. case "$cmd" in diff --git a/bin/axiomcode.js b/bin/axiomcode.js index 4a0f6c735..72a1fc1a5 100755 --- a/bin/axiomcode.js +++ b/bin/axiomcode.js @@ -29,12 +29,20 @@ function fail(msg) { process.exit(127); } +// `axiomcode --version` is answered here, before bash is looked for: it is what a user runs to check an install, +// including one whose bash cannot be found. bin/axiomcode answers it the same way when run from a checkout. +const args = process.argv.slice(2); +if (args.length === 1 && args[0] === '--version') { + process.stdout.write(`${require('../package.json').version}\n`); + process.exit(0); +} + const { bash, error } = findBash(); if (error) fail(error); 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 }); +const r = spawnSync(bash, [path.join(__dirname, 'axiomcode'), ...args], { 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/gemini-extension.json b/gemini-extension.json index bdc7980f4..defcedc65 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.1", + "version": "0.1.2", "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/graph/pipeline/run-souffle.sh b/graph/pipeline/run-souffle.sh index 0987b1de0..a7655b4e8 100755 --- a/graph/pipeline/run-souffle.sh +++ b/graph/pipeline/run-souffle.sh @@ -394,6 +394,9 @@ engine_platform(){ x86_64|amd64) arch=x64;; arm64|aarch64) arch=arm64;; *) echo "unsupported architecture: $(uname -m)" >&2; return 1;; esac + # a bash started from an Intel python3 on an Apple Silicon Mac runs under Rosetta and reports x86_64; npm installed + # the arm64 engine, and an arm64 binary runs natively even from a translated process. + if [ "$os" = darwin ] && [ "$arch" = x64 ] && [ "$(/usr/sbin/sysctl -n hw.optional.arm64 2>/dev/null)" = 1 ]; then arch=arm64; fi printf '%s-%s\n' "$os" "$arch" } # 1. the engine package npm installed for this machine, if it was built from exactly these @@ -401,7 +404,15 @@ engine_platform(){ # node_modules and a global install both work. PACKAGED="" platform="$(engine_platform 2>/dev/null || true)" +# this machine's package first, then the same OS's other architecture: npm installs exactly one per machine, so when +# the first is absent the installed one is the one npm chose here. if [ -n "$platform" ]; then + case "$platform" in *-arm64) other="${platform%-arm64}-x64";; *) other="${platform%-x64}-arm64";; esac + for p in "$platform" "$other"; do + d="$PKG" + while [ "$d" != / ] && [ ! -d "$d/node_modules/$ENGINE_PACKAGE_SCOPE/engine-$p" ]; do d="$(dirname "$d")"; done + if [ "$d" != / ]; then platform="$p"; break; fi + done d="$PKG" while [ "$d" != / ]; do pkgdir="$d/node_modules/$ENGINE_PACKAGE_SCOPE/engine-$platform" diff --git a/package.json b/package.json index 7315e0658..b38548c41 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@axiomcode/code-graph", - "version": "0.1.1", + "version": "0.1.2", "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.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" + "@axiomcode/engine-darwin-arm64": "0.1.2", + "@axiomcode/engine-darwin-x64": "0.1.2", + "@axiomcode/engine-linux-x64": "0.1.2", + "@axiomcode/engine-linux-arm64": "0.1.2", + "@axiomcode/engine-win32-x64": "0.1.2" }, "bin": { "axiomcode": "bin/axiomcode.js" diff --git a/plugins/axiomcode/.claude-plugin/plugin.json b/plugins/axiomcode/.claude-plugin/plugin.json index 514bde667..28f13b63e 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.1", + "version": "0.1.2", "author": { "name": "AxiomCode" } diff --git a/plugins/axiomcode/.codex-plugin/plugin.json b/plugins/axiomcode/.codex-plugin/plugin.json index 811f1fb2b..f4d18b779 100644 --- a/plugins/axiomcode/.codex-plugin/plugin.json +++ b/plugins/axiomcode/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.1", + "version": "0.1.2", "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 229f4d275..a8f886ec1 100644 --- a/plugins/axiomcode/.cursor-plugin/plugin.json +++ b/plugins/axiomcode/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.1", + "version": "0.1.2", "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 33a5dd408..f19922889 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -33,7 +33,8 @@ def save_state(st): 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) + try: r = os.path.relpath(a, b) + except ValueError: continue # Windows: a file on another drive is not under the tree if not r.startswith('..'): return r.replace(os.sep, '/') # the index stores '/' on every platform return fp diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index 94ffe51d7..e5b9b30fc 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -21,7 +21,8 @@ def rel_of(fp): reverse) — compare real paths, and if the file still is not under the tree, fall back to the graph's own suffix match""" 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) + try: r = os.path.relpath(a, b) + except ValueError: continue # Windows: a file on another drive is not under the tree 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') diff --git a/plugins/axiomcode/mcp/find-bash.js b/plugins/axiomcode/mcp/find-bash.js index 0c9c87a9a..2d58c4bfe 100644 --- a/plugins/axiomcode/mcp/find-bash.js +++ b/plugins/axiomcode/mcp/find-bash.js @@ -15,12 +15,15 @@ const { execFileSync } = require('child_process'); const fs = require('fs'); const path = require('path'); +const { which } = require('./which.js'); function gitBash() { const candidates = []; try { // /mingw64/libexec/git-core, or /libexec/git-core on some layouts. - const exec = execFileSync('git', ['--exec-path'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }).trim(); + const git = which('git'); + if (!git) throw new Error('no git on PATH'); + const exec = execFileSync(git, ['--exec-path'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 15000 }).trim(); for (let d = path.resolve(exec), i = 0; i < 4; i++, d = path.dirname(d)) candidates.push(path.join(d, 'bin', 'bash.exe')); } catch { /* no git on PATH: fall through to the default locations */ } const env = process.env; diff --git a/plugins/axiomcode/mcp/find-python.js b/plugins/axiomcode/mcp/find-python.js index 70c9fdd7a..1492f31a6 100644 --- a/plugins/axiomcode/mcp/find-python.js +++ b/plugins/axiomcode/mcp/find-python.js @@ -13,6 +13,7 @@ // 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 { which } = require('./which.js'); const path = require('path'); const SHIM = path.join(__dirname, '..', 'skills', 'axiomcode', 'scripts', 'pyshim'); @@ -25,8 +26,10 @@ function candidates() { // { 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 exe0 = which(cmd[0]); // PATH only: never a python.exe in the current directory + if (!exe0) continue; + const r = spawnSync(exe0, [...cmd.slice(1), '-c', 'import sys; print(sys.executable)'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 15000 }); const exe = r.status === 0 && String(r.stdout).trim(); if (exe) return { cmd, exe }; } @@ -40,6 +43,9 @@ function findPython() { // 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, '/') }; + // and every program started below — python, git, bash, the engine — skips the current directory when it looks a + // bare name up (see which.js); Windows reads this variable from the environment of the process that starts one + if (process.platform === 'win32' && out.NoDefaultCurrentDirectoryInExePath === undefined) out.NoDefaultCurrentDirectoryInExePath = '1'; // 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'; diff --git a/plugins/axiomcode/mcp/launch.js b/plugins/axiomcode/mcp/launch.js index a5af11456..b15557a46 100644 --- a/plugins/axiomcode/mcp/launch.js +++ b/plugins/axiomcode/mcp/launch.js @@ -30,13 +30,15 @@ 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 { which } = require('./which.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 // `node -e` there is none. const args = require.main === module ? process.argv.slice(2) : []; -const runs = (cmd, argv) => spawnSync(cmd[0], [...cmd.slice(1), ...argv], { stdio: 'ignore', windowsHide: true }).status === 0; +// a bare name is resolved over PATH only (which.js): a python.exe or uv.exe in the client's working directory is not it +const runs = (cmd, argv) => { const exe = which(cmd[0]); return !!exe && spawnSync(exe, [...cmd.slice(1), ...argv], { stdio: 'ignore', windowsHide: true, timeout: 15000 }).status === 0; }; const UV = ['uv', 'run', '--quiet', '--with', 'mcp', 'python']; // A first resolve on a clean machine downloads the SDK and its dependencies, so the limit is generous; a @@ -45,7 +47,7 @@ const UV_PROBE_MS = Number(process.env.AXIOMCODE_UV_TIMEOUT_MS) || 60000; function uvWorks() { if (!runs(['uv'], ['--version'])) return false; - const r = spawnSync(UV[0], [...UV.slice(1), '-c', 'import mcp'], + const r = spawnSync(which(UV[0]), [...UV.slice(1), '-c', 'import mcp'], { stdio: ['ignore', 'ignore', 'pipe'], encoding: 'utf8', timeout: UV_PROBE_MS, windowsHide: true }); if (r.status === 0) return true; const why = r.error ? (r.error.code === 'ETIMEDOUT' ? `no answer within ${UV_PROBE_MS / 1000}s` : r.error.message) @@ -78,7 +80,7 @@ if (!cmd) { // stdio is inherited, so the client talks to the server directly and this process only waits. A signal // sent to it is passed on, so stopping the launcher stops the server rather than orphaning it. -const child = spawn(cmd[0], [...cmd.slice(1), ...args], { stdio: 'inherit', env, windowsHide: true }); +const child = spawn(which(cmd[0]) || cmd[0], [...cmd.slice(1), ...args], { stdio: 'inherit', env, windowsHide: true }); for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => child.kill(sig)); child.on('error', (e) => { process.stderr.write(`axiomcode mcp: could not start ${cmd[0]}: ${e.message}\n`); process.exit(1); }); child.on('exit', (code, signal) => { diff --git a/plugins/axiomcode/mcp/which.js b/plugins/axiomcode/mcp/which.js new file mode 100644 index 000000000..bdf196317 --- /dev/null +++ b/plugins/axiomcode/mcp/which.js @@ -0,0 +1,27 @@ +// which.js — a program's full path from PATH alone, never from the current directory (Windows). +// +// Windows looks in the CURRENT DIRECTORY before PATH when a program is started by bare name, and Node's spawn does +// the same. A repository holding a git.exe, a python.exe or a py.exe — a vendored tool, an installer someone +// downloaded into it — then runs instead of the real one: the finders' `git --exec-path` probe started a Git +// installer that waited, invisibly, for ever, and every build from that directory hung. So on Windows a bare name +// is resolved here, over PATH and PATHEXT only, and the absolute path is what gets started. Elsewhere the name is +// returned as is: POSIX never searches the current directory for a bare name. +'use strict'; +const fs = require('fs'); +const path = require('path'); + +function which(name) { + if (process.platform !== 'win32' || path.isAbsolute(name) || /[\\/]/.test(name)) return name; + const exts = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean); + const dirs = (process.env.PATH || process.env.Path || '').split(path.delimiter).filter((d) => d && d !== '.'); + for (const d of dirs) { + if (!path.isAbsolute(d)) continue; + for (const e of path.extname(name) ? [''] : exts) { + const p = path.join(d, name + e); + try { if (fs.statSync(p).isFile()) return p; } catch { /* not here */ } + } + } + return null; +} + +module.exports = { which }; diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 9c5a4a956..7271f7757 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -67,7 +67,8 @@ does not restrict. Detail: `reference/context.md`. `axiomcode impact … [--depth N] [--in ] [--delete]`. Targets as written in the code: `Owner.method`, `Owner.field`, `Type`, `Owner.method(param)`, `Type`, `Owner.method:local`, a config key, or -`file.ts:123` — the declaration at that line. **When you know where the declaration is, target it by `file:line`**: a +`file.ts:123` — the declaration at that line. Separators are interchangeable in every language: `util.square`, +`src.util.square` and `src/util#square` are one name. **When you know where the declaration is, target it by `file:line`**: a bare name answers for EVERY declaration of that name, and two unrelated functions in different files come back as one. Sections: **must change with it** · **produces or writes it** · **reads or uses it** (by rung) · **reaches those** (transitively: what can reach a user, not where the value goes) · tests, counted by rung with the strong ones named · `verified:` · `bound:`. For the full test list ask second: `--tests-only` (grouped by rung and file), `--why` for routes, `--tests-in ` to narrow. A long answer comes in pages of ~2000 tokens with the whole answer's counts on every page; `--page 2` (MCP `page=2`) only when page 1's strongest rows are not enough. It finds config diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index 175d38172..57636c7c5 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -253,7 +253,10 @@ 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([os.environ.get('AXIOMCODE_BASH') or 'bash', os.path.join(H, 'axiomcode-build'), repo], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + # the worker is detached and has no console, so Windows would give the console program bash a new, visible + # window for the length of every rebuild; CREATE_NO_WINDOW keeps it hidden + 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, + **(dict(creationflags=0x08000000) if os.name == 'nt' else {})) 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 3c9e75a3d..ab1dd8233 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -62,6 +62,9 @@ if [ -z "${AXIOMCODE_PYTHON_EXE:-}" ] && 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 +# the builds Python starts need THIS bash: a bare `bash` from a native python.exe is WSL's (System32) or nothing. +# The node launchers set it; a run from Git Bash itself does not, so it is set here from the bash that is running. +if [ -z "${AXIOMCODE_BASH:-}" ] && command -v cygpath >/dev/null 2>&1; then AXIOMCODE_BASH="$(cygpath -m "$BASH")"; export AXIOMCODE_BASH; fi # 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 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index 5a92793d1..4724ce140 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -134,7 +134,7 @@ build_head_graph(){ [ -d "$REPO/node_modules" ] && ln -s "$REPO/node_modules" "$T/node_modules" # library roots are given relative to the repository or absolute; from the scratch directory only absolute reach local LIBS="" r; if [ -n "${AXIOMCODE_LIBRARY:-}" ]; then - for r in $(printf %s "$AXIOMCODE_LIBRARY" | tr ',' ' '); do case "$r" in /*) ;; *) r="$REPO/$r" ;; esac; LIBS="${LIBS:+$LIBS,}$r"; done + local IFS=','; for r in $AXIOMCODE_LIBRARY; do case "$r" in /*|[A-Za-z]:*) ;; *) r="$REPO/$r" ;; esac; LIBS="${LIBS:+$LIBS,}$r"; done; unset IFS fi if env -u AXIOMCODE_BACKGROUND AXIOMCODE_NO_REFRESH=1 AXIOMCODE_LIBRARY="$LIBS" bash "$H/axiomcode-build" "$T" > "$REPO/.axiomcode/base-build.log" 2>&1; then D="$(readlink "$T/.axiomcode/out/graph.sqlite" 2>/dev/null || true)" diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index cfd5a4f2c..0055d86c0 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -427,7 +427,22 @@ class Changed: sigged = {(e['file'], e['symbol']) for e in out if e['kind'] in ('signature', 'field', 'type')} out = [e for e in out if not (e['kind'] == 'added' and (e['file'], e['symbol']) in sigged)] if overrun_note: adds.append(overrun_note) + # THE TARGET NAMES THIS DECLARATION, NOT EVERY DECLARATION OF ITS NAME. `symbol` stays the short display; the + # target a hook hands to impact is the full dotted name, or `square` also answers for every other `square`. + for e in out: + t = e.get('target') + q = self.qualified(e.get('id'), e.get('kind')) + if t and q and t.startswith(e['symbol']): e['target'] = q + t[len(e['symbol']):]; e['shown_target'] = t return out, adds + def qualified(self, i, kind): + if i is None: return None + r = self.g.all_sym.get(i) + q = r.get('qualified_name') if r else None + if not q and kind == 'field': + row = self.g.q("SELECT qualified_name FROM symbols WHERE rowid = ?", i) + q = row[0][0] if row else None + q = P.canon(q) if q else None + return q if q and re.fullmatch(r'[\w$.]+', q) else None @staticmethod def target(kind, d, k, n): return d @@ -493,7 +508,7 @@ def main(argv): else f" — against the graph's commit {C.built_at[:10]}") print(f"changed declarations ({len(results)})" + (against if C.built_at and mode == 'worktree' else (f" — {rng}" if rng else '')) + ":") for e in results: - print(f" {e['kind']:<10} {e['symbol']} {e['file']}:{e['line']}" + (f" — {e['detail']}" if e.get('detail') else '') + (f" → impact {e['target']}" if e.get('target') else (' (no declaration of that name existed before, so nothing in the old tree names it)' if e['kind'] == 'added' else ''))) + print(f" {e['kind']:<10} {e['symbol']} {e['file']}:{e['line']}" + (f" — {e['detail']}" if e.get('detail') else '') + (f" → impact {e.get('shown_target') or e['target']}" if e.get('target') else (' (no declaration of that name existed before, so nothing in the old tree names it)' if e['kind'] == 'added' else ''))) # a note with a file is an addition in that file; one without is a caveat about the whole answer, and printing it # under `added` made "the graph is built at the newer commit …" read as a new declaration for f, n, _ in notes: print(f" {'added':<10} {n}" if f else f"note: {n}") diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 30f278e10..8f5c9f22e 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -2,7 +2,10 @@ """axiomcode impact […] [] [--depth N] [--in ] [--tests | --tests-only [--why] [--tests-in ]] [--limit N] [--page N|all] [--budget N] [--json] what has to be looked at again when a declaration changes — and how sure each entry is. -A target is a declaration, written as it appears in the code. Its kind is read from the index, never guessed: +A target is a declaration, written as it appears in the code. Separators are interchangeable in every language: +util.square, src.util.square and src/util#square name the same declaration; the spelling as written is tried first, +and a dotted name that fits declarations differing only in separators is refused with each listed. Its kind is read +from the index, never guessed: Owner.method · method · file.java:123 a method (or every declaration of that name — one impact per kind) Owner.field · CONSTANT · Enum.MEMBER a field, constant or enum member Type a class / interface / enum @@ -233,6 +236,11 @@ class Impact: # ── targets ────────────────────────────────────────────────────────────────────────────────────────────────── CONFIG_RE = re.compile(r'^[a-z][\w-]*(\.[a-z0-9][\w-]*)+$') # server.error.path, spring.datasource.url + def declared_name(self, name): + return bool(self.g.q("SELECT 1 FROM symbols WHERE name = ? AND kind NOT IN ('library', 'written') LIMIT 1", name)) + def has_config(self): + g = self.g + return any(g.q(f"SELECT 1 FROM {t} LIMIT 1") for t in ('ext_config_affects_method', 'ext_config_binding', 'ext_config_key_ref') if g.has(t)) def config_target(self, s): """a configuration key: what the engine's framework extension bound it into. An unknown key stops with what is known — a key is never answered as a by-name match on code, which is what makes a wrong answer look like an answer.""" @@ -272,8 +280,8 @@ class Impact: low = typed.lower() for n in names: if not n: continue - n = str(n).replace('#', '.').lower() - if n == low or n.endswith('.' + low): return '' # the prefix IS part of the name + for form in (str(n).replace('#', '.').lower(), P.canon(str(n)).lower()): + if form == low or form.endswith('.' + low) or form == P.canon(low) or form.endswith('.' + P.canon(low)): return '' # the prefix IS part of the name return typed.rsplit('.', 2)[0] # `--kind` HAS ITS OWN VOCABULARY, AND IT IS NOT THE INDEX'S. The flag takes the seven words the @@ -307,7 +315,8 @@ class Impact: if pre: tail = re.sub(r'\(.*\)$', '', sel).replace('#', '.').strip('.').rsplit('.', 2)[-2:] short = '.'.join(tail) - if self.CONFIG_RE.match(re.sub(r'\(.*\)$', '', sel).strip()): + key = re.sub(r'\(.*\)$', '', sel).strip() + if self.CONFIG_RE.match(key) and (self.has_config() or not self.declared_name(key.rsplit('.', 1)[-1])): # a lowercase dotted key reaching a declaration only by its tail is the key, not the code return self.config_target(re.sub(r'\(.*\)$', '', sel).strip()) die(f"'{pre}' matches no package, type or declaration in this graph; '{sel}' was matched only by its\n" @@ -342,7 +351,10 @@ class Impact: f" properties file, a YAML, an XML). Do not read this as 'nothing uses it'.") return [('string', f"the string {raw} ({n} literal site(s), {d} decoration site(s))", v)] s = raw.strip('`"\'') - if (kind == 'config' or (not kind and self.CONFIG_RE.match(s) and not self.field_rows(s) and not self.types(s, soft=True) and not self.methods(s, soft=True))): return self.config_target(s) + # a lowercase dotted name is read as a configuration key when the graph has configuration facts, or when its last + # segment names nothing the code declares; `util.square` ending in a real function is a declaration that missed + if (kind == 'config' or (not kind and self.CONFIG_RE.match(s) and (self.has_config() or not self.declared_name(s.rsplit('.', 1)[-1])) + and not self.field_rows(s) and not self.types(s, soft=True) and not self.methods(s, soft=True))): return self.config_target(s) if s.startswith('@'): # an annotation: half of Java-touching commits change one (#758) pat = s[1:].replace('*', '%') rows = self.g.q("SELECT DISTINCT d.owner_id FROM decorations d JOIN symbols x ON x.id = d.owner_id WHERE d.name LIKE ? OR d.name LIKE ?", pat, f"%.{pat}") @@ -478,12 +490,17 @@ class Impact: cand = '.'.join(parts[k:]) r = self.g.q("SELECT id FROM symbols WHERE type_id IS NOT NULL AND (display = ? OR qualified_name = ?)", cand, cand) or \ [x for x in self.g.q("SELECT id, display FROM symbols WHERE type_id IS NOT NULL AND display LIKE ?", f"%.{cand}") if x['display'].endswith('.' + cand)] + if not r: # the dotted spelling, only after the written one + r = self.g.q("SELECT id, qualified_name, file, line FROM symbols WHERE type_id IS NOT NULL AND qualified_name LIKE ? AND canon_is(qualified_name, ?)", '%' + cand.rsplit('.', 1)[-1], cand) + if r: self.g.separator_collision(s, r) if r: return [x[0] for x in r] if soft: return [] die(f"no type named {s}") def field_rows(self, s): parts = s.split('.'); name = parts[-1]; owner = '.'.join(parts[:-1]) rows = [f for f in self.fields.values() if f['name'] == name and (not owner or (f['owner'] or '') == owner or (f['owner'] or '').endswith('.' + owner) or (f['qualified_name'] or '').endswith('.' + s))] + if not rows and '.' in s: # the dotted spelling, only after the written one + rows = [f for f in self.fields.values() if f['name'] == name and P.canon_is(f['qualified_name'] or '', s)] return rows def refs_in(self, cid, name, kinds): s = self.g.sym[cid] @@ -1129,8 +1146,8 @@ class Impact: for n in os.listdir(D): if not n.endswith('.facts'): continue if n[:-6] in skip: open(os.path.join(F, n), 'w').close() # an empty file: declared, never read - else: os.symlink(os.path.join(D, n), os.path.join(F, n)) - for n in ('edge', 'named'): os.symlink(os.path.join(g.facts, n + '.facts'), os.path.join(F, n + '.facts')) + else: P.link_facts(os.path.join(D, n), os.path.join(F, n)) + for n in ('edge', 'named'): P.link_facts(os.path.join(g.facts, n + '.facts'), os.path.join(F, n + '.facts')) T = []; textuse = []; importuse = []; inside = [] def type_query(qq, tid): t = g.sym[tid]; T.append((qq, 'type', tid, '')) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 14817e42a..c8f062565 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -118,7 +118,10 @@ A = { refKinds={'IDENTIFIER', 'PROPERTY_ACCESS'}, entityKind='referenceKind', litKinds={'LITERAL'}, litType=('literalKind', 'STRING'), litValue='text'), comments=dict(file='all-javascript-comments.csv', text='text', kind='commentKind', line='startLine', fileVia=('modules', 'ownerModuleLinkHash')), - decls=[dict(file='all-javascript-variables.csv', only=lambda r: not r.get('ownerMethodLinkHash') and r.get('bindingRegime') != 'IMPORT_BINDING', + # a `function f` or `class C` is also a binding (FUNCTION_DECLARATION_HOISTED, CLASS_TDZ), and `const { C } = + # require('./m')` is an import in all but syntax (it carries an importLinkHash): none of them is a variable, and + # each made the function or class it names look declared as a second kind, so `impact C` refused as ambiguous + decls=[dict(file='all-javascript-variables.csv', only=lambda r: not r.get('ownerMethodLinkHash') and r.get('bindingRegime') not in ('IMPORT_BINDING', 'FUNCTION_DECLARATION_HOISTED', 'CLASS_TDZ') and not r.get('importLinkHash'), kind=lambda r: 'const' if r.get('bindingRegime', '').startswith('CONST') else 'variable', name='name', owner=None, fileVia=('modules', 'ownerModuleLinkHash'), line='startLine', end='endLine'), dict(file='all-javascript-fields.csv', kind=lambda r: 'field', name='name', owner=None, fileVia=('modules', 'ownerModuleLinkHash'), line='startLine', end='startLine')], # `const foo = () => …`: the variable's initializerExpressionLinkHash is the FUNCTION_EXPRESSION row whose diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 5bd0c2593..111dae6f8 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -66,6 +66,29 @@ def chain_json(g, chain): BODILESS_KINDS = {'METHOD_SIGNATURE', 'TYPE_LITERAL_METHOD_SIGNATURE', 'CALL_SIGNATURE', 'TYPE_LITERAL_CALL_SIGNATURE', 'FUNCTION_TYPE_SIGNATURE', 'CONSTRUCT_SIGNATURE', 'TYPE_LITERAL_CONSTRUCT_SIGNATURE', 'CONSTRUCTOR_TYPE_SIGNATURE'} +# ONE SPELLING FOR EVERY LANGUAGE. Each front end writes a qualified name its own way: Java and C# `pkg.Owner.m`, +# Python `pkg.module.f`, TypeScript `src/util#square`, JavaScript `src/util.square`. canon() maps all of them onto the +# dotted form (`src.util.square`), and the resolvers match it beside the native spellings, so `util.square` or +# `src.util.square` names the same declaration in every language, and a name one verb prints another accepts. +_CANON_SEP = re.compile(r'::|[/\\#$]') +def canon(q): + if not q: return q + return re.sub(r'\.{2,}', '.', _CANON_SEP.sub('.', q)).strip('.') +def canon_is(q, name): + """1 when `name`, in any spelling (`/`, `#`, `.`), is q or a whole-segment suffix of it. Case-sensitive, unlike LIKE.""" + c, n = canon(q), canon(name) + return int(bool(c) and bool(n) and (c == n or c.endswith('.' + n))) + + +def link_facts(src, dst): + """make a fact file visible under another directory. A symlink needs a privilege on Windows that an unelevated user + does not hold (WinError 1314), so a hard link is tried next and a copy last: the solver only reads the file.""" + try: os.symlink(src, dst); return + except (OSError, NotImplementedError): pass + try: os.link(src, dst); return + except OSError: shutil.copyfile(src, dst) + + class G: def __init__(self, repo): self.repo = os.path.realpath(repo or '.') @@ -78,12 +101,12 @@ class G: # words the caller can act on. if not os.path.exists(self.db) and not ax_contract.ensure_graph(self.repo, self.db): die(ax_contract.no_graph(self.repo, self.db)) - self.con = sqlite3.connect(self.db); self.con.row_factory = sqlite3.Row + self.connect() if not self.has('symbols'): # a graph that was built but never indexed: the index is this tool's own, and adding it # is seconds, so asking the caller to run one more command buys nothing. subprocess.run([sys.executable, os.path.join(HERE, 'axiomcode-index'), self.repo], capture_output=True) - self.con.close(); self.con = sqlite3.connect(self.db); self.con.row_factory = sqlite3.Row + self.con.close(); self.connect() if not self.has('symbols'): die(f"{self.db} has no index — run `axiomcode index {self.repo}`") # `sym` is the CALLABLE/type layer: it is what an endpoint may resolve to, so a field must stay out of it. # `all_sym` is every declaration the index holds, fields, constants and enum members included. A consumer that @@ -97,6 +120,10 @@ class G: self.callers_ids = {r['caller_id'] for r in self.q("SELECT DISTINCT caller_id FROM call_edges")} # a type whose field initializers call: a node too self.decorations_from_sites() + def connect(self): + self.con = sqlite3.connect(self.db); self.con.row_factory = sqlite3.Row + self.con.create_function('canon_is', 2, canon_is, deterministic=True) + # ── decorations the front end records as calls, not as decorations ─────────────────────────────────────── # A TypeScript or JavaScript graph has a `decorations` table with NOTHING IN IT, and the same decorators sitting # in `call_sites` as DECORATOR_CALL rows — the parser records `@Get(':id')` as a call to `Get`, and no projection @@ -159,6 +186,19 @@ class G: if len(kept) < len(ids): label += f" [{len(kept)} under *{self.IN}*]" ids = kept return label, ids + def separator_collision(self, typed, rows): + """two declarations whose names differ only in separators (`a/b#c`, `a.b/c`) are the same dotted name. Neither + is the answer: the caller is told both, and the exact spelling of each still resolves to that one alone.""" + by = collections.defaultdict(set) + for r in rows: + q = r['qualified_name'] + if q: by[canon(q)].add(q) + clash = {c: sorted(n) for c, n in by.items() if len(n) > 1} + if not clash: return + where = {r['qualified_name']: f"{r['file']}:{r['line']}" for r in rows if r['qualified_name'] and r['file']} + lines = [f" {n} {where.get(n, '')}" for names in clash.values() for n in names] + die(f"'{typed}' names {sum(len(n) for n in clash.values())} declarations whose names differ only in separators " + f"(/ # .):\n" + '\n'.join(lines) + "\n ask with the exact spelling of the one you mean") def method_kind(self, i): if not hasattr(self, '_mkind'): self._mkind = {r[0]: r[1] for r in self.q("SELECT id, kind FROM methods")} if self.has('methods') else {} @@ -253,6 +293,13 @@ class G: cand = '.'.join(parts[k:]) rows = self.q("SELECT id, kind, display, type_id, method_id FROM symbols WHERE display = ? OR qualified_name = ? OR qualified_name LIKE ?", cand, cand, f"%.{cand}") if not rows: rows = self.q("SELECT id, kind, display, type_id, method_id FROM symbols WHERE display LIKE ? OR display LIKE ?", f"%.{cand}", f"%#{cand}") + # the name as written matched nothing: only then is it compared in the one dotted spelling, so a name that + # resolved before resolves to the same declaration now, whatever a dotted lookalike elsewhere is called + if not rows: + # LIKE on the last segment first: C-speed, and a superset (it folds case, `_` is a wildcard), so the Python + # comparison sees a handful of rows instead of every symbol (2.3 s → 0.06 s on a million) + rows = self.q("SELECT id, kind, display, type_id, method_id, qualified_name, file, line FROM symbols WHERE qualified_name LIKE ? AND canon_is(qualified_name, ?)", '%' + cand.rsplit('.', 1)[-1], cand) + self.separator_collision(sel, rows) if rows: used = cand; break exact = [r for r in rows if r['display'] == used or (r['display'] or '').endswith('.' + used) and r['display'].count('.') == used.count('.') + 1] or rows methods = [r['id'] for r in exact if r['method_id'] and r['kind'] != 'module'] @@ -392,6 +439,10 @@ class G: references T where the parser gives a line (type references / identifier references). Java type references carry no line, so there the constructor calls and identifier uses are what is found; the answer says which kinds were matched.""" name = s.split('.')[-1]; ids = []; parts = [] + # a qualified name whose last segment the client itself declares is a declaration asked for under a wrong + # prefix, not a type from outside: answering for every reference to that short name is a different question + if '.' in s and self.q("SELECT 1 FROM symbols WHERE name = ? AND (method_id IS NOT NULL OR type_id IS NOT NULL) AND kind NOT IN ('library', 'written') LIMIT 1", name): + return None w = self.written('new ' + name) if w: ids += w[1]; parts.append(f"new {name} at {len(self.SITES[w[1][0]])} site(s)") lib = self.library(s + '.*') if '.' in s else self.library(name + '.*') @@ -557,12 +608,12 @@ def run_dl(g, queries, programs=('path.dl',)): 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')) + for n in ('byname', 'named'): link_facts(os.path.join(g.facts, n + '.facts'), os.path.join(F, n + '.facts')) if g.EXTRA: shutil.copy(os.path.join(g.facts, 'edge.facts'), os.path.join(F, 'edge.facts')) with open(os.path.join(F, 'edge.facts'), 'a') as f: for a, b, t in g.EXTRA: f.write(f"{a}\t{b}\t{t}\n") - else: os.symlink(os.path.join(g.facts, 'edge.facts'), os.path.join(F, 'edge.facts')) + else: link_facts(os.path.join(g.facts, 'edge.facts'), os.path.join(F, 'edge.facts')) g.write('src', [(q, m) for q, (s, _) in queries.items() for m in s], F) g.write('dst', [(q, m) for q, (_, d) in queries.items() for m in d], F) out = {n: collections.defaultdict(list) for n in ('hit', 'parent', 'hit_opt', 'parent_opt', 'between_edge', 'dist_up', 'dist')} diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py b/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py index d19b61e1c..3f742d6dc 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py @@ -71,9 +71,26 @@ 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()) + # An Intel python3 on an Apple Silicon Mac runs under Rosetta and reports x86_64, while npm, going by Node's arch, + # installed the arm64 engine. The hardware decides: an arm64 binary runs natively even from a translated process. + if o == 'darwin' and a == 'x64' and _sysctl('hw.optional.arm64') == '1': a = 'arm64' return f'{o}-{a}' if o and a else None +def _sysctl(key): + try: return subprocess.run(['/usr/sbin/sysctl', '-n', key], capture_output=True, text=True, timeout=5).stdout.strip() + except (OSError, subprocess.SubprocessError): return '' + + +def candidate_platforms(): + """the engine packages to look in, best first: this machine's, then the same OS's other architecture. npm installs + exactly one per machine, so when the first is absent the installed one is the one npm chose for this machine.""" + plat = npm_platform() + if not plat: return [] + o, a = plat.split('-') + return [plat, f"{o}-{'x64' if a == 'arm64' else 'arm64'}"] + + 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 @@ -81,7 +98,11 @@ def engine_roots(): 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)))) + if b: + roots.append(os.path.dirname(os.path.dirname(os.path.realpath(b)))) + # on Windows npm links the command as axiomcode.cmd / .ps1 files beside node_modules, not as a symlink into + # the package, so realpath leads nowhere near it: the package is /node_modules/@axiomcode/code-graph + roots.append(os.path.join(os.path.dirname(b), 'node_modules', SCOPE, 'code-graph')) return roots @@ -89,27 +110,26 @@ 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 + for plat in candidate_platforms(): + 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 diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index 64f9ff1a4..1372cb61e 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -67,7 +67,8 @@ does not restrict. Detail: `reference/context.md`. `axiomcode impact … [--depth N] [--in ] [--delete]`. Targets as written in the code: `Owner.method`, `Owner.field`, `Type`, `Owner.method(param)`, `Type`, `Owner.method:local`, a config key, or -`file.ts:123` — the declaration at that line. **When you know where the declaration is, target it by `file:line`**: a +`file.ts:123` — the declaration at that line. Separators are interchangeable in every language: `util.square`, +`src.util.square` and `src/util#square` are one name. **When you know where the declaration is, target it by `file:line`**: a bare name answers for EVERY declaration of that name, and two unrelated functions in different files come back as one. Sections: **must change with it** · **produces or writes it** · **reads or uses it** (by rung) · **reaches those** (transitively: what can reach a user, not where the value goes) · tests, counted by rung with the strong ones named · `verified:` · `bound:`. For the full test list ask second: `--tests-only` (grouped by rung and file), `--why` for routes, `--tests-in ` to narrow. A long answer comes in pages of ~2000 tokens with the whole answer's counts on every page; `--page 2` (MCP `page=2`) only when page 1's strongest rows are not enough. It finds config diff --git a/tests/README.md b/tests/README.md index 1aef034a3..581f3cd3d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -33,6 +33,8 @@ One check needs no graph and is its own script: the budget said spent once, its second half kept for edges into unopened files (#1199; indexes a small project, so it needs the engine) python3 tests/engine_choice.py axiomcode-build picks a built engine over an unbuilt clone it sits in + python3 tests/no_symlink.py impact and the Datalog path answer where os.symlink is refused, as it is for an + unelevated Windows user (WinError 1314); indexes a case, so it needs the engine python3 tests/tiers.py every call_edges tier the schema documents is ranked, labelled and given a certainty by the frontend, so a new tier cannot read as the weakest claim diff --git a/tests/cases/javascript/dotted-target/case.json b/tests/cases/javascript/dotted-target/case.json new file mode 100644 index 000000000..7bea19a62 --- /dev/null +++ b/tests/cases/javascript/dotted-target/case.json @@ -0,0 +1,10 @@ +{"lang": "javascript", "src": "src", + "checks": [ + {"why": "the dotted module form resolves in JavaScript, whose own spelling mixes / and .", + "run": ["impact", "util.square"], + "want": ["2 resolved"], + "avoid": ["configuration key"]}, + {"why": "backward compatible: the native spelling still resolves to the same function", + "run": ["impact", "util#square"], + "want": ["2 resolved"], + "avoid": ["configuration key", "matches no package"]}]} diff --git a/tests/cases/javascript/dotted-target/src/model.js b/tests/cases/javascript/dotted-target/src/model.js new file mode 100644 index 000000000..fc3779030 --- /dev/null +++ b/tests/cases/javascript/dotted-target/src/model.js @@ -0,0 +1,10 @@ +const { square } = require('./util'); +class Circle { + constructor(r) { this.r = r; } + area() { return square(this.r) * Math.PI; } +} +class Square { + constructor(s) { this.s = s; } + area() { return square(this.s); } +} +module.exports = { Circle, Square }; diff --git a/tests/cases/javascript/dotted-target/src/service.js b/tests/cases/javascript/dotted-target/src/service.js new file mode 100644 index 000000000..c3a5f7c86 --- /dev/null +++ b/tests/cases/javascript/dotted-target/src/service.js @@ -0,0 +1,10 @@ +const { Circle, Square } = require('./model'); +function total(shapes) { + let sum = 0; + for (const s of shapes) sum += s.area(); + return sum; +} +function main() { + console.log(total([new Circle(1), new Square(2)])); +} +module.exports = { total, main }; diff --git a/tests/cases/javascript/dotted-target/src/util.js b/tests/cases/javascript/dotted-target/src/util.js new file mode 100644 index 000000000..581a91725 --- /dev/null +++ b/tests/cases/javascript/dotted-target/src/util.js @@ -0,0 +1,2 @@ +function square(x) { return x * x; } +module.exports = { square }; diff --git a/tests/cases/javascript/imported-class/case.json b/tests/cases/javascript/imported-class/case.json new file mode 100644 index 000000000..2be2b24d4 --- /dev/null +++ b/tests/cases/javascript/imported-class/case.json @@ -0,0 +1,35 @@ +{ + "lang": "javascript", + "src": "src", + "checks": [ + { + "why": "a class imported elsewhere with `const { C } = require(...)` is one declaration: neither its own binding nor the require destructuring is a second, variable declaration", + "run": [ + "impact", + "Circle" + ], + "want": [ + "class Circle", + "instantiates it" + ], + "avoid": [ + "declared as more than one kind", + "variable Circle" + ] + }, + { + "why": "a function declaration is a method, not also a variable of the same name", + "run": [ + "impact", + "square" + ], + "want": [ + "2 resolved" + ], + "avoid": [ + "declared as more than one kind", + "by name" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/javascript/imported-class/src/model.js b/tests/cases/javascript/imported-class/src/model.js new file mode 100644 index 000000000..fc3779030 --- /dev/null +++ b/tests/cases/javascript/imported-class/src/model.js @@ -0,0 +1,10 @@ +const { square } = require('./util'); +class Circle { + constructor(r) { this.r = r; } + area() { return square(this.r) * Math.PI; } +} +class Square { + constructor(s) { this.s = s; } + area() { return square(this.s); } +} +module.exports = { Circle, Square }; diff --git a/tests/cases/javascript/imported-class/src/service.js b/tests/cases/javascript/imported-class/src/service.js new file mode 100644 index 000000000..c3a5f7c86 --- /dev/null +++ b/tests/cases/javascript/imported-class/src/service.js @@ -0,0 +1,10 @@ +const { Circle, Square } = require('./model'); +function total(shapes) { + let sum = 0; + for (const s of shapes) sum += s.area(); + return sum; +} +function main() { + console.log(total([new Circle(1), new Square(2)])); +} +module.exports = { total, main }; diff --git a/tests/cases/javascript/imported-class/src/util.js b/tests/cases/javascript/imported-class/src/util.js new file mode 100644 index 000000000..581a91725 --- /dev/null +++ b/tests/cases/javascript/imported-class/src/util.js @@ -0,0 +1,2 @@ +function square(x) { return x * x; } +module.exports = { square }; diff --git a/tests/cases/typescript/dotted-target/case.json b/tests/cases/typescript/dotted-target/case.json new file mode 100644 index 000000000..1f490a1d3 --- /dev/null +++ b/tests/cases/typescript/dotted-target/case.json @@ -0,0 +1,153 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "a module-qualified dotted name resolves in TypeScript as it does in Java and Python: `util.square` is src/util#square, not a configuration key", + "run": [ + "impact", + "util.square" + ], + "want": [ + "2 resolved", + "Circle.area", + "Square.area" + ], + "avoid": [ + "configuration key" + ] + }, + { + "why": "the whole dotted form, directory included (src/lib/util.ts is lib/util#square), resolves too", + "run": [ + "impact", + "lib.util.square" + ], + "want": [ + "2 resolved" + ], + "avoid": [ + "configuration key" + ] + }, + { + "why": "backward compatible: the native spelling `lib/util#square` still resolves, to the same declaration as the dotted form", + "run": [ + "impact", + "lib/util#square" + ], + "want": [ + "2 resolved", + "Circle.area", + "Square.area" + ], + "avoid": [ + "configuration key", + "matches no package" + ] + }, + { + "why": "backward compatible: the native spelling `util#square` still resolves, to the same declaration as the dotted form", + "run": [ + "impact", + "util#square" + ], + "want": [ + "2 resolved", + "Circle.area", + "Square.area" + ], + "avoid": [ + "configuration key", + "matches no package" + ] + }, + { + "why": "backward compatible: the native spelling `lib/util.square` still resolves, to the same declaration as the dotted form", + "run": [ + "impact", + "lib/util.square" + ], + "want": [ + "2 resolved", + "Circle.area", + "Square.area" + ], + "avoid": [ + "configuration key", + "matches no package" + ] + }, + { + "why": "a dotted module prefix on an Owner.member resolves", + "run": [ + "impact", + "model.Circle.area" + ], + "want": [ + "one of a set" + ], + "avoid": [ + "matches no package" + ] + }, + { + "why": "the dotted form works as a path endpoint as well", + "run": [ + "path", + "service.main", + "util.square" + ], + "want": [ + "reached through resolved calls" + ] + }, + { + "why": "a wrong prefix on a name the code declares is a miss that names the close declarations, not a configuration key and not every reference to `square`", + "run": [ + "impact", + "zzz.square" + ], + "expect_error": true, + "want": [ + "nothing named 'zzz.square'", + "square" + ], + "avoid": [ + "configuration key", + "referenced by name" + ] + }, + { + "why": "a dotted key whose last segment the code does not declare is still read as a configuration key", + "run": [ + "impact", + "app.server.prefix" + ], + "expect_error": true, + "want": [ + "configuration key", + "NOT visible" + ] + }, + { + "why": "changed hands impact the full dotted name of the edited declaration, so a hook answers for it alone, and still prints the short one", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.ts", + "--new", + "{repo}/new.ts", + "--file", + "src/lib/util.ts", + "--json" + ], + "stdout_json": true, + "want": [ + "\"target\": \"lib.util.square\"", + "\"shown_target\": \"square\"" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/typescript/dotted-target/new.ts b/tests/cases/typescript/dotted-target/new.ts new file mode 100644 index 000000000..65463ac1d --- /dev/null +++ b/tests/cases/typescript/dotted-target/new.ts @@ -0,0 +1 @@ +export function square(x: number): number { return x * x * 1; } diff --git a/tests/cases/typescript/dotted-target/old.ts b/tests/cases/typescript/dotted-target/old.ts new file mode 100644 index 000000000..c44df747a --- /dev/null +++ b/tests/cases/typescript/dotted-target/old.ts @@ -0,0 +1 @@ +export function square(x: number): number { return x * x; } diff --git a/tests/cases/typescript/dotted-target/src/lib/model.ts b/tests/cases/typescript/dotted-target/src/lib/model.ts new file mode 100644 index 000000000..fcf07ec72 --- /dev/null +++ b/tests/cases/typescript/dotted-target/src/lib/model.ts @@ -0,0 +1,10 @@ +import { square } from './util'; +export interface Shape { area(): number; } +export class Circle implements Shape { + constructor(private r: number) {} + area(): number { return square(this.r) * Math.PI; } +} +export class Square implements Shape { + constructor(private s: number) {} + area(): number { return square(this.s); } +} diff --git a/tests/cases/typescript/dotted-target/src/lib/service.ts b/tests/cases/typescript/dotted-target/src/lib/service.ts new file mode 100644 index 000000000..808f79053 --- /dev/null +++ b/tests/cases/typescript/dotted-target/src/lib/service.ts @@ -0,0 +1,9 @@ +import { Circle, Square, Shape } from './model'; +export function total(shapes: Shape[]): number { + let sum = 0; + for (const s of shapes) sum += s.area(); + return sum; +} +export function main(): void { + console.log(total([new Circle(1), new Square(2)])); +} diff --git a/tests/cases/typescript/dotted-target/src/lib/util.ts b/tests/cases/typescript/dotted-target/src/lib/util.ts new file mode 100644 index 000000000..c44df747a --- /dev/null +++ b/tests/cases/typescript/dotted-target/src/lib/util.ts @@ -0,0 +1 @@ +export function square(x: number): number { return x * x; } diff --git a/tests/cases/typescript/separator-collision/case.json b/tests/cases/typescript/separator-collision/case.json new file mode 100644 index 000000000..f769f504d --- /dev/null +++ b/tests/cases/typescript/separator-collision/case.json @@ -0,0 +1,22 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "three declarations whose names differ only in separators (a/b#c, a.b#c, a#b.c) are one dotted name: it is refused with every spelling listed, never answered for one of them", + "run": ["impact", "a.b.c"], + "expect_error": true, + "want": ["differ only in separators", "a/b#c", "a.b#c", "a#b.c", "exact spelling"]}, + {"why": "the exact spelling of the function in a/b.ts resolves to that function alone", + "run": ["impact", "a/b#c"], + "want": ["useDir"], + "avoid": ["useDotted", "useMethod", "differ only in separators"]}, + {"why": "the exact spelling of the function in a.b.ts resolves to that function alone", + "run": ["impact", "a.b#c"], + "want": ["useDotted"], + "avoid": ["useDir", "useMethod", "differ only in separators"]}, + {"why": "a spelling that matches as written wins over the dotted comparison: b.c is the method, not the two functions that are also dotted a.b.c", + "run": ["impact", "b.c"], + "want": ["useMethod"], + "avoid": ["useDir", "useDotted", "differ only in separators"]}, + {"why": "the collision is refused as a path endpoint too", + "run": ["path", "useDir", "a.b.c"], + "expect_error": true, + "want": ["differ only in separators"]}]} diff --git a/tests/cases/typescript/separator-collision/src/a.b.ts b/tests/cases/typescript/separator-collision/src/a.b.ts new file mode 100644 index 000000000..958433b00 --- /dev/null +++ b/tests/cases/typescript/separator-collision/src/a.b.ts @@ -0,0 +1 @@ +export function c(): number { return 2; } diff --git a/tests/cases/typescript/separator-collision/src/a.ts b/tests/cases/typescript/separator-collision/src/a.ts new file mode 100644 index 000000000..f57b7884c --- /dev/null +++ b/tests/cases/typescript/separator-collision/src/a.ts @@ -0,0 +1,3 @@ +export class b { + c(): number { return 3; } +} diff --git a/tests/cases/typescript/separator-collision/src/a/b.ts b/tests/cases/typescript/separator-collision/src/a/b.ts new file mode 100644 index 000000000..25e25af33 --- /dev/null +++ b/tests/cases/typescript/separator-collision/src/a/b.ts @@ -0,0 +1 @@ +export function c(): number { return 1; } diff --git a/tests/cases/typescript/separator-collision/src/main.ts b/tests/cases/typescript/separator-collision/src/main.ts new file mode 100644 index 000000000..67641cdca --- /dev/null +++ b/tests/cases/typescript/separator-collision/src/main.ts @@ -0,0 +1,6 @@ +import { c as fromDir } from './a/b'; +import { c as fromDotted } from './a.b'; +import { b } from './a'; +export function useDir(): number { return fromDir(); } +export function useDotted(): number { return fromDotted(); } +export function useMethod(): number { return new b().c(); } diff --git a/tests/cli_version.py b/tests/cli_version.py new file mode 100644 index 000000000..947570439 --- /dev/null +++ b/tests/cli_version.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""tests/cli_version.py — `axiomcode --version` prints the package version (#1352). + +It is answered by the Node launcher before bash is looked for, so it is checked there with no bash on PATH, +and by bin/axiomcode for a checkout. With other arguments --version is still the build option, not this. + + python3 tests/cli_version.py +""" +import json, os, shutil, subprocess, sys, tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +VERSION = json.load(open(os.path.join(ROOT, 'package.json')))['version'] + +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) + + +node = shutil.which('node') +with tempfile.TemporaryDirectory() as only_node: + os.symlink(node, os.path.join(only_node, 'node')) + r = subprocess.run([node, os.path.join(ROOT, 'bin', 'axiomcode.js'), '--version'], capture_output=True, text=True, + env=dict(os.environ, PATH=only_node, AXIOMCODE_BASH=''), timeout=30) + check('the installed command prints the package version, with no bash on PATH', + r.returncode == 0 and r.stdout == VERSION + '\n', f'rc={r.returncode} out={r.stdout!r} err={r.stderr[-200:]!r}') + +r = subprocess.run(['bash', os.path.join(ROOT, 'bin', 'axiomcode'), '--version'], capture_output=True, text=True, timeout=30) +check('bin/axiomcode prints the same version', r.returncode == 0 and r.stdout == VERSION + '\n', f'rc={r.returncode} out={r.stdout!r}') + +r = subprocess.run(['bash', os.path.join(ROOT, 'bin', 'axiomcode'), '--version', 'v9'], capture_output=True, text=True, timeout=30) +check('--version with a value is not taken for the version query', r.returncode != 0 and VERSION not in r.stdout, + f'rc={r.returncode} out={r.stdout!r}') + +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) diff --git a/tests/no_symlink.py b/tests/no_symlink.py new file mode 100644 index 000000000..15254b180 --- /dev/null +++ b/tests/no_symlink.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""tests/no_symlink.py — impact and the Datalog path answer where os.symlink is refused. + +On Windows a symlink needs a privilege an unelevated user does not hold, and os.symlink raises WinError 1314. +Both query programs stage their fact files by linking them into a scratch directory, so every `impact` (and +`test-impact`, `changed --impact`, the MCP tools and the hooks that call them) died with a traceback for an +ordinary user, while CI's elevated Windows runner passed. The refusal is reproduced here on any OS: a +sitecustomize on PYTHONPATH replaces os.symlink with one that raises exactly that error. + +Indexes one case, so it needs the engine (AXIOMCODE_ENGINE, as tests/run.py). + + python3 tests/no_symlink.py +""" +import os, shutil, subprocess, sys, tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SCRIPTS = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts') +CASE = os.path.join(ROOT, 'tests', 'cases', 'java', 'impact-answer-in-pages', 'src') +DENY = '''import os +def _deny(*a, **k): + raise OSError(1314, 'A required privilege is not held by the client') +os.symlink = _deny +''' + + +def main(): + work = tempfile.mkdtemp(prefix='axiomcode-nosymlink-') + try: + repo = os.path.join(work, 'repo'); shutil.copytree(CASE, repo) + subprocess.run(['git', 'init', '-q', '.'], cwd=repo, check=True) + subprocess.run(['git', 'add', '-A'], cwd=repo, check=True) + subprocess.run(['git', '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x'], cwd=repo, check=True) + r = subprocess.run(['bash', os.path.join(SCRIPTS, 'axiomcode-build'), repo], capture_output=True, text=True, timeout=600) + if r.returncode != 0: + print('no_symlink: index failed\n' + (r.stdout + r.stderr)[-1500:]); return 1 + site = os.path.join(work, 'site'); os.makedirs(site) + open(os.path.join(site, 'sitecustomize.py'), 'w').write(DENY) + env = dict(os.environ, PYTHONPATH=site + os.pathsep + os.environ.get('PYTHONPATH', '')) + checks = [ + ('impact answers where a symlink is refused', ['axiomcode-impact', 'Rates.rate', repo], {}, 'Quote.total'), + ('the Datalog path answers where a symlink is refused', ['axiomcode-path', 'Quote.total', 'Rates.rate', repo], {'AXIOMCODE_DATALOG': '1'}, 'the chain is verified'), + ] + bad = 0 + for why, cmd, extra, want in checks: + p = subprocess.run([sys.executable, os.path.join(SCRIPTS, cmd[0])] + cmd[1:], cwd=repo, env=dict(env, **extra), + capture_output=True, text=True, timeout=300) + out = p.stdout + p.stderr + ok = p.returncode == 0 and want in out and 'Traceback' not in out + print(('ok ' if ok else 'FAIL ') + why) + if not ok: bad += 1; print(' ' + out[-800:].replace('\n', '\n ')) + print(f"{len(checks) - bad} of {len(checks)} check(s) held" if not bad else f"{bad} FAILED") + return 1 if bad else 0 + finally: + shutil.rmtree(work, ignore_errors=True) + + +if __name__ == '__main__': + sys.exit(main())