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/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/protect-main.sh b/.github/scripts/protect-main.sh new file mode 100755 index 000000000..058aedb81 --- /dev/null +++ b/.github/scripts/protect-main.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# Apply the branch ruleset for `main`. +# +# 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, with an approving review +# - a review is dismissed when new commits are pushed +# - review from a code owner +# - the `CI` check must pass, evaluated against an up-to-date branch +# - linear history: squash or rebase, no merge bubbles +# +# `bypass_actors` is empty on purpose. A rule that some accounts can step around +# is a convention rather than a control, and the point of this file is the control. +# ───────────────────────────────────────────────────────────────────────────── +set -euo pipefail + +REPO="${REPO:-AxiomCodeAI/axiom-code-graph}" +DRY=0 +[ "${1:-}" = "--dry-run" ] && DRY=1 + +payload="$(cat <<'JSON' +{ + "name": "protect-main", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } + }, + "bypass_actors": [], + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { "type": "required_linear_history" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": true, + "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 +)" + +if [ "$DRY" = "1" ]; then + echo "$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 + +existing="$(gh api "repos/$REPO/rulesets" --jq '.[] | select(.name=="protect-main") | .id' || true)" + +if [ -n "$existing" ]; then + echo "updating ruleset $existing on $REPO" + printf '%s' "$payload" | gh api -X PUT "repos/$REPO/rulesets/$existing" --input - >/dev/null +else + echo "creating ruleset on $REPO" + printf '%s' "$payload" | gh api -X POST "repos/$REPO/rulesets" --input - >/dev/null +fi + +# 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 now requires a pull request and a green CI check." +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/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..dc667397f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,300 @@ +# ───────────────────────────────────────────────────────────────────────────── +# 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 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 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. Correctness beats the saved minutes. +# ───────────────────────────────────────────────────────────────────────────── +name: CI + +on: + push: + branches: [main] + pull_request: + merge_group: + workflow_dispatch: + +# 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: + 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 + + # 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; 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; 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 + + engine: + name: engine (${{ matrix.lang }}) + 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' + 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' + + - 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. + - name: cache the compiled Soufflé engine + uses: actions/cache@v4 + with: + path: .souffle-cache + key: souffle-${{ env.SOUFFLE_VERSION }}-${{ matrix.lang }}-${{ hashFiles('graph/**/*.dl', 'graph/**/*.map', 'graph/**/*.tsv') }} + restore-keys: | + souffle-${{ env.SOUFFLE_VERSION }}-${{ matrix.lang }}- + + - name: ${{ matrix.lang }} regression suite + env: + AXIOM_PARSER: ${{ github.workspace }}/parser/dist/index.js + 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. macOS builds on a self-hosted runner and + # is left to the publish workflow. + engines: + name: engines build on every platform + needs: [build] + uses: ./.github/workflows/build-engines.yml + with: + macos: false + + # 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: [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. + results="${{ join(needs.*.result, ' ') }}" + echo "upstream results: $results" + for r in $results; do + [ "$r" = "success" ] || { echo "::error::a required job reported '$r'"; exit 1; } + done + 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/graph/test/java/run-tests.sh b/graph/test/java/run-tests.sh index 11a690a70..2e2d0da00 100755 --- a/graph/test/java/run-tests.sh +++ b/graph/test/java/run-tests.sh @@ -21,6 +21,15 @@ # (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: the census goes from 10 missing +# edges to 23 and recall to 0.847, which measures the staging +# rather than the rules. Running them without it is worse than +# not running them, because the resulting red looks like a +# regression. Excluding them is stated in the output, never silent. # # 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 @@ -120,9 +129,9 @@ if ! bash "$ROOT/graph/test/tools/engine-package-test.sh"; then fi PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" 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 ────────── @@ -383,7 +392,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. @@ -397,6 +406,9 @@ if [ -d "$HERE/torture" ] && [ ${#FILTERS[@]} -eq 0 ]; then fi fi +if [ "$NO_TORTURE" = 1 ] && [ ${#FILTERS[@]} -eq 0 ]; then + echo "torture (10 families) EXCLUDED (--no-torture)" +fi echo "─────────────────────────────────────────────" echo "passed $pass failed $fail" [ $fail -eq 0 ] || { printf 'failing: %s\n' "${failed[*]}"; exit 1; } 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