Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/actions/bot/action.yml
Original file line number Diff line number Diff line change
@@ -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 <app-slug>[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: <user id>+<slug>[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
54 changes: 54 additions & 0 deletions .github/scripts/e2e-queries.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <qualified> 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
Expand Down Expand Up @@ -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")"
10 changes: 9 additions & 1 deletion .github/workflows/main-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
13 changes: 12 additions & 1 deletion .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
40 changes: 30 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ jobs:
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'
Expand All @@ -57,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)"
Expand All @@ -74,12 +83,12 @@ 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"
# 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 push origin "refs/tags/$tag"
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"
Expand All @@ -102,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
Expand All @@ -127,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"
Expand Down
11 changes: 11 additions & 0 deletions graph/pipeline/run-souffle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -394,14 +394,25 @@ 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
# rules. Found by walking up from the package root the way node would, so a checkout's own
# 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"
Expand Down
3 changes: 2 additions & 1 deletion plugins/axiomcode/hooks/changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion plugins/axiomcode/hooks/enrich.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
5 changes: 4 additions & 1 deletion plugins/axiomcode/mcp/find-bash.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
// <git>/mingw64/libexec/git-core, or <git>/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;
Expand Down
10 changes: 8 additions & 2 deletions plugins/axiomcode/mcp/find-python.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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 };
}
Expand All @@ -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';
Expand Down
Loading
Loading