diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..484fec503 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# Reviewers requested automatically on every pull request. + +* @swapnilpaliwal-sd @JaredHLZhang @suyashpaliwal26 @Whua689 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..3ba13e0ce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/engine-defect.yml b/.github/ISSUE_TEMPLATE/engine-defect.yml new file mode 100644 index 000000000..f09f7b89f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/engine-defect.yml @@ -0,0 +1,52 @@ +name: Engine defect +description: A call edge the engine resolves wrongly, or does not resolve at all. +labels: [bug, engine] +body: + - type: markdown + attributes: + value: | + **Describe the defect with a minimal synthetic example.** Do not paste code from + a real project, name the project, or quote its identifiers or file names — this + tracker is a durable public record, and naming someone's codebase reads as a + benchmark claim about it. Reduce the finding to `class Widget`, `pkg/_helpers.py` + and the like, and describe the mechanism. + + - type: dropdown + id: language + attributes: + label: Front end + options: [java, python, typescript] + validations: { required: true } + + - type: textarea + id: repro + attributes: + label: Minimal example + description: The smallest synthetic source that shows it. Include the call site. + render: text + validations: { required: true } + + - type: textarea + id: expected + attributes: + label: What the engine should resolve, and what it resolves instead + description: | + Name the edge both ways — `A.foo -> B.bar`, versus the `ambiguous_unknown` or + wrong target you actually get. + validations: { required: true } + + - type: textarea + id: mechanism + attributes: + label: Mechanism + description: | + What does the rule join on, and why does the join fail? If you already know which + relation derives zero rows, say which. + validations: { required: false } + + - type: input + id: parser + attributes: + label: Parser commit + description: The SHA in `.github/parser-ref` you reproduced against. + validations: { required: false } diff --git a/.github/ISSUE_TEMPLATE/enhancement.yml b/.github/ISSUE_TEMPLATE/enhancement.yml new file mode 100644 index 000000000..f437ade66 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/enhancement.yml @@ -0,0 +1,25 @@ +name: Enhancement +description: Resolution power the engine does not have yet. +labels: [enhancement] +body: + - type: dropdown + id: language + attributes: + label: Front end + options: [java, python, typescript, all] + validations: { required: true } + + - type: textarea + id: construct + attributes: + label: The construct + description: A synthetic example of the code shape that does not resolve today. + render: text + validations: { required: true } + + - type: textarea + id: why + attributes: + label: Why it matters + description: What becomes answerable once this resolves. Mechanism, not corpus. + validations: { required: true } diff --git a/.github/ISSUE_TEMPLATE/harness.yml b/.github/ISSUE_TEMPLATE/harness.yml new file mode 100644 index 000000000..4fd1424c3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/harness.yml @@ -0,0 +1,26 @@ +name: Test harness or CI +description: A suite, golden, oracle or workflow that measures the wrong thing. +labels: [test] +body: + - type: markdown + attributes: + value: | + A measurement fault outranks an engine fault: while it stands, every number the + suite reports is unsafe to act on. Say what the harness claims and why that claim + is not true. + + - type: textarea + id: claim + attributes: + label: What the harness reports, and why it is wrong + validations: { required: true } + + - type: textarea + id: isolate + attributes: + label: How you isolated it + description: | + The rows you read, the A/B you ran, the commit on both arms. A regression that + was really the harness looks identical to one that was really the engine until + this part exists. + validations: { required: true } diff --git a/.github/ISSUE_TEMPLATE/parser-blocked.yml b/.github/ISSUE_TEMPLATE/parser-blocked.yml new file mode 100644 index 000000000..a2cf118e7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/parser-blocked.yml @@ -0,0 +1,42 @@ +name: Parser-blocked +description: The engine cannot resolve something because the IR does not carry it. +labels: [parser-blocked] +body: + - type: markdown + attributes: + value: | + Use this when the fix does not belong in this repository. The engine's rules are + read-only with respect to the parser: a missing fact is filed here, proven, and + fixed in `AxiomCodeAI/parser`. + + **A parser defect needs three things before it is one:** the parser source that + drops the fact, a synthetic input that reproduces it, and the real construct it + came from. Without all three this is a hypothesis. + + - type: dropdown + id: language + attributes: + label: Front end + options: [java, python, typescript] + validations: { required: true } + + - type: textarea + id: missing + attributes: + label: The fact that is missing + description: Which relation, which column, and what it should contain. + validations: { required: true } + + - type: textarea + id: evidence + attributes: + label: Evidence in the parser source + description: The file and function that drops it. Synthetic example only. + validations: { required: true } + + - type: textarea + id: blocked + attributes: + label: What this blocks here + description: The rule that cannot be written, or the edge that cannot resolve. + validations: { required: true } diff --git a/.github/RELEASING.md b/.github/RELEASING.md new file mode 100644 index 000000000..7918b8d01 --- /dev/null +++ b/.github/RELEASING.md @@ -0,0 +1,68 @@ +# Releasing + +One version, `package.json`'s, names everything that ships: `@axiomcode/code-graph`, the +`@axiomcode/engine--` packages it pins, the vendored parser and every plugin manifest. + +## Cutting a release + +1. **Bump** in a pull request: `node .github/scripts/version.mjs set 0.2.0` + (writes all ten locations; `check` verifies them). CI's version gate requires the bump to + move forward and not reuse a tagged version. +2. **Merge.** `release.yml` tags the merge commit `v0.2.0` and opens a **draft** release with + notes generated from the pull requests since the previous tag. Nothing is published yet. +3. **Publish the draft** under *Releases*. That runs `publish-npm.yml`: it checks the tag + against every manifest, builds every language's engine on every platform, publishes the + engine packages and then `@axiomcode/code-graph`, and attaches the tarballs to the release. + A version containing `-` (`0.2.0-rc.1`) is published under the `next` dist-tag, never `latest`. + +A failed publish can be re-run: versions already on the registry are skipped. + +To see what would ship without publishing: *Actions → publish-npm → Run workflow* (dry run by +default); the packed tarballs are uploaded as a workflow artifact. + +## When a pull request needs a bump + +Once a version is tagged, any change to a file that reaches users (everything outside the +`!` entries of `package.json`'s `files`, `graph/test/` and `.github/`) needs a new version, since +npm will not republish one. Before the first tag, changes simply join the unreleased version. + +## One-time setup + +- `CLI_BINARY_PUBLISH` repository secret: an npm token with publish rights on `@axiomcode`. +- The repository must be public: every platform, macOS included, builds on GitHub's standard + hosted runners, which are free only for public repositories. +- `bash .github/scripts/protect-main.sh` (admin): PR + green `CI` for everyone, only admins merge, + makes `v*` tags immutable. + +## dev and main + +`dev` is the default branch: every pull request lands there, and CI runs build, repo checks and +the five language suites on it (a docs-only change runs only the first two). Its one rule is that +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 +the commands to resolve it by hand. + +## Nightly + +`nightly.yml` is dev's status (the "nightly" badge in the README). Every night it builds `dev` +from scratch: no cached engines, the five suites, all four platforms' engines, then +`e2e-install.sh` packs the npm tarballs, installs them into an empty project without Soufflé and +runs `axiomcode` in four languages, and `npm publish --dry-run` checks each package. + +A green night is published under the `nightly` dist-tag, as `-nightly..g`, where +`` is the patch after the last release (`npm i @axiomcode/code-graph@nightly`). The version +is never committed or tagged, and plain `npm i` keeps getting `latest`. No nightly is published +until a first real release exists, because npm makes a package's first publish its `latest`. + +A red night publishes nothing and opens an issue; the next green night closes it. Run it by hand +from Actions at any time (it publishes only if you tick `publish`). + +## Caches + +Compiled engines are cached by ENGINE_ID, the hash of a language's rules and the Soufflé version, +so a change that touches no rules reuses every engine and a rule change recompiles only its own +language. The nightly and every real publish build fresh. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..27b47936c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,23 @@ +version: 2 +updates: + # Actions are pinned by major tag; this keeps them current and, more to the + # point, surfaces the deprecations that otherwise fail CI without warning. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + labels: [build, dependencies] + open-pull-requests-limit: 3 + + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + labels: [build, dependencies] + open-pull-requests-limit: 3 + # The engine's behaviour is pinned to the toolchain that produced the + # goldens. A TypeScript major arriving on its own schedule is a change to + # what this repo measures, not a routine bump. + ignore: + - dependency-name: typescript + update-types: [version-update:semver-major] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..ba5e3b9f5 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,40 @@ + + +Fixes # + +## What changed + + + +## Why the goldens moved, or why they did not + + + +## Evidence + + + +## Checklist + +- [ ] The three suites pass locally against the parser commit in `.github/parser-ref` +- [ ] Any golden that moved is explained above +- [ ] A fix validated on more than one shape, so this is not overfitting to one case +- [ ] Labels set, including the front end (`java` / `python` / `typescript`) diff --git a/.github/scripts/e2e-install.sh b/.github/scripts/e2e-install.sh new file mode 100755 index 000000000..5205c0ab9 --- /dev/null +++ b/.github/scripts/e2e-install.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# Install the packages the way a user would, and run them. +# +# e2e-install.sh +# +# is one platform's build-engines output (/axiomcode-engine- +# + /ENGINE_ID). This packs @axiomcode/code-graph and that platform's engine +# package exactly as publish-npm.yml would, installs both into an empty project, and +# runs `axiomcode` on with souffle NOT on PATH. It passes only if the +# installed package found its engine package, used it, and wrote a graph with edges. +# +# The test suites cannot see any of this: they run from the checkout, where a missing +# `files` entry, a broken bin, or an engine package the driver does not find all go +# unnoticed until someone installs a release. +# ───────────────────────────────────────────────────────────────────────────── +set -uo pipefail + +engines="${1:?usage: e2e-install.sh }" +platform="${2:?platform}"; lang="${3:?language}"; src="${4:?source-dir}" +root="$(cd "$(dirname "$0")/../.." && pwd)" +engines="$(cd "$engines" && pwd)"; src="$(cd "$src" && pwd)" +W="$(mktemp -d)"; SHADOW=""; trap 'rm -rf "$W" ${SHADOW:+"$SHADOW"}' EXIT +fail() { echo "::error::e2e: $*"; exit 1; } + +version="$(node "$root/.github/scripts/version.mjs" get)" +echo "── packing $version" +bash "$root/packaging/assemble-engine-package.sh" "$platform" "$version" "$engines" "$W/engine" >/dev/null \ + || fail "the engine package did not assemble" +mkdir -p "$W/tgz" +( cd "$W/engine" && npm pack --silent --pack-destination "$W/tgz" >/dev/null ) || fail "npm pack of the engine package failed" +# --ignore-scripts: the tree is already built; the tarball must carry what the build produced +( cd "$root" && npm pack --silent --ignore-scripts --pack-destination "$W/tgz" >/dev/null ) || fail "npm pack of code-graph failed" +ls -1 "$W/tgz" | sed 's/^/ /' + +echo "── installing into an empty project" +mkdir -p "$W/proj" +( cd "$W/proj" && npm init -y >/dev/null && npm install --no-audit --no-fund --loglevel=error "$W"/tgz/*.tgz ) \ + || fail "npm install of the packed tarballs failed" +bin="$W/proj/node_modules/.bin/axiomcode" +[ -x "$bin" ] || fail "the installed package has no axiomcode bin" + +. "$root/graph/test/tools/hide-souffle.sh" +PATH="$SANDBOX_PATH" command -v souffle >/dev/null 2>&1 && fail "souffle is still on the sandbox PATH" + +echo "── axiomcode $lang on $(basename "$src"), no souffle" +PATH="$SANDBOX_PATH" "$bin" "$src" "$W/out" --language "$lang" > "$W/run.log" 2>&1 +rc=$? +sed 's/^/ /' "$W/run.log" | tail -15 +[ "$rc" -eq 0 ] || fail "axiomcode exited $rc" +grep -q "using packaged engine" "$W/run.log" || fail "the run did not use the installed engine package" + +db="$(find "$W/out" -name graph.sqlite | head -1)" +[ -n "$db" ] || fail "no graph.sqlite was written" +edges="$(node -e ' + const { DatabaseSync } = require("node:sqlite"); + const db = new DatabaseSync(process.argv[1], { readOnly: true }); + console.log(db.prepare("SELECT COUNT(*) AS n FROM call_edges").get().n); +' "$db" 2>/dev/null)" +[ -n "$edges" ] && [ "$edges" -gt 0 ] || fail "the graph has no call edges (${edges:-unreadable})" +echo "e2e: ok — installed from the tarballs, used the packaged $platform engine, $edges call edges" diff --git a/.github/scripts/protect-main.sh b/.github/scripts/protect-main.sh new file mode 100755 index 000000000..428529ade --- /dev/null +++ b/.github/scripts/protect-main.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# Apply the rulesets for `main`, `dev` and release tags. +# +# bash .github/scripts/protect-main.sh # apply +# bash .github/scripts/protect-main.sh --dry-run # print the payload only +# +# Idempotent: updates the ruleset if it exists, creates it otherwise. +# +# What it enforces +# - no direct push to main, and no force-push +# - main cannot be deleted +# - every change arrives by pull request; no approving review is required +# - 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 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 +# +# protect-main has no bypass: the PR and CI rules hold for admins too. The merge +# restriction is a separate ruleset so that its bypass exempts nobody from those. Its +# bypass is `always`, not `pull_request`: in pull_request mode GitHub still refuses the +# merge itself ("Cannot update this protected ref"), so no one could merge at all. +# ───────────────────────────────────────────────────────────────────────────── +set -euo pipefail + +REPO="${REPO:-AxiomCodeAI/axiomcodegraph}" +DRY=0 +[ "${1:-}" = "--dry-run" ] && DRY=1 + +payload="$(cat <<'JSON' +{ + "name": "protect-main", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { "include": ["refs/heads/main"], "exclude": [] } + }, + "bypass_actors": [], + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { "type": "required_linear_history" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": false, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": true, + "allowed_merge_methods": ["squash", "rebase"] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "do_not_enforce_on_create": false, + "required_status_checks": [ + { "context": "CI" } + ] + } + } + ] +} +JSON +)" + +merge_payload="$(cat <<'JSON' +{ + "name": "main-merge-admins", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { "include": ["refs/heads/main"], "exclude": [] } + }, + "bypass_actors": [ + { "actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always" } + ], + "rules": [ + { "type": "update" } + ] +} +JSON +)" + +# dev is the DEFAULT branch: every pull request lands there, and main moves only by +# promotion. Its one rule is that it cannot be deleted; direct pushes, force-pushes and +# the sync-dev bot's merges from main are all allowed, because dev is where work is +# tried. The rulesets above name refs/heads/main rather than ~DEFAULT_BRANCH for the +# same reason: the default branch is dev, and main's protection must not follow it. +dev_payload="$(cat <<'JSON' +{ + "name": "protect-dev", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { "include": ["refs/heads/dev"], "exclude": [] } + }, + "bypass_actors": [], + "rules": [ + { "type": "deletion" } + ] +} +JSON +)" + +tag_payload="$(cat <<'JSON' +{ + "name": "protect-release-tags", + "target": "tag", + "enforcement": "active", + "conditions": { + "ref_name": { "include": ["refs/tags/v*"], "exclude": [] } + }, + "bypass_actors": [], + "rules": [ + { "type": "deletion" }, + { "type": "update" }, + { "type": "non_fast_forward" } + ] +} +JSON +)" + +if [ "$DRY" = "1" ]; then + echo "$payload" + echo "$merge_payload" + echo "$dev_payload" + echo "$tag_payload" + exit 0 +fi + +# Fail with something readable rather than a raw API error. +if ! gh api "repos/$REPO/rulesets" >/dev/null 2>&1; then + echo "cannot read rulesets on $REPO — check that this account has admin rights there" >&2 + exit 1 +fi + +apply_ruleset() { + local name="$1" body="$2" existing + existing="$(gh api "repos/$REPO/rulesets" --jq ".[] | select(.name==\"$name\") | .id" || true)" + if [ -n "$existing" ]; then + echo "updating ruleset $name ($existing) on $REPO" + printf '%s' "$body" | gh api -X PUT "repos/$REPO/rulesets/$existing" --input - >/dev/null + else + echo "creating ruleset $name on $REPO" + printf '%s' "$body" | gh api -X POST "repos/$REPO/rulesets" --input - >/dev/null + fi +} +apply_ruleset protect-main "$payload" +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 +# 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_rebase_merge=false \ + -F delete_branch_on_merge=true \ + -F allow_auto_merge=true >/dev/null + +echo "done. main requires a pull request and a green CI check; only admins merge." +gh api "repos/$REPO/rulesets" --jq '.[] | " ruleset: \(.name) enforcement=\(.enforcement)"' diff --git a/.github/scripts/run-suite.sh b/.github/scripts/run-suite.sh new file mode 100755 index 000000000..080d176b9 --- /dev/null +++ b/.github/scripts/run-suite.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# Run one engine regression suite under CI semantics. +# +# The suites are written for a developer laptop, where "the parser isn't built +# yet" is a good reason to step aside: they print SKIP and exit 77. On CI that is +# the one outcome that must never be tolerated. A gate that opens when its input +# is missing is worse than no gate at all — the pull request goes green having +# tested nothing, and the next person reads that green as evidence. +# +# So this wrapper turns every shape of "did not actually run" into a failure: +# +# 1. exit 77 from the suite itself; +# 2. a `SKIP: parser not found` line from a sub-harness (torture, fixtures) +# that caught its own 77 and carried on; +# 3. a suite that reported zero passing cases, which means the case loop found +# nothing to do and the exit status is meaningless. +# +# Usage: run-suite.sh [extra args passed to the suite] +# ───────────────────────────────────────────────────────────────────────────── +set -uo pipefail + +lang="${1:?usage: run-suite.sh [args...]}"; shift +root="$(cd "$(dirname "$0")/../.." && pwd)" +suite="$root/graph/test/$lang/run-tests.sh" + +[ -f "$suite" ] || { echo "::error::no suite at $suite"; exit 1; } + +# The parser lives in this repository (parser/) and `npm run build` builds it; the +# suites default AXIOM_PARSER to parser/dist/index.js. CI still refuses to start on a +# missing build rather than discover the absence halfway through as a SKIP. +AXIOM_PARSER="${AXIOM_PARSER:-$root/parser/dist/index.js}"; export AXIOM_PARSER +if [ ! -f "$AXIOM_PARSER" ]; then + echo "::error::AXIOM_PARSER=$AXIOM_PARSER does not exist. The parser did not build (npm run build)." + exit 1 +fi + +log="$(mktemp)" +echo "── $lang suite ── parser: $AXIOM_PARSER" +bash "$suite" "$@" 2>&1 | tee "$log" +rc="${PIPESTATUS[0]}" + +if [ "$rc" -eq 77 ]; then + echo "::error::the $lang suite skipped itself (exit 77). A skipped suite is a failed gate." + exit 1 +fi + +# A sub-harness that swallowed its own 77. The suite's exit status cannot see this: +# it counted the sub-harness as neither a pass nor a failure, so the run is green +# with a whole family unexecuted. +# +# EVERY skip is a failure here, not just the parser ones. The sub-harnesses decline +# for several different reasons — no parser, no javac, a JDK without +# java.lang.classfile (the torture oracle needs 24+), no oracle checkout — and each +# one is a dependency CI is supposed to provide. If a skip is ever legitimate it +# should be an explicit exclusion in this file, visible in a diff, rather than a +# green run that quietly tested less than the last one. +if grep -qE '(^|[[:space:]])SKIP([: (]|PED)' "$log"; then + echo "::error::a sub-harness in the $lang suite skipped instead of running:" + grep -nE '(^|[[:space:]])SKIP([: (]|PED)' "$log" | sed 's/^/ /' + echo "::error::CI must supply what it wanted. Do not relax this check to go green." + exit 1 +fi + +# "passed 0" means the case loop matched nothing — a rename or a bad filter, not a +# clean run. Guard it, because exit 0 with zero assertions is the quietest failure +# this harness can produce. +if grep -qE '^passed:? 0([^0-9]|$)|^passed:? 0,' "$log"; then + echo "::error::the $lang suite passed 0 cases — it asserted nothing." + exit 1 +fi + +exit "$rc" diff --git a/.github/scripts/version-gate.sh b/.github/scripts/version-gate.sh new file mode 100755 index 000000000..9baccdf8e --- /dev/null +++ b/.github/scripts/version-gate.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# A change that reaches a user needs a version they can ask for. +# +# npm does not let a published version be republished. So anything inside the +# tarball — and that includes README.md, which package.json's `files` allowlist +# names explicitly — can only reach anyone through a new version. A pull request +# that improves the README and leaves the version alone is not a small omission: +# the improvement is simply never delivered, and nothing anywhere says so. The +# next person reads the README on the registry, sees the old text, and has no +# way to tell it apart from a README nobody has written yet. +# +# The inverse error is the one worth avoiding here too. Demanding a bump for a +# CI tweak or a test fixture trains people to bump without asking why, and a +# version that moves for reasons users cannot observe stops meaning anything. +# +# So the gate asks one question: COULD THIS CHANGE REACH A USER? It answers it +# from package.json's own `files` declaration rather than a second list kept by +# hand, because a second list is a thing that drifts — which is the defect this +# repository keeps finding in other forms. +# +# NOT the engine packages. `@axiomcode/engine--` is named by ENGINE_ID, +# a sha256 over the Soufflé version and the rule text and nothing else (see +# graph/pipeline/run-souffle.sh). A README cannot move it, and a rule change +# moves it whether or not anyone bumps anything. That gate is already correct +# and this script must not second-guess it. +# +# Usage: version-gate.sh e.g. version-gate.sh origin/main +# VERSION_GATE=off skips the check (for a branch that is not yet publishing) +# ───────────────────────────────────────────────────────────────────────────── +set -uo pipefail + +[ "${VERSION_GATE:-on}" = off ] && { echo "version gate: off"; exit 0; } + +base="${1:?usage: version-gate.sh }" +root="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$root" || exit 1 + +git rev-parse --verify --quiet "$base" >/dev/null || { + echo "::error::version-gate: no such ref: $base"; exit 1; } + +head_version="$(node -p "require('./package.json').version" 2>/dev/null)" +base_version="$(git show "$base:package.json" 2>/dev/null | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version" 2>/dev/null)" + +[ -n "$head_version" ] && [ -n "$base_version" ] || { + echo "::error::version-gate: could not read the version on both sides"; exit 1; } + +# Every manifest that repeats the version has to agree with package.json, bump +# or no bump — a plugin manifest left behind reports a version nobody can install. +node .github/scripts/version.mjs check || exit 1 + +# A bump was made. It has to move forward, and it cannot name a version that was +# already released: npm refuses the publish, and the tag would point at two trees. +if [ "$head_version" != "$base_version" ]; then + if ! node -e ' + const p = (v) => { const [core, pre] = v.split("-"); return [...core.split(".").map(Number), pre]; }; + const [a, b] = process.argv.slice(1).map(p); + for (let i = 0; i < 3; i++) if (a[i] !== b[i]) process.exit(b[i] > a[i] ? 0 : 1); + // same x.y.z: a release outranks its prereleases, prereleases compare as strings + if (a[3] === undefined) process.exit(1); + if (b[3] === undefined) process.exit(0); + process.exit(b[3] > a[3] ? 0 : 1); + ' "$base_version" "$head_version"; then + echo "::error::version-gate: $head_version does not come after $base_version" + exit 1 + fi + if git rev-parse --verify --quiet "refs/tags/v$head_version" >/dev/null \ + || git ls-remote --exit-code --tags origin "refs/tags/v$head_version" >/dev/null 2>&1; then + echo "::error::version-gate: v$head_version is already tagged — it has been released. Bump again." + exit 1 + fi + echo "version: $base_version -> $head_version" + exit 0 +fi + +# What cannot reach a user: the negations package.json already declares, read +# from the file so the two cannot disagree, plus CI configuration, which npm +# never packs and which no `files` entry would ever name. +# (a while-read loop, not mapfile: bash 3.2 is still what a macOS laptop runs) +excluded=() +while IFS= read -r e; do + [ -n "$e" ] && excluded+=("$e") +done < <(node -e ' + const f = require("./package.json").files || []; + for (const e of f) if (e.startsWith("!")) console.log(e.slice(1)); +') +excluded+=(".github/") + +reaches_a_user() { + local path="$1" + case "$path" in */__pycache__/*) return 1;; esac + for e in "${excluded[@]}"; do + case "$e" in + */) [ "${path##"$e"}" != "$path" ] && return 1 ;; + *) [ "$path" = "$e" ] && return 1 ;; + esac + done + return 0 +} + +# The base's version has not been released yet: nothing has shipped under it, so +# this change simply joins that release. The gate starts to bite at the tag. +if ! git rev-parse --verify --quiet "refs/tags/v$base_version" >/dev/null \ + && ! git ls-remote --exit-code --tags origin "refs/tags/v$base_version" >/dev/null 2>&1; then + echo "version $base_version is not released yet (no tag v$base_version); this change ships in it" + exit 0 +fi + +shipped=() +while IFS= read -r p; do + [ -n "$p" ] || continue + reaches_a_user "$p" && shipped+=("$p") +done < <(git diff --name-only "$base"...HEAD) + +if [ ${#shipped[@]} -eq 0 ]; then + echo "version $head_version unchanged; nothing in this change reaches a published artefact" + exit 0 +fi + +echo "::error::version-gate: package.json is still $head_version, but ${#shipped[@]} changed file(s) reach a user" +printf ' %s\n' "${shipped[@]:0:20}" +[ ${#shipped[@]} -gt 20 ] && echo " … and $(( ${#shipped[@]} - 20 )) more" +cat <<'WHY' + +npm will not republish a version, so none of the above can be delivered under +0.x.y once 0.x.y is out. A docs-only change is still a delivery: README.md is in +the `files` allowlist and `description` is the registry page, so both reach users +and both want a patch bump — nothing larger. + +If this change genuinely reaches nobody, it belongs under one of the paths +package.json already excludes, and the gate will say so on its own. +WHY +exit 1 diff --git a/.github/scripts/version.mjs b/.github/scripts/version.mjs new file mode 100755 index 000000000..7d5cbc2a3 --- /dev/null +++ b/.github/scripts/version.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +// ───────────────────────────────────────────────────────────────────────────── +// One version, every manifest that carries it. +// +// The release version is package.json's `version`. It is repeated in the engine +// pins (optionalDependencies — each engine package is published under the same +// version as this one), the vendored parser, and every agent plugin manifest a +// marketplace reads. A release where they disagree ships a plugin that reports a +// version nobody can install, or an install that pins engines that were never +// published. +// +// node .github/scripts/version.mjs check every manifest agrees +// node .github/scripts/version.mjs check v0.2.0 ...and agrees with this tag +// node .github/scripts/version.mjs set 0.2.0 write it everywhere +// node .github/scripts/version.mjs get print it +// ───────────────────────────────────────────────────────────────────────────── +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +// [file, JSON path to the version, ...] — optionalDependencies are listed by +// reading the file, so a fifth platform is covered without editing this list. +const MANIFESTS = [ + 'package.json', + 'parser/package.json', + 'gemini-extension.json', + 'plugins/axiomcode/.claude-plugin/plugin.json', + 'plugins/axiomcode/.codex-plugin/plugin.json', + 'plugins/axiomcode/.cursor-plugin/plugin.json', +]; +const ENGINE_PREFIX = '@axiomcode/engine-'; +const SEMVER = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/; + +const read = (f) => JSON.parse(readFileSync(join(root, f), 'utf8')); + +function locations() { + const out = []; + for (const f of MANIFESTS) { + const j = read(f); + out.push({ file: f, key: 'version', value: j.version }); + if (f === 'package.json') { + for (const [name, v] of Object.entries(j.optionalDependencies || {})) { + if (name.startsWith(ENGINE_PREFIX)) out.push({ file: f, key: `optionalDependencies.${name}`, value: v }); + } + } + } + return out; +} + +const [cmd, arg] = process.argv.slice(2); + +if (cmd === 'get') { + console.log(read('package.json').version); +} else if (cmd === 'check') { + const want = arg ? arg.replace(/^v/, '') : read('package.json').version; + const locs = locations(); + const bad = locs.filter((l) => l.value !== want); + if (!SEMVER.test(want)) bad.unshift({ file: arg ? 'tag' : 'package.json', key: 'version', value: `${want} (not semver)` }); + if (bad.length) { + console.log(`::error::version ${want} is not what every manifest says`); + for (const b of bad) console.log(` ${b.file} ${b.key} = ${b.value}`); + console.log('fix with: node .github/scripts/version.mjs set '); + process.exit(1); + } + console.log(`version ${want}: ${locs.length} locations agree`); +} else if (cmd === 'set') { + if (!arg || !SEMVER.test(arg)) { console.error('usage: version.mjs set '); process.exit(2); } + // Rewrites the strings in place rather than re-serialising, so a manifest keeps + // its own formatting and the diff is exactly the version lines. + for (const f of MANIFESTS) { + let text = readFileSync(join(root, f), 'utf8'); + text = text.replace(/^(\s*"version"\s*:\s*")[^"]*(")/m, `$1${arg}$2`); + text = text.replace(/("@axiomcode\/engine-[^"]+"\s*:\s*")[^"]*(")/g, `$1${arg}$2`); + writeFileSync(join(root, f), text); + } + const bad = locations().filter((l) => l.value !== arg); + if (bad.length) { + for (const b of bad) console.error(` not updated: ${b.file} ${b.key} = ${b.value}`); + process.exit(1); + } + console.log(`version set to ${arg}: ${locations().length} locations`); +} else { + console.error('usage: version.mjs check [tag] | set | get'); + process.exit(2); +} diff --git a/.github/workflows/build-engines.yml b/.github/workflows/build-engines.yml new file mode 100644 index 000000000..d182aadef --- /dev/null +++ b/.github/workflows/build-engines.yml @@ -0,0 +1,197 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Build the engine binaries — every language, every platform — as artifacts. +# +# Reusable (workflow_call) so publish-npm.yml can build then publish in one run, and +# dispatchable on its own to check that the rules still compile everywhere without +# publishing anything. Soufflé is a BUILD-time dependency only: `souffle -g` turns each +# language's rules into portable C++ once on Ubuntu (the pinned .deb), and every platform +# compiles that C++ with its own C++17 compiler against Soufflé's headers. The result is +# one self-contained executable per language per platform, linked against nothing but the +# C++ runtime. Same flags as the local compile (no OpenMP/zlib/sqlite), portable targets. +# +# Artifacts: engines-/ holding /axiomcode-engine-[.exe] + /ENGINE_ID +# Platforms are named the npm way (process.platform-process.arch): darwin-arm64, linux-x64, +# linux-arm64, win32-x64, each on a standard GitHub-hosted runner (free for a public +# repository; darwin-arm64 is the Apple Silicon macos-15 image). +# ───────────────────────────────────────────────────────────────────────────── +name: build-engines + +on: + workflow_call: + inputs: + # fresh: compile every engine, restore nothing. The nightly and a release use it. + fresh: + type: boolean + default: false + workflow_dispatch: + inputs: + fresh: + type: boolean + default: false + +# COMPILED ENGINES ARE CACHED BY ENGINE_ID. Compiling one language's C++ at -O3 takes +# about three minutes, and a run that changed no rules used to recompile all five on +# every platform. Each platform now restores its last engines and recompiles only the +# languages whose ENGINE_ID — a hash of that language's rules and the Soufflé version — +# differs from the one the restored binary records. A binary is therefore reused only +# for exactly the rules it was built from; the smoke step still runs every one. + +env: + LANGUAGES: java typescript python javascript csharp + # Same pin and checksum as ci.yml: the tool that produces the published binaries is + # verified against what upstream published for that release. + SOUFFLE_SHA512: '6b86e554f6aa5abf8a8b55d8312ae37c0957c5bd6c9edeea89246db9406f645ec5e600b84fe6636b1c163da556f0da6c3d2dad46c1083413f2fcf4f95b9ac62c' + +jobs: + generate: + runs-on: ubuntu-24.04 + outputs: + key: ${{ steps.gen.outputs.key }} + flags: ${{ steps.gen.outputs.flags }} + steps: + - uses: actions/checkout@v4 + - name: Install the pinned Soufflé + run: | + . graph/pipeline/engine.conf + deb="x86_64-ubuntu-2404-souffle-${SOUFFLE_VERSION}-Linux.deb" + curl -fsSL --retry 3 -o "/tmp/$deb" "https://github.com/souffle-lang/souffle/releases/download/${SOUFFLE_VERSION}/$deb" + echo "${SOUFFLE_SHA512} /tmp/$deb" | sha512sum -c - + sudo apt-get update -qq && sudo apt-get install -y -qq "/tmp/$deb" + souffle --version | head -2 + test -f /usr/include/souffle/CompiledSouffle.h + - name: Generate portable C++ per language + id: gen + run: | + set -e + mkdir -p gen + for lang in $LANGUAGES; do + id="$(bash graph/pipeline/run-souffle.sh --language "$lang" --print-engine-id)" + echo "$lang: $id"; printf '%s' "$id" > "gen/$lang.id" + bash graph/pipeline/run-souffle.sh --language "$lang" --emit-program "gen/$lang.dl" + souffle -I graph -g "gen/$lang.cpp" "gen/$lang.dl" 2> "gen/$lang.gen.log" || { cat "gen/$lang.gen.log"; exit 1; } + awk '/No rules\/facts defined/{skip=2;next} skip>0{skip--;next} {print}' "gen/$lang.gen.log" + done + cp -r /usr/include/souffle gen/souffle + # one key for the whole set; a partial match restores the previous set + echo "key=$(cat gen/*.id | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT" + # The compile flags live in THIS file and ENGINE_ID does not cover them, so its + # hash prefixes the key AND the restore prefix: a flag change restores nothing. + # Computed here because the build jobs never check the repository out. + echo "flags=$(sha256sum .github/workflows/build-engines.yml | cut -c1-12)" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@v4 + with: { name: generated, path: gen, retention-days: 3, if-no-files-found: error } + + build: + needs: generate + strategy: + fail-fast: false + matrix: + target: + - { os: ubuntu-24.04, platform: linux-x64 } + - { os: ubuntu-24.04-arm, platform: linux-arm64 } + - { os: windows-2025, platform: win32-x64 } + runs-on: ${{ matrix.target.os }} + steps: + - uses: actions/download-artifact@v4 + with: { name: generated, path: gen } + - name: restore the engines built from these rules last time + id: restore + if: ${{ !inputs.fresh }} + uses: actions/cache/restore@v4 + with: + path: engines + key: engines-${{ matrix.target.platform }}-${{ needs.generate.outputs.flags }}-${{ needs.generate.outputs.key }} + restore-keys: engines-${{ matrix.target.platform }}-${{ needs.generate.outputs.flags }}- + - name: Compile every language (Linux) + if: startsWith(matrix.target.platform, 'linux') + run: | + set -e + for lang in $LANGUAGES; do + if cmp -s "gen/$lang.id" "engines/$lang/ENGINE_ID"; then echo "$lang: cached, rules unchanged"; continue; fi + mkdir -p "engines/$lang" + c++ -std=c++17 -O3 -w -static-libstdc++ -static-libgcc -I gen "gen/$lang.cpp" -o "engines/$lang/axiomcode-engine-$lang" + cp "gen/$lang.id" "engines/$lang/ENGINE_ID" + done + ls -la engines/*; ldd engines/java/axiomcode-engine-java || true + - uses: ilammy/msvc-dev-cmd@v1 + if: startsWith(matrix.target.platform, 'win32') + with: { arch: x64 } + - name: Compile every language (Windows, MSVC) + if: startsWith(matrix.target.platform, 'win32') + shell: cmd + run: | + for %%L in (java typescript python javascript csharp) do ( + fc /b gen\%%L.id engines\%%L\ENGINE_ID >nul 2>&1 + if errorlevel 1 ( + if not exist engines\%%L mkdir engines\%%L + cl /nologo /std:c++17 /O2 /EHsc /bigobj /w /permissive- /Zc:__cplusplus /D_CRT_SECURE_NO_WARNINGS /DNOMINMAX /DUSE_CUSTOM_GETOPTLONG /I gen gen\%%L.cpp /Fe:engines\%%L\axiomcode-engine-%%L.exe + if errorlevel 1 exit /b 1 + copy /y gen\%%L.id engines\%%L\ENGINE_ID + ) else ( + echo %%L: cached, rules unchanged + ) + ) + dir /s engines + - name: Smoke — every binary starts on empty inputs + shell: bash + run: | + set -e + for lang in $LANGUAGES; do + bin="$(ls engines/$lang/axiomcode-engine-$lang* )"; chmod +x "$bin" 2>/dev/null || true + mkdir -p "facts-$lang" "out-$lang" + sed -n 's/^\.input \([A-Za-z0-9_]*\)(.*/\1/p' "gen/$lang.dl" | while read -r r; do : > "facts-$lang/$r.facts"; done + "./$bin" -F "facts-$lang" -D "out-$lang" + echo "$lang: ok ($(ls out-$lang | wc -l) relations written)" + done + - name: save the engines for the next run + if: ${{ !inputs.fresh && steps.restore.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + 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 + + build-macos: + needs: generate + runs-on: macos-15 + steps: + - uses: actions/download-artifact@v4 + with: { name: generated, path: gen } + - name: restore the engines built from these rules last time + id: restore + if: ${{ !inputs.fresh }} + uses: actions/cache/restore@v4 + with: + path: engines + key: engines-darwin-arm64-${{ needs.generate.outputs.flags }}-${{ needs.generate.outputs.key }} + restore-keys: engines-darwin-arm64-${{ needs.generate.outputs.flags }}- + - name: Compile every language (macOS arm64) + run: | + set -e + for lang in $LANGUAGES; do + if cmp -s "gen/$lang.id" "engines/$lang/ENGINE_ID"; then echo "$lang: cached, rules unchanged"; continue; fi + mkdir -p "engines/$lang" + c++ -std=c++17 -O3 -w -arch arm64 -mmacosx-version-min=12.0 -I gen "gen/$lang.cpp" -o "engines/$lang/axiomcode-engine-$lang" + cp "gen/$lang.id" "engines/$lang/ENGINE_ID" + done + otool -L engines/java/axiomcode-engine-java + - name: Smoke — every binary starts on empty inputs + run: | + set -e + for lang in $LANGUAGES; do + mkdir -p "facts-$lang" "out-$lang" + sed -n 's/^\.input \([A-Za-z0-9_]*\)(.*/\1/p' "gen/$lang.dl" | while read -r r; do : > "facts-$lang/$r.facts"; done + "./engines/$lang/axiomcode-engine-$lang" -F "facts-$lang" -D "out-$lang" + done + - name: save the engines for the next run + if: ${{ !inputs.fresh && steps.restore.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: engines + key: engines-darwin-arm64-${{ needs.generate.outputs.flags }}-${{ needs.generate.outputs.key }} + - uses: actions/upload-artifact@v4 + with: { name: engines-darwin-arm64, path: engines, if-no-files-found: error } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..138a3feed --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,434 @@ +# ───────────────────────────────────────────────────────────────────────────── +# CI — the gate every change to main passes through. +# +# Four tiers, cheapest first, all required: +# build the parser and the engine driver compile and typecheck +# hygiene repo invariants that need no solver and catch silent breakage +# engine the java / typescript / python / javascript / csharp regression suites, in +# parallel, each compiling its own engine from the rules +# engines every language's engine builds on every platform (the reusable +# build-engines workflow — the same artifacts publish-npm ships) +# +# NO WORKFLOW-LEVEL PATH FILTERS, deliberately. A required check that is skipped +# by a path filter never reports, and a pull request waiting on a check that will +# never report can never merge. Instead the workflow always starts, the `changes` +# job classifies the diff, and the expensive jobs skip THEMSELVES: a docs-only pull +# request runs build and hygiene (which carries the version gate) and nothing else, +# and the platform engine build runs only for main, and only when what it compiles +# changed. The `CI` job reports either way. +# +# dev is the default branch: every pull request lands there and gets build, hygiene +# and the five suites. The four-platform engine build is the slow part and no test +# uses its output, so it runs on the way INTO main (a promotion pull request, a push +# to main) and in the nightly, not on every change to dev. +# ───────────────────────────────────────────────────────────────────────────── +name: CI + +on: + push: + branches: [main, dev] # dev takes direct pushes, so they get a result too + pull_request: + merge_group: + workflow_dispatch: + # nightly.yml calls this with fresh=true: no restored engine cache, so every engine + # is compiled from the rules as they are, and the platform build always runs. + workflow_call: + inputs: + fresh: + type: boolean + default: false + +# One run per ref. A new push to a pull request cancels the previous run, but a +# run on main is always allowed to finish — main's history is the record. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + # bin/axiomcode and the parser need Node ≥ 22.5. + NODE_VERSION: '22' + SOUFFLE_VERSION: '2.5' + SOUFFLE_SHA512: '6b86e554f6aa5abf8a8b55d8312ae37c0957c5bd6c9edeea89246db9406f645ec5e600b84fe6636b1c163da556f0da6c3d2dad46c1083413f2fcf4f95b9ac62c' + +jobs: + changes: + name: what changed + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + code: ${{ steps.c.outputs.code }} + engines: ${{ steps.c.outputs.engines }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: c + env: + BASE: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + if [ "${{ inputs.fresh }}" = true ]; then + echo "code=true" >> "$GITHUB_OUTPUT"; echo "engines=true" >> "$GITHUB_OUTPUT" + echo "nightly: everything runs"; exit 0 + fi + if [ "${{ github.event_name }}" = push ] && [ "${{ github.ref }}" = refs/heads/dev ]; then + echo "code=true" >> "$GITHUB_OUTPUT"; echo "engines=false" >> "$GITHUB_OUTPUT" + echo "push to dev: suites run, platform engines wait for main"; 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 + fi + files="$(git diff --name-only "$BASE"...HEAD)" + printf '%s\n' "$files" | sed 's/^/ /' + # DOCS: prose nothing executes. Markdown under graph/ or parser/ is NOT + # docs: graph/bundle/SCHEMA.md is generated, and a suite checks it is current. + code="$(printf '%s\n' "$files" | grep -vE \ + -e '^$' \ + -e '^(graph|parser)/' -e '^[^/]+\.md$' -e '^docs/' -e '^paper/' \ + -e '^\.github/(ISSUE_TEMPLATE/|pull_request_template\.md$|CODEOWNERS$|RELEASING\.md$)' \ + -e '^LICENSE' -e '\.(png|jpe?g|gif|svg)$' || true)" + # graph/ and parser/ are code even when the file is markdown + code="$code$(printf '%s\n' "$files" | grep -E '^(graph|parser)/' || true)" + # ENGINES: what the platform build compiles or packages. + engines="$(printf '%s\n' "$files" | grep -E \ + -e '\.dl$' -e '^graph/pipeline/' -e '^packaging/' \ + -e '^\.github/workflows/build-engines\.yml$' -e '^package\.json$' || true)" + [ -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="" + [ -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)" + + build: + name: build & typecheck + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + # No lock file is committed (#476), so this is `npm install`, not `npm ci`, + # and setup-node's npm cache (keyed on a lock file) is not used. The install + # runs the `prepare` script, which builds the parser workspace and the + # driver. Keeping the explicit build step anyway means a prepare-script + # change cannot silently stop compiling this repo. + - run: npm install + - run: npm run typecheck + - run: npm run build + - name: the parser actually built + run: | + test -f parser/dist/index.js \ + || { echo "::error::parser/dist/index.js is missing after build"; exit 1; } + + hygiene: + name: repo invariants + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # the version gate needs the merge base with the target branch + + # A fixture input that .gitignore matches passes on the machine that wrote + # it and fails on every clone. The suites run this too; running it here as + # well means the answer arrives in seconds rather than after the engine. + - name: every fixture input is tracked by git + run: bash graph/test/tools/no-ignored-fixtures.sh + + # A relation staged for the client but not for libraries is EMPTY on every + # run and nothing errors — no golden can see it. This is the only check + # that can. + - name: IR staging maps are consistent + run: | + fail=0 + for lang in java typescript python javascript csharp; do + echo "── $lang" + python3 graph/test/tools/check_staging.py --lang "$lang" || fail=1 + done + exit $fail + + # The engine's base declarations are a COPY of the parser's generated + # schema (graph//souffle/decls_base.dl ← parser/src/schema//). + # A column appended on the parser side and not here is an arity error at + # solve time in every suite at once, with the cause two directories away. + # Compared on the `.decl` lines only: the copies carry their own preambles. + - name: engine declarations match the parser schema + run: | + fail=0 + for pair in typescript:decls_base_ts.dl python:decls_base_py.dl javascript:decls_base_js.dl csharp:decls_base_cs.dl; do + lang="${pair%%:*}"; file="${pair#*:}" + if ! diff <(grep '^\.decl' "parser/src/schema/$lang/$file") \ + <(grep '^\.decl' "graph/$lang/souffle/decls_base.dl"); then + echo "::error::graph/$lang/souffle/decls_base.dl has drifted from parser/src/schema/$lang/$file" + fail=1 + fi + done + exit $fail + + # The client->library half is carried by a smaller set of fixtures than the + # client->client half, and it is the half that disappears silently: delete a + # golden and the case still runs, still passes, and simply stops claiming + # anything. A suite can only check the assertions it still has. + - name: client->library coverage has not shrunk + run: bash graph/test/tools/lib-coverage.sh + + - name: shell scripts parse + run: | + fail=0 + while IFS= read -r f; do + bash -n "$f" || { echo "::error file=$f::does not parse"; fail=1; } + done < <(git ls-files '*.sh') + exit $fail + + # npm will not republish a version, so anything inside the tarball reaches + # nobody unless the version moves — README.md included, since `files` names + # it. The inverse is the error worth avoiding too: a bump demanded for a CI + # tweak or a test fixture teaches people to bump without asking why, and a + # version that moves for reasons users cannot observe stops meaning + # anything. The gate reads package.json's own `files` declaration to tell + # the two apart, and prints the files that decided it. + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + # package.json's version is repeated in the engine pins, the parser and every + # plugin manifest. Checked on every event, not only pull requests, so main + # can never hold a tree whose manifests disagree about what it is. + - name: every manifest carries the same version + run: node .github/scripts/version.mjs check + + - name: a change that reaches a user has a version + if: github.event_name == 'pull_request' + run: bash .github/scripts/version-gate.sh "origin/${{ github.base_ref }}" + + engine: + name: engine (${{ matrix.lang }}) + needs: [changes] + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + # --oracle scores the engine against GROUND TRUTH, not only against the + # goldens. A golden says "the same as last time", which a wrong answer + # satisfies perfectly well as long as it was wrong last time too. + # + # The ground truth is built with the toolchain that defines the language: + # javac and javap for Java, the TypeScript compiler for TypeScript and — + # over allowJs/checkJs — for JavaScript. No third-party analyzer, and no + # third-party library is downloaded to do it. A case whose ground truth would need an external + # classpath reports itself unscored rather than pulling one in. + # + # --no-torture for java ONLY. Those families call java.util.List, Map and + # the functional interfaces, so they need the JVM platform IR staged as a + # library. That IR is 1.8 GB and is built from a JDK source checkout, so it + # cannot live in a repository or a cache. Without it those receivers are + # unresolvable BY CONSTRUCTION — the census goes from 10 missing edges to + # 23 and recall to 0.847 — which measures the staging, not the rules, and + # the resulting red would read as a regression in whatever PR met it. + # + # Java client->library resolution is still covered here: six cases ship + # their own stub library in lib-src/ and are solved with it as --library. + # The torture families remain a local gate until the platform IR can be + # produced reproducibly; the suite prints EXCLUDED so it is never mistaken + # for a family that passed. + - lang: java + oracle: '--oracle --no-torture' + - lang: typescript + oracle: '--oracle' + # Python's ground truth is frozen CPython output, authored by a separate + # harness checkout ($AXIOM_PY_ORACLE) that CI cannot reach yet, so this leg + # is goldens-only for now. That separation is deliberate — + # graph/test/python/run-tests.sh explains why the ability to re-bless + # ground truth must not sit beside the code under test — but it does mean + # the python leg is a weaker check than the other three until the harness + # is reachable from here. + - lang: python + oracle: '' + # JavaScript: 19 cases; the library case ships its dependency under + # src/node_modules and is solved twice. The execution oracles (torture/, + # realapp/) are separate harnesses and stay a local gate — realapp needs + # network for its own npm install. + - lang: javascript + oracle: '--oracle' + # C# has no goldens: every case is scored against the Roslyn oracle + # (graph/test/csharp/ground-truth), which the step below builds with the + # .NET 8 SDK. No flag — scoring against the compiler is all it does. + - lang: csharp + oracle: '' + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + # Builds the in-repo parser (parser/dist) through the prepare script, and + # supplies the TypeScript compiler the typescript and javascript oracles run. + # `npm install`, not `npm ci`: no lock file is committed (#476). + - run: npm install + + - name: the parser actually built + run: | + test -f parser/dist/index.js \ + || { echo "::error::parser/dist/index.js is missing after npm install"; exit 1; } + + # TWO interpreters, because the python suite needs two different things and + # they cannot be the same version. + # + # 3.10 — the tier-1 attribution preflight reads CPython OPCODES, whose + # shapes are not stable across minor versions. It resolves + # `python3.10` by name, so this only has to exist on PATH. + # 3.12 — the torture fixtures are SOURCE that has to import: one of them + # uses `typing.Self`, which is 3.11+ (PEP 673), so on 3.10 the + # tracer dies at import and the family scores nothing. + # + # The later setup-python wins for plain `python3`, so 3.12 must come second. + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: both interpreters are on PATH + run: | + set -euo pipefail + echo "python3 -> $(python3 --version)" + echo "python3.10 -> $(python3.10 --version)" + + # JDK 24, not the runner's default. The java torture harness reads class files + # with java.lang.classfile, which is not final before 24 — on an older JDK it + # exits 77 and the whole ten-family oracle silently does not run. + - uses: actions/setup-java@v4 + if: matrix.lang == 'java' + with: + distribution: temurin + java-version: '24' + + - uses: actions/setup-dotnet@v4 + if: matrix.lang == 'csharp' + with: + dotnet-version: '8.0.x' + + # Without the oracle binary the suite exits 77, which run-suite.sh turns into + # a failure — so a missing build is loud, never a silent skip. + - name: build the Roslyn oracle + if: matrix.lang == 'csharp' + run: dotnet build -c Release graph/test/csharp/ground-truth/AxiomCsOracle + + - name: cache the Soufflé package + uses: actions/cache@v4 + with: + path: ~/souffle-pkg + key: souffle-deb-${{ env.SOUFFLE_VERSION }}-ubuntu-2404 + + # Soufflé is the solver, not a library under test: the engine is compiled + # from .dl to C++ and linked against Soufflé's headers, so a build of it has + # to be present the way a compiler has to be present. + # + # It is pinned to an exact version AND verified against the checksum upstream + # published for that release, so what CI links against is decided in this + # file rather than by whatever the archive happens to serve today. The pin + # the driver reads is graph/pipeline/engine.conf; this must agree with it. + - name: install Soufflé ${{ env.SOUFFLE_VERSION }} + run: | + set -euo pipefail + deb="x86_64-ubuntu-2404-souffle-${SOUFFLE_VERSION}-Linux.deb" + dir="$HOME/souffle-pkg"; mkdir -p "$dir" + if [ ! -f "$dir/$deb" ]; then + curl -fsSL --retry 3 -o "$dir/$deb" \ + "https://github.com/souffle-lang/souffle/releases/download/${SOUFFLE_VERSION}/${deb}" + fi + echo "${SOUFFLE_SHA512} ${dir}/${deb}" | sha512sum -c - + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends "$dir/$deb" + souffle --version | head -2 + + # ── the compiled engine ──────────────────────────────────────────────── + # run-souffle.sh caches the compiled solver under a content hash of the + # .dl program text. Mirroring that key here skips a multi-minute C++ build + # on every run whose rules did not change. + # Keyed by this language's ENGINE_ID — the hash the driver itself names its + # compiled binary by (its rules and the Soufflé version) — so a rule change in + # one language recompiles that language only, and an unchanged one never does. + # Two things decide the binary that ENGINE_ID does not cover, so they are in the + # key too: the driver that compiles it (run-souffle.sh holds the compiler flags), + # and the runner's CPU, because the driver compiles with -march=native and a + # binary built on one CPU model can die with an illegal instruction on another. + - name: this language's engine id + id: eid + run: | + echo "id=$(bash graph/pipeline/run-souffle.sh --language ${{ matrix.lang }} --print-engine-id)" >> "$GITHUB_OUTPUT" + echo "cpu=$(grep -m1 'model name' /proc/cpuinfo | sha256sum | cut -c1-12)" >> "$GITHUB_OUTPUT" + + - name: cache the compiled Soufflé engine + if: ${{ !inputs.fresh }} + uses: actions/cache@v4 + with: + path: .souffle-cache + key: souffle-engine-${{ matrix.lang }}-${{ steps.eid.outputs.id }}-${{ hashFiles('graph/pipeline/run-souffle.sh') }}-${{ steps.eid.outputs.cpu }} + + - name: ${{ matrix.lang }} regression suite + env: + AXIOM_PARSER: ${{ github.workspace }}/parser/dist/index.js + # The driver's default cache is ~/.cache/axiomcode/souffle; point it at the + # directory the cache step above saves and restores, or it never hits. + 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. + engines: + name: engines build on every platform + needs: [build, changes] + if: needs.changes.outputs.engines == 'true' + uses: ./.github/workflows/build-engines.yml + with: + fresh: ${{ inputs.fresh == true }} + + # A single job the branch ruleset can require. Without it, every new matrix + # entry has to be added to the protection rules by hand, and a matrix job that + # fails to start reports nothing at all — which a ruleset reads as "not + # failing" rather than as "did not run". + ci: + name: CI + runs-on: ubuntu-24.04 + needs: [changes, build, hygiene, engine, engines] + if: always() + steps: + - name: every required job succeeded + run: | + # The expression quotes its separator with SINGLE quotes because that is + # the only string delimiter a GitHub expression has. A double quote there + # is a lex error that invalidates the entire workflow file, and the run + # then fails in zero seconds with no job having started. + # changes, build and hygiene always run and must succeed. engine and + # engines may be SKIPPED, but only when `changes` said so; any other + # skip (a job that never started) is a failure. + always="${{ needs.changes.result }} ${{ needs.build.result }} ${{ needs.hygiene.result }}" + echo "always-run jobs: $always" + for r in $always; do + [ "$r" = "success" ] || { echo "::error::a required job reported '$r'"; exit 1; } + done + check() { # name result expected-to-run + if [ "$3" = true ]; then + [ "$2" = success ] || { echo "::error::$1 reported '$2'"; exit 1; } + else + [ "$2" = skipped ] || { echo "::error::$1 reported '$2' though nothing it tests changed"; exit 1; } + fi + echo "$1: $2" + } + check "engine suites" "${{ needs.engine.result }}" "${{ needs.changes.outputs.code }}" + check "platform engines" "${{ needs.engines.result }}" "${{ needs.changes.outputs.engines }}" + echo "all required jobs passed" diff --git a/.github/workflows/main-guard.yml b/.github/workflows/main-guard.yml new file mode 100644 index 000000000..040023dff --- /dev/null +++ b/.github/workflows/main-guard.yml @@ -0,0 +1,87 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Every commit on `main` should have arrived through a pull request. +# +# This reports when one did not. It is a backstop and not the rule itself: a +# workflow runs after the push has already been accepted, so it can record a +# direct push but never refuse one. The rule that refuses lives in +# .github/scripts/protect-main.sh. +# ───────────────────────────────────────────────────────────────────────────── +name: main-guard + +on: + push: + branches: [main] + +permissions: + contents: read + issues: write + +jobs: + direct-push: + name: every commit on main came from a pull request + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: check the pushed commits + id: check + env: + GH_TOKEN: ${{ github.token }} + BEFORE: ${{ github.event.before }} + AFTER: ${{ github.event.after }} + run: | + set -uo pipefail + + # A branch created or force-updated from nothing has no usable range. + if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then + echo "branch created — nothing to compare"; exit 0 + fi + + orphans=() + while IFS= read -r sha; do + [ -n "$sha" ] || continue + n="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${sha}/pulls" --jq 'length' 2>/dev/null || echo 0)" + if [ "$n" = "0" ]; then + orphans+=("$sha $(git log -1 --format=%s "$sha")") + fi + done < <(git rev-list "${BEFORE}..${AFTER}" 2>/dev/null) + + if [ ${#orphans[@]} -eq 0 ]; then + echo "all pushed commits arrived through a pull request" + exit 0 + fi + + { + echo "### Direct push to \`main\` detected" + echo + echo "These commits are on \`main\` without a pull request:" + echo + printf -- '- %s\n' "${orphans[@]}" + } >> "$GITHUB_STEP_SUMMARY" + + printf '%s\n' "${orphans[@]}" > /tmp/orphans.txt + echo "found=1" >> "$GITHUB_OUTPUT" + echo "::error::${#orphans[@]} commit(s) reached main without a pull request" + exit 1 + + - name: record it as an issue + if: failure() && steps.check.outputs.found == '1' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -uo pipefail + title="Direct push to main on $(date -u +%Y-%m-%d)" + # One issue per day, not one per push. + existing="$(gh issue list --state open --search "\"$title\" in:title" --json number --jq '.[0].number' || true)" + run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + body="$(printf "Commits reached \`main\` without a pull request:\n\n\`\`\`\n%s\n\`\`\`\n\nRun: %s\n\nReported after the fact: a workflow runs once the push has been accepted, so it can record a direct push but not refuse one. See .github/scripts/protect-main.sh\n" \ + "$(cat /tmp/orphans.txt)" "$run_url")" + if [ -n "${existing:-}" ]; then + gh issue comment "$existing" --body "$body" + else + gh issue create --title "$title" --body "$body" --label platform || \ + gh issue create --title "$title" --body "$body" + fi diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 000000000..f7120c662 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,200 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Nightly: is dev stable? Build it from scratch, every platform, and install it the +# way a user would. The workflow's badge in the README is dev's status. +# +# A green night is published to npm under the `nightly` dist-tag (never `latest`), so +# `npm i @axiomcode/code-graph@nightly` installs dev as of that night. Pull request +# runs test each change on its own; this is the only +# full check of dev's head as it actually is — after several merges, after direct +# pushes and sync-dev's merges from main, with the four-platform build and the +# install-and-run check that dev's pull requests skip, and against whatever versions +# of the dependencies a fresh install resolves today (no lock file is committed, so +# those move without a commit here). +# +# 1. CI with fresh=true: a clean checkout, no restored engine cache, every engine +# compiled from the rules, the five suites, and all four platforms' engines. +# 2. End to end: pack @axiomcode/code-graph and the linux-x64 engine package exactly +# as publish-npm.yml would, install them into an empty project with no Soufflé, +# and run axiomcode on a test project (.github/scripts/e2e-install.sh). +# 3. `npm publish --dry-run` for every package: the registry's own checks, no upload. +# 4. Green: publish every package as -nightly..g under `nightly`. +# The version is computed here and never committed or tagged. is the +# version after the last release, so a nightly always sorts above what is out +# and below the release it leads to. Skipped when that commit is already the +# nightly, and until a first real release exists: npm makes a package's first +# publish its `latest`, whatever dist-tag it was published under. +# 5. A failure opens an issue (or comments on the open one) naming what broke; the +# next green night closes it. +# +# Runs every night, new commits or not: the status is daily, and a dependency release +# can break a fresh install with nothing committed. Run it by hand from Actions any +# time. Scheduled runs use the default branch — dev — for the workflow and the code. +# ───────────────────────────────────────────────────────────────────────────── +name: nightly + +on: + schedule: + - cron: '0 8 * * *' # 08:00 UTC, after the US workday + workflow_dispatch: + inputs: + publish: + description: publish this run under the nightly dist-tag if it is green + type: boolean + default: false + +concurrency: + group: nightly + cancel-in-progress: false + +permissions: + contents: read + +jobs: + ci: + uses: ./.github/workflows/ci.yml + with: + fresh: true + + e2e: + name: install and run, as a user + needs: ci + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: npm install --no-audit --no-fund + - uses: actions/download-artifact@v4 + with: { pattern: engines-*, path: artifacts } + + - name: every language, installed from the tarballs, no Soufflé + run: | + set -euo pipefail + for pair in java:java typescript:typescript python:python javascript:javascript; do + lang="${pair%%:*}" + case_dir="$(ls -d graph/test/$lang/cases/*/src | head -1)" + bash .github/scripts/e2e-install.sh artifacts/engines-linux-x64 linux-x64 "$lang" "$case_dir" + done + + - name: npm publish --dry-run, every package + run: | + set -euo pipefail + version="$(node .github/scripts/version.mjs get)" + for d in artifacts/engines-*; do + platform="${d#artifacts/engines-}" + bash packaging/assemble-engine-package.sh "$platform" "$version" "$d" "packages/engine-$platform" >/dev/null + ( cd "packages/engine-$platform" && npm publish --dry-run --access public 2>&1 | tail -3 ) + done + npm publish --dry-run --ignore-scripts --access public 2>&1 | tail -3 + + publish: + name: publish under nightly + needs: [ci, e2e] + if: github.event_name == 'schedule' || inputs.publish + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + + - name: decide the nightly version + id: v + run: | + set -euo pipefail + sha="g$(git rev-parse --short=7 HEAD)" + latest="$(npm view @axiomcode/code-graph dist-tags.latest 2>/dev/null || true)" + if [ -z "$latest" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "no release on npm yet: a first publish would become 'latest', so no nightly until one exists" + exit 0 + fi + current="$(npm view @axiomcode/code-graph dist-tags.nightly 2>/dev/null || true)" + if [ "${current##*.}" = "$sha" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT"; echo "$current is already this commit"; exit 0 + fi + base="$(node .github/scripts/version.mjs get)" + # a released version moves on to the next patch; an unreleased one is previewed as itself + if git rev-parse --verify --quiet "refs/tags/v$base" >/dev/null || [ "$base" = "$latest" ]; then + base="$(node -p 'const [a,b,c]=process.argv[1].split("-")[0].split(".").map(Number); `${a}.${b}.${c+1}`' "$base")" + fi + version="$base-nightly.$(date -u +%Y%m%d).$sha" + echo "version=$version" >> "$GITHUB_OUTPUT"; echo "skip=false" >> "$GITHUB_OUTPUT" + echo "nightly version: $version (latest release: $latest)" + + - if: steps.v.outputs.skip == 'false' + run: | + node .github/scripts/version.mjs set "${{ steps.v.outputs.version }}" + npm install --no-audit --no-fund + + - if: steps.v.outputs.skip == 'false' + uses: actions/download-artifact@v4 + with: { pattern: engines-*, path: artifacts } + + # Engines first: the root package pins them at its own version. + - name: publish every package under nightly + if: steps.v.outputs.skip == 'false' + env: + NODE_AUTH_TOKEN: ${{ secrets.CLI_BINARY_PUBLISH }} + VERSION: ${{ steps.v.outputs.version }} + run: | + set -euo pipefail + for d in artifacts/engines-*; do + platform="${d#artifacts/engines-}" + bash packaging/assemble-engine-package.sh "$platform" "$VERSION" "$d" "packages/engine-$platform" >/dev/null + ( cd "packages/engine-$platform" && npm publish --access public --tag nightly ) + done + npm publish --ignore-scripts --access public --tag nightly + echo "::notice::published $VERSION under nightly — npm i @axiomcode/code-graph@nightly" + + report: + name: report + needs: [ci, e2e, publish] + if: always() + runs-on: ubuntu-24.04 + permissions: + issues: write + steps: + - env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + CI_RESULT: ${{ needs.ci.result }} + E2E_RESULT: ${{ needs.e2e.result }} + PUBLISH_RESULT: ${{ needs.publish.result }} + run: | + set -uo pipefail + title="nightly build of dev is failing" + run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + existing="$(gh issue list --state open --search "\"$title\" in:title" --json number --jq '.[0].number' || true)" + if [ "$CI_RESULT" = success ] && [ "$E2E_RESULT" = success ] && [ "$PUBLISH_RESULT" != failure ]; then + echo "nightly green" + if [ -n "$existing" ]; then + gh issue close "$existing" --comment "Green again at ${GITHUB_SHA::8}: $run_url" + fi + exit 0 + fi + { + echo "Nightly build of \`${GITHUB_REF_NAME}\` at ${GITHUB_SHA::8} failed." + echo + echo "| stage | result |" + echo "|---|---|" + echo "| build, tests, every platform's engines (fresh) | $CI_RESULT |" + echo "| install from tarballs and run; npm publish dry-run | $E2E_RESULT |" + echo "| publish under nightly | $PUBLISH_RESULT |" + echo + echo "Run: $run_url" + echo + echo "A nightly is published only when every stage passes. This closes itself on the next green night." + } > /tmp/body.md + if [ -n "$existing" ]; then + gh issue comment "$existing" --body-file /tmp/body.md + else + gh issue create --title "$title" --body-file /tmp/body.md + fi + exit 1 diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 000000000..8651517ad --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -0,0 +1,160 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Publish a release to npm: the engine packages, then @axiomcode/code-graph. +# +# @axiomcode/engine-- is one package per platform holding every +# language's engine. @axiomcode/code-graph lists them as optionalDependencies +# pinned to its own version, so `npm install` fetches exactly the one for the +# machine — no Soufflé, no compiler. The engines therefore go first: publishing +# the root before them would leave a window where the version it pins is not +# on the registry. +# +# Runs when: +# • a GitHub release is PUBLISHED (release.yml drafts one for each new version +# on main; publishing the draft is the release decision). Real publish. +# • manually (Actions → publish-npm → Run workflow) with dry_run, which packs +# every package and prints exactly what would be uploaded, uploading nothing. +# +# Needs the CLI_BINARY_PUBLISH secret (an npm token with publish rights on @axiomcode) +# for a real publish; a dry run needs nothing. +# +# Re-running is safe: a package whose version is already on the registry is +# skipped, so a run that failed half way can simply be run again. +# ───────────────────────────────────────────────────────────────────────────── +name: publish-npm + +on: + release: + types: [published] + workflow_dispatch: + inputs: + dry_run: + description: pack and print, publish nothing + type: boolean + default: true + +concurrency: + group: publish-npm-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + # Fails in seconds, before any engine is compiled, when the tag and the tree + # disagree about the version. + check: + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.v.outputs.version }} + dist_tag: ${{ steps.v.outputs.dist_tag }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - id: v + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = release ]; then + node .github/scripts/version.mjs check "${{ github.event.release.tag_name }}" + else + node .github/scripts/version.mjs check + fi + version="$(node .github/scripts/version.mjs get)" + # A prerelease never becomes `latest`: 0.2.0-rc.1 goes out as `next`. + case "$version" in *-*) dist_tag=next;; *) dist_tag=latest;; esac + echo "version=$version" >> "$GITHUB_OUTPUT" + 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 + + publish: + needs: [check, engines] + runs-on: ubuntu-24.04 + permissions: + contents: write # attach the tarballs to the release + env: + VERSION: ${{ needs.check.outputs.version }} + DIST_TAG: ${{ needs.check.outputs.dist_tag }} + DRY: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + - uses: actions/download-artifact@v4 + with: { pattern: engines-*, path: artifacts } + + # A real release ships every platform the root package pins. Missing one + # means some machine installs a version whose engine does not exist. + - name: every pinned platform was built + run: | + set -euo pipefail + missing=0 + for name in $(node -p "Object.keys(require('./package.json').optionalDependencies||{}).join(' ')"); do + platform="${name#@axiomcode/engine-}" + if [ ! -d "artifacts/engines-$platform" ]; then + echo "::warning::no engines were built for $platform" + missing=1 + fi + done + if [ "$missing" = 1 ] && [ "$DRY" != true ]; then + echo "::error::a real publish needs every platform package.json pins"; exit 1 + fi + + - name: assemble one engine package per platform + run: | + set -euo pipefail + for d in artifacts/engines-*; do + platform="${d#artifacts/engines-}" + bash packaging/assemble-engine-package.sh "$platform" "$VERSION" "$d" "packages/engine-$platform" + 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: publish (or dry-run) + env: + NODE_AUTH_TOKEN: ${{ secrets.CLI_BINARY_PUBLISH }} + run: | + set -euo pipefail + flag=""; [ "$DRY" = true ] && flag="--dry-run" + mkdir -p tarballs + publish() { + local dir="$1" name + name="$(node -p "require('./$dir/package.json').name")" + if [ -z "$flag" ] && npm view "$name@$VERSION" version >/dev/null 2>&1; then + echo "══ $name@$VERSION is already on the registry — skipped" + return + fi + echo "══ $name@$VERSION tag=$DIST_TAG $flag" + ( cd "$dir" && npm pack --pack-destination "$GITHUB_WORKSPACE/tarballs" >/dev/null \ + && npm publish --access public --tag "$DIST_TAG" $flag ) + } + for p in packages/engine-*; do publish "$p"; done + publish . + ls -la tarballs + if [ "$DRY" = true ]; then + echo "DRY RUN — nothing was uploaded." + else + echo "published $VERSION" + fi + + - name: attach the tarballs to the release + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${{ github.event.release.tag_name }}" tarballs/*.tgz --clobber + + - uses: actions/upload-artifact@v4 + if: github.event_name == 'workflow_dispatch' + with: { name: npm-tarballs, path: tarballs, retention-days: 7 } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..72de687a3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,141 @@ +# ───────────────────────────────────────────────────────────────────────────── +# 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. +# +# 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. +# +# 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. +# ───────────────────────────────────────────────────────────────────────────── +name: release + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: release-main + cancel-in-progress: false + +permissions: + contents: write + +jobs: + tag: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: every manifest carries the same version + run: node .github/scripts/version.mjs check + + - name: tag and draft the release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + version="$(node .github/scripts/version.mjs get)" + tag="v$version" + if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null; then + echo "$tag already exists — this push did not change the version" + exit 0 + fi + prerelease="" + case "$version" in *-*) prerelease="--prerelease";; esac + # The previous tag bounds the generated notes; the first release has none. + prev="$(git tag --list 'v*' --sort=-v:refname | head -1)" + start=""; [ -n "$prev" ] && start="--notes-start-tag $prev" + # 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" + 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 + # 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: + runs-on: ubuntu-24.04 + permissions: + contents: write + issues: write + steps: + - uses: actions/checkout@v4 + with: + ref: dev + fetch-depth: 0 + + - name: merge main into dev + id: merge + run: | + set -uo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + 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 + echo "dev now contains main at ${GITHUB_SHA::8}" + else + git diff --name-only --diff-filter=U > /tmp/conflicts.txt + git merge --abort + echo "conflict=1" >> "$GITHUB_OUTPUT" + echo "::error::main does not merge cleanly into dev" + exit 1 + fi + + # One open issue at most: a later conflicting push comments on it. + - name: say how to resolve it + if: failure() && steps.merge.outputs.conflict == '1' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -uo pipefail + title="main does not merge cleanly into dev" + { + echo "Merging main (${GITHUB_SHA::8}) into dev conflicts in:" + echo; echo '```'; cat /tmp/conflicts.txt; echo '```'; echo + echo "Resolve it locally (dev takes direct pushes):" + echo; echo '```' + echo "git fetch origin && git checkout dev && git pull" + echo "git merge origin/main" + echo "# fix the files above, then" + echo "git add -A && git commit && git push origin dev" + echo '```'; echo + echo "Close this once dev is pushed; the next push to main retries on its own." + echo; echo "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + } > /tmp/body.md + existing="$(gh issue list --state open --search "\"$title\" in:title" --json number --jq '.[0].number' || true)" + if [ -n "$existing" ]; then + gh issue comment "$existing" --body-file /tmp/body.md + else + gh issue create --title "$title" --body-file /tmp/body.md + fi diff --git a/README.md b/README.md index 7ad8410ea..ff865b5a5 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,8 @@

Build - Engines: not yet published - Nightly: not yet enabled + npm + Nightly (dev) License: FSL-1.1-Apache-2.0 Node ≥ 22.5

@@ -257,7 +257,7 @@ A language is usable end to end when both the **parser** (source → relational | **TypeScript** | stable | stable | **stable**. Structural typing, overload sets, the module graph, `.d.ts` libraries | | **Python** | stable | stable | **stable**. MRO, decorators, protocols, dynamic-attribute detection | | **JavaScript** | stable | beta | **beta**. JSDoc as the type channel, CommonJS and ESM; being scored against the TypeScript compiler | -| **C#** | stable | beta | **beta**. Regression cases, ground truth and a runtime oracle; not yet in the published engine packages | +| **C#** | stable | beta | **beta**. Regression cases, ground truth and a runtime oracle | XML, YAML, `.properties` and `META-INF/services` are part of the Java graph, so a change to a property key or a wiring declaration has a blast radius into methods. A repository with several languages gets one graph per diff --git a/graph/test/csharp/run-tests.sh b/graph/test/csharp/run-tests.sh index 964ac3750..ed6448a7e 100755 --- a/graph/test/csharp/run-tests.sh +++ b/graph/test/csharp/run-tests.sh @@ -57,6 +57,14 @@ done [ -n "$WORK" ] || WORK="$REPO/.cs-case-work" mkdir -p "$WORK"; WORK="$(cd "$WORK" && pwd)" +# ONE compiled engine for the whole run. devrun.sh keeps its binary cache beside the +# work dir it is given, and every case below gets a fresh one (rm -rf "$w"), so left +# to its default each case recompiled the same engine — about 70s apiece. The cache +# is content-addressed by the rule text, so sharing it is safe; under CI it lives in +# the compiled-engine cache directory the workflow saves and restores. +export AXIOM_CS_DEV_CACHE="${AXIOM_CS_DEV_CACHE:-${AXIOM_SOUFFLE_CACHE:-$WORK}/cs-dev}" +mkdir -p "$AXIOM_CS_DEV_CACHE" + command -v souffle >/dev/null || { echo "souffle is not installed (brew install souffle)" >&2; exit 77; } [ -f "$REPO/parser/dist/index.js" ] || { echo "the parser is not built (npm run build)" >&2; exit 77; } [ -x "$ORACLE" ] || { diff --git a/graph/test/java/expected/oracle-agreement.txt b/graph/test/java/expected/oracle-agreement.txt index 6fba8287b..a204958ea 100644 --- a/graph/test/java/expected/oracle-agreement.txt +++ b/graph/test/java/expected/oracle-agreement.txt @@ -1,4 +1,4 @@ -cases compared 44 agreeing 44 +cases compared 45 agreeing 45 CONSTRUCTOR-RULE disagreements: 0 cases, 0 rows OTHER disagreements: 0 rows naming no method: 0 diff --git a/graph/test/java/run-tests.sh b/graph/test/java/run-tests.sh index 1c52bb8ab..4a2c433dd 100755 --- a/graph/test/java/run-tests.sh +++ b/graph/test/java/run-tests.sh @@ -21,6 +21,12 @@ # (javac + javap invoke instructions — no third-party analyzer): # every bytecode-declared client->client edge must be present. # ./run-tests.sh --keep keep the per-case work dirs for debugging +# ./run-tests.sh --no-torture skip the torture families. +# ONLY for an environment that cannot hold the JVM platform IR. +# The families call java.util.List, Map and the functional +# interfaces, so with the platform absent those receivers are +# unresolvable by construction, and the score measures the staging +# rather than the rules. Excluding them is stated in the output. # # With --oracle, a case carrying a spring-oracle.conf ALSO boots its sources in a real # AnnotationConfigApplicationContext and scores bean_def / di_edge against what Spring @@ -144,9 +150,9 @@ PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" . "$HERE/tools/oracle-build.sh" ORACLE_FLAGS="" WORK="$HERE/.work" -BLESS=0; KEEP=0; ORACLE=0; FILTERS=() +BLESS=0; KEEP=0; ORACLE=0; NO_TORTURE=0; FILTERS=() for a in "$@"; do case "$a" in - --bless) BLESS=1;; --keep) KEEP=1;; --oracle) ORACLE=1;; + --bless) BLESS=1;; --keep) KEEP=1;; --oracle) ORACLE=1;; --no-torture) NO_TORTURE=1;; -h|--help) sed -n '2,34p' "$0"; exit 0;; *) FILTERS+=("$a");; esac; done # ── PREFLIGHT: every relation the parser emits must actually reach the solver ────────── @@ -543,7 +549,7 @@ done # The cases above each pin ONE rule. This asks what happens when a project uses everything at # once, and reports WHICH construct is the gap rather than one number. It stages the platform IR, # because half the families call java.util types and scoring them without it measures the staging. -if [ -d "$HERE/torture" ] && [ ${#FILTERS[@]} -eq 0 ]; then +if [ -d "$HERE/torture" ] && [ ${#FILTERS[@]} -eq 0 ] && [ "$NO_TORTURE" = 0 ]; then printf '%-34s ' "torture (10 families)" # --bless has to reach the torture harness too, or a run that regenerates every other golden # leaves this one stale and the very next run fails on a diff the operator just approved. @@ -556,6 +562,9 @@ if [ -d "$HERE/torture" ] && [ ${#FILTERS[@]} -eq 0 ]; then echo "FAIL"; echo "$out" | sed 's/^/ /' | head -24; fail=$((fail+1)); failed+=("torture") fi fi +if [ "$NO_TORTURE" = 1 ] && [ ${#FILTERS[@]} -eq 0 ]; then + echo "torture (10 families) EXCLUDED (--no-torture)" +fi echo "─────────────────────────────────────────────" echo "passed $pass failed $fail" diff --git a/graph/test/tools/engine-id-test.sh b/graph/test/tools/engine-id-test.sh index a28525947..7fb46da2a 100755 --- a/graph/test/tools/engine-id-test.sh +++ b/graph/test/tools/engine-id-test.sh @@ -12,32 +12,13 @@ set -u ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker RUN="graph/pipeline/run-souffle.sh" fail=0; bad(){ echo " ✗ $*"; fail=$((fail+1)); } -W="$(mktemp -d)"; trap 'rm -rf "$W"' EXIT +W="$(mktemp -d)"; SHADOW=""; trap 'rm -rf "$W" ${SHADOW:+"$SHADOW"}' EXIT # a copy of the tree, at a different path, with souffle hidden from PATH mkdir -p "$W/copy" cp -R "$ROOT/graph" "$W/copy/graph"; cp "$ROOT/package.json" "$W/copy/package.json" -# SOUFFLE IS HIDDEN BY REMOVING ITS DIRECTORY, not by rebuilding a minimal PATH from a -# whitelist of symlinks. The whitelist cannot work on macOS: /usr/bin/shasum is a perl -# script and the system perl dispatches on the script's CANONICAL path, so a symlink to -# it, or even a copy of it, is refused with "perl version 5.30.3 can't run ". -# The sandbox was then left with no digest tool at all -- and on a machine without -# coreutils there is no sha256sum to fall back to -- so every check failed for a reason -# that had nothing to do with the engine id. Dropping one directory hides souffle and -# leaves every other tool where the system expects to find it. -SOUFFLE_BIN="$(command -v souffle 2>/dev/null || true)" -if [ -n "$SOUFFLE_BIN" ]; then - SOUFFLE_DIR="$(cd "$(dirname "$SOUFFLE_BIN")" && pwd)" - SANDBOX_PATH="$(printf '%s' "$PATH" | tr ':' '\n' | while IFS= read -r d; do - [ -n "$d" ] || continue - rd="$(cd "$d" 2>/dev/null && pwd)" || continue - [ "$rd" = "$SOUFFLE_DIR" ] || printf '%s:' "$d" - done)" - SANDBOX_PATH="${SANDBOX_PATH%:}" -else - SANDBOX_PATH="$PATH" -fi +. "$(dirname "$0")/hide-souffle.sh" # sets SANDBOX_PATH (and SHADOW, removed on exit) command -v souffle >/dev/null 2>&1 && PATH="$SANDBOX_PATH" command -v souffle >/dev/null 2>&1 \ && { echo " ✗ sandbox PATH still finds souffle"; fail=$((fail+1)); } diff --git a/graph/test/tools/engine-package-test.sh b/graph/test/tools/engine-package-test.sh index c2a25d481..a4bbd62cd 100755 --- a/graph/test/tools/engine-package-test.sh +++ b/graph/test/tools/engine-package-test.sh @@ -13,7 +13,7 @@ set -u ROOT="$(d="$(cd "$(dirname "$0")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" # the repository root, found by its marker fail=0; bad(){ echo " ✗ $*"; fail=$((fail+1)); } [ -x "$ROOT/node_modules/.bin/tsx" ] || { echo "engine-package: SKIP (no node_modules/.bin/tsx — run npm install)"; exit 0; } -W="$(mktemp -d)"; trap 'rm -rf "$W"' EXIT +W="$(mktemp -d)"; SHADOW=""; trap 'rm -rf "$W" ${SHADOW:+"$SHADOW"}' EXIT mkdir -p "$W/bin" "$W/ir" "$W/int" "$W/out" "$W/tree" # a copy of the tree with its own node_modules dir (the real one linked in for the bundler) cp -R "$ROOT/graph" "$W/tree/graph"; cp "$ROOT/package.json" "$ROOT/tsconfig.json" "$W/tree/" @@ -21,25 +21,7 @@ mkdir -p "$W/tree/node_modules"; ln -s "$ROOT/node_modules/.bin" "$W/tree/node_m for d in "$ROOT"/node_modules/*/; do n="$(basename "$d")"; [ "$n" = "@axiomcode" ] && continue; ln -s "${d%/}" "$W/tree/node_modules/$n"; done RUN="$W/tree/graph/pipeline/run-souffle.sh" # a PATH with everything the driver and the bundler need, and no souffle -# SOUFFLE IS HIDDEN BY REMOVING ITS DIRECTORY from PATH, not by rebuilding a minimal -# PATH from a whitelist of symlinks. The whitelist cannot work on macOS: /usr/bin/shasum -# is a perl script and the system perl dispatches on the script's CANONICAL path, so a -# symlink to it, or a copy of it, is refused with "perl version 5.30.3 can't run ". -# The sandbox was then left with no digest tool, the library cache key came back empty, -# and the run refused to proceed -- a failure with nothing to do with packaging. -# Same reasoning as engine-id-test.sh; keep the two in step. -SOUFFLE_BIN="$(command -v souffle 2>/dev/null || true)" -if [ -n "$SOUFFLE_BIN" ]; then - SOUFFLE_DIR="$(cd "$(dirname "$SOUFFLE_BIN")" && pwd)" - SANDBOX_PATH="$(printf '%s' "$PATH" | tr ':' '\n' | while IFS= read -r d; do - [ -n "$d" ] || continue - rd="$(cd "$d" 2>/dev/null && pwd)" || continue - [ "$rd" = "$SOUFFLE_DIR" ] || printf '%s:' "$d" - done)" - SANDBOX_PATH="${SANDBOX_PATH%:}" -else - SANDBOX_PATH="$PATH" -fi +. "$(dirname "$0")/hide-souffle.sh" # sets SANDBOX_PATH (and SHADOW, removed on exit) for t in ; do p="$(command -v "$t" 2>/dev/null)" && ln -sf "$p" "$W/bin/$t" done diff --git a/graph/test/tools/hide-souffle.sh b/graph/test/tools/hide-souffle.sh new file mode 100644 index 000000000..559dc407f --- /dev/null +++ b/graph/test/tools/hide-souffle.sh @@ -0,0 +1,35 @@ +# shellcheck shell=bash +# Sourced, not run: sets SANDBOX_PATH, a PATH on which `souffle` cannot be found and every +# other tool still can. Sets SHADOW to a directory the caller removes (it may be empty). +# +# SOUFFLE IS HIDDEN BY REMOVING ITS DIRECTORY, not by rebuilding a minimal PATH from a +# whitelist of symlinks. The whitelist cannot work on macOS: /usr/bin/shasum is a perl +# script and the system perl dispatches on the script's CANONICAL path, so a symlink to +# it, or even a copy of it, is refused with "perl version 5.30.3 can't run ". +# The sandbox was then left with no digest tool at all, so every check failed for a +# reason that had nothing to do with what it tested. +# +# Directories are compared PHYSICALLY (pwd -P): on a merged-/usr Linux /bin is a symlink +# to /usr/bin, so a logical compare keeps /bin and souffle stays reachable through it. +# And where souffle shares its directory with the system tools (/usr/bin on Ubuntu), +# dropping that directory would take bash and sed with it, so it is REPLACED by a shadow +# holding every entry except souffle's own. On macOS souffle sits in Homebrew's bin, so +# the shadow never stands in for /usr/bin and shasum is never symlinked. +SHADOW="" +SOUFFLE_BIN="$(command -v souffle 2>/dev/null || true)" +if [ -n "$SOUFFLE_BIN" ]; then + SOUFFLE_DIR="$(cd "$(dirname "$SOUFFLE_BIN")" && pwd -P)" + SHADOW="$(mktemp -d)" + for e in "$SOUFFLE_DIR"/*; do + case "$(basename "$e")" in souffle*) continue;; esac + ln -s "$e" "$SHADOW/$(basename "$e")" 2>/dev/null || true + done + SANDBOX_PATH="$(printf '%s' "$PATH" | tr ':' '\n' | while IFS= read -r d; do + [ -n "$d" ] || continue + rd="$(cd "$d" 2>/dev/null && pwd -P)" || continue + if [ "$rd" = "$SOUFFLE_DIR" ]; then printf '%s:' "$SHADOW"; else printf '%s:' "$d"; fi + done)" + SANDBOX_PATH="${SANDBOX_PATH%:}" +else + SANDBOX_PATH="$PATH" +fi diff --git a/graph/test/tools/lib-coverage.sh b/graph/test/tools/lib-coverage.sh new file mode 100755 index 000000000..66024e908 --- /dev/null +++ b/graph/test/tools/lib-coverage.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# ── The client->library half must not quietly stop being tested ────────────── +# Most of what these suites assert is client->client. The client->library hand-off +# is carried by a smaller set of fixtures, and it is the half that disappears +# silently: delete a golden and the case still runs, still passes, and simply +# stops making the claim. Nothing in the suites notices, because a suite can only +# check the assertions it still has. +# +# So this pins the SHAPE of that coverage, and it needs no parser and no solver: +# +# 1. every TypeScript case carrying a lib/, and every JavaScript case carrying a +# dependency under src/node_modules/, is solved twice, and both goldens exist — +# the delta between them IS the client->library mapping; +# 2. every Java case carrying a lib-src/ stub library still has its golden; +# 3. the counts never fall. A number here going DOWN is either a deletion that +# wanted review, or coverage lost by accident. +set -uo pipefail +cd "$(dirname "$0")/../../.." || exit 2 + +fail=0 + +# Floors, not targets. Raise one when real coverage is added; lowering one is a +# deliberate reduction in what this repository proves, and belongs in a diff. +TS_LIB_FLOOR=26 +JAVA_LIB_FLOOR=6 +PY_LIB_FLOOR=1 +JS_LIB_FLOOR=1 + +ts_libs=0; ts_missing=() +for d in graph/test/typescript/cases/*/lib; do + [ -d "$d" ] || continue + c="$(basename "$(dirname "$d")")"; ts_libs=$((ts_libs+1)) + [ -f "graph/test/typescript/expected/$c.edges" ] || ts_missing+=("$c.edges") + [ -f "graph/test/typescript/expected/$c.lib.edges" ] || ts_missing+=("$c.lib.edges") +done + +java_libs=0; java_missing=() +for d in graph/test/java/cases/*/lib-src; do + [ -d "$d" ] || continue + c="$(basename "$(dirname "$d")")"; java_libs=$((java_libs+1)) + [ -f "graph/test/java/expected/$c.edges" ] || java_missing+=("$c.edges") +done + +py_libs=0 +[ -d graph/test/python/torture/lib ] && py_libs=1 + +js_libs=0; js_missing=() +for d in graph/test/javascript/cases/*/src/node_modules; do + [ -d "$d" ] || continue + c="$(basename "$(dirname "$(dirname "$d")")")"; js_libs=$((js_libs+1)) + [ -f "graph/test/javascript/expected/$c.edges" ] || js_missing+=("$c.edges") + [ -f "graph/test/javascript/expected/$c.lib.edges" ] || js_missing+=("$c.lib.edges") +done + +printf 'client->library coverage: typescript %d (both goldens each), java %d stub libs, python %d torture lib, javascript %d (both goldens each)\n' \ + "$ts_libs" "$java_libs" "$py_libs" "$js_libs" + +if [ ${#ts_missing[@]} -gt 0 ]; then + echo " a TypeScript case ships a lib/ but is missing a golden, so its client->library half asserts nothing:" + printf ' %s\n' "${ts_missing[@]}"; fail=1 +fi +if [ ${#java_missing[@]} -gt 0 ]; then + echo " a Java case ships a lib-src/ but is missing its golden:" + printf ' %s\n' "${java_missing[@]}"; fail=1 +fi +if [ ${#js_missing[@]} -gt 0 ]; then + echo " a JavaScript case ships a dependency under src/node_modules/ but is missing a golden:" + printf ' %s\n' "${js_missing[@]}"; fail=1 +fi + +check_floor() { # name actual floor + if [ "$2" -lt "$3" ]; then + echo " $1 client->library coverage fell: $2, was $3." + echo " If that removal was intended, lower the floor in this file in the same commit." + fail=1 + fi +} +check_floor typescript "$ts_libs" "$TS_LIB_FLOOR" +check_floor java "$java_libs" "$JAVA_LIB_FLOOR" +check_floor python "$py_libs" "$PY_LIB_FLOOR" +check_floor javascript "$js_libs" "$JS_LIB_FLOOR" + +[ "$fail" = 0 ] && echo " 0 violation(s)" +exit $fail diff --git a/graph/typescript/engine/resolution/value-flow.dl b/graph/typescript/engine/resolution/value-flow.dl index b4ffca795..d6c16d6a6 100644 --- a/graph/typescript/engine/resolution/value-flow.dl +++ b/graph/typescript/engine/resolution/value-flow.dl @@ -188,7 +188,7 @@ holder_value(v, e) :- var_initializer("client", _, e, v), e != "". holder_value(v, e) :- var_reassigned_value(v, e). holder_value(p, a) :- expr_child("client", ce, "ARGUMENT", pos, a), expr_resolves_to_method(ce, callee), # the overload the call SELECTS, not every candidate: - param_decl("client", _, pos, _, callee, p). # an arrow passed to on("close", …) is not every on's listener + param_decl("client", _, pos, _, callee, p). # an arrow passed to on("close", …) is not a listener of every on // ── value_branch(Root, Expr): Expr is a value Root can evaluate to ── value_branch(e, e) :- holder_value(_, e).