Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1837ec9
parser/readme: C# and TypeScript are documented, and usage, output an…
swapnilpaliwal-sd Sep 25, 2026
52e42aa
Merge main into dev (a96a7f30)
github-actions[bot] Sep 25, 2026
c4b4689
Merge main into dev (42320adf)
github-actions[bot] Sep 25, 2026
47fd6c3
index: use python3's sqlite3 module, not the sqlite3 CLI (#1329) (#1333)
swapnilpaliwal-sd Sep 25, 2026
7bf92bf
windows: find Python as the MCP launcher does, and give bash a python…
swapnilpaliwal-sd Sep 25, 2026
3f8d673
graph: a small repository opens as clean as a large one, and no label…
swapnilpaliwal-sd Sep 25, 2026
c5b2cc8
README: replace the graph screenshot with the test-impact graph (#1338)
swapnilpaliwal-sd Sep 25, 2026
23ad48c
path --every: bound the route enumeration by work, not only by routes…
swapnilpaliwal-sd Sep 25, 2026
d1e269f
windows: the index stores '/' paths on every platform, so context and…
swapnilpaliwal-sd Sep 25, 2026
72daad6
engines: ship the query programs compiled, so impact and path need no…
swapnilpaliwal-sd Sep 25, 2026
55f31e2
path --every: list routes with Yen's k-shortest paths, count them up …
swapnilpaliwal-sd Sep 25, 2026
b587523
ci: the way into main installs the packages on all five platforms and…
swapnilpaliwal-sd Sep 25, 2026
e09a45e
context: a tree with every file at its root is scoped to the root, no…
swapnilpaliwal-sd Sep 25, 2026
f3cab62
Merge main into dev (9c2be22b)
github-actions[bot] Sep 25, 2026
966c0f3
ci: build the platform engines once per release, on the push that lan…
swapnilpaliwal-sd Sep 25, 2026
53b88f0
main: promote dev with a merge commit, not a squash (#1356)
swapnilpaliwal-sd Sep 26, 2026
53b7952
cli: axiomcode --version prints the installed version (#1352) (#1353)
swapnilpaliwal-sd Sep 26, 2026
74eeef5
0.1.2: CLI end to end on every platform, one target spelling, Windows…
swapnilpaliwal-sd Sep 26, 2026
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
5 changes: 3 additions & 2 deletions .github/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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")"
11 changes: 6 additions & 5 deletions .github/scripts/protect-main.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand All @@ -41,7 +42,6 @@ payload="$(cat <<'JSON'
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "required_linear_history" },
{
"type": "pull_request",
"parameters": {
Expand All @@ -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"]
}
},
{
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/build-engines.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 }
50 changes: 42 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)"

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand All @@ -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 }}
Expand Down
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
Loading
Loading