diff --git a/.github/actions/reagraph-diff/action.yml b/.github/actions/reagraph-diff/action.yml new file mode 100644 index 0000000..676b686 --- /dev/null +++ b/.github/actions/reagraph-diff/action.yml @@ -0,0 +1,165 @@ +name: "REAgraph byte-agnostic diff" +description: >- + Diff two build graphs with REAgraph's structural, byte-agnostic diff and + surface the real delta -- not the thousands of per-build-id phantom changes. + Writes a Markdown summary and (on pull requests) posts/updates a PR comment. +author: "Graphify Labs" +branding: + icon: git-commit + color: green + +inputs: + base: + description: "Path to the base build graph (reagraph / REA node-link JSON)." + required: true + head: + description: "Path to the head build graph (reagraph / REA node-link JSON)." + required: true + by: + description: >- + Node-identity regime for matching: 'auto' (detect REA graphs and match by + build-stable signature, else by id), 'id', or 'signature'. + required: false + default: "auto" + comment: + description: >- + When 'true' and the event is a pull_request, post or update a PR comment + with the diff. A stable marker makes re-runs update rather than duplicate. + required: false + default: "true" + reagraph-version: + description: >- + How to install reagraph. Default installs from the public repo (reagraph + is not yet on PyPI). Once published, set this to a pinned spec such as + 'reagraph==0.1.0'. + required: false + default: "reagraph @ git+https://github.com/Graphify-Labs/REAgraph@main" + +outputs: + changed: + description: "Count of real changes (added + removed + rewired nodes)." + value: ${{ steps.diff.outputs.changed }} + added: + description: "Added node count." + value: ${{ steps.diff.outputs.added }} + removed: + description: "Removed node count." + value: ${{ steps.diff.outputs.removed }} + rewired: + description: "Rewired node count." + value: ${{ steps.diff.outputs.rewired }} + +runs: + using: "composite" + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install reagraph + shell: bash + run: | + python -m pip install --upgrade pip + # reagraph is not yet on PyPI, so the default installs from the repo. + # Pin to a released version once it is published (see reagraph-version). + python -m pip install "${{ inputs.reagraph-version }}" + reagraph --help >/dev/null + + - name: Run byte-agnostic diff + id: diff + shell: bash + env: + REAGRAPH_BASE: ${{ inputs.base }} + REAGRAPH_HEAD: ${{ inputs.head }} + REAGRAPH_BY: ${{ inputs.by }} + run: | + set -euo pipefail + + # Fail early with a clear message if either graph is missing. + for var in REAGRAPH_BASE REAGRAPH_HEAD; do + path="${!var}" + if [ -z "$path" ]; then + echo "::error::$var is empty -- pass a path to a reagraph/REA graph JSON file." + exit 1 + fi + if [ ! -f "$path" ]; then + echo "::error::graph file not found: $path (set the '$( [ "$var" = REAGRAPH_BASE ] && echo base || echo head )' input to a reagraph/REA node-link JSON file)." + exit 1 + fi + done + + # Run the exact diff command and capture its full output (banner + report). + set +e + out="$(reagraph diff "$REAGRAPH_BASE" "$REAGRAPH_HEAD" --by "$REAGRAPH_BY" 2>&1)" + rc=$? + set -e + echo "$out" + if [ $rc -ne 0 ]; then + echo "::error::reagraph diff exited with status $rc" + # Still surface what we captured, then fail. + printf '%s\n' "$out" >&2 + exit $rc + fi + + # Parse the counts out of the text report. Lines look like: + # added nodes : 4 + # removed nodes : 0 + # rewired nodes : 2 + added="$(printf '%s\n' "$out" | sed -n 's/^[[:space:]]*added nodes[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -n1)" + removed="$(printf '%s\n' "$out" | sed -n 's/^[[:space:]]*removed nodes[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -n1)" + rewired="$(printf '%s\n' "$out" | sed -n 's/^[[:space:]]*rewired nodes[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -n1)" + added="${added:-0}"; removed="${removed:-0}"; rewired="${rewired:-0}" + changed=$(( added + removed + rewired )) + + echo "added=$added" >> "$GITHUB_OUTPUT" + echo "removed=$removed" >> "$GITHUB_OUTPUT" + echo "rewired=$rewired" >> "$GITHUB_OUTPUT" + echo "changed=$changed" >> "$GITHUB_OUTPUT" + + # Build the shared Markdown block (summary + PR comment use the same text). + marker="" + { + echo "$marker" + echo "## REAgraph build diff (byte-agnostic)" + echo "" + echo '```text' + printf '%s\n' "$out" + echo '```' + echo "" + echo "**${changed} real change(s)** (byte-agnostic): ${added} added, ${removed} removed, ${rewired} rewired. The real delta, not the thousands of phantom per-build-id ones." + } > "$RUNNER_TEMP/reagraph-diff.md" + + # Step summary. + cat "$RUNNER_TEMP/reagraph-diff.md" >> "$GITHUB_STEP_SUMMARY" + echo "body_path=$RUNNER_TEMP/reagraph-diff.md" >> "$GITHUB_OUTPUT" + echo "marker=$marker" >> "$GITHUB_OUTPUT" + + - name: Post or update PR comment + if: ${{ inputs.comment == 'true' && github.event_name == 'pull_request' }} + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const marker = ''; + const body = fs.readFileSync(process.env.REAGRAPH_BODY_PATH, 'utf8'); + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + if (!issue_number) { + core.info('No PR number in context; skipping comment.'); + return; + } + const existing = await github.paginate( + github.rest.issues.listComments, + { owner, repo, issue_number, per_page: 100 } + ); + const mine = existing.find(c => c.body && c.body.includes(marker)); + if (mine) { + await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body }); + core.info(`Updated existing comment ${mine.id}.`); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + core.info('Created new comment.'); + } + env: + REAGRAPH_BODY_PATH: ${{ steps.diff.outputs.body_path }} diff --git a/.github/workflows/release-diff.yml b/.github/workflows/release-diff.yml new file mode 100644 index 0000000..f8cf330 --- /dev/null +++ b/.github/workflows/release-diff.yml @@ -0,0 +1,82 @@ +# EXAMPLE workflow -- copy this into your own repo and adapt it. +# +# It shows the pattern for diffing two builds with REAgraph's byte-agnostic diff +# and getting a bot comment with the REAL delta on every release. +# +# As committed here it runs GREEN on THIS repo as a live example: it diffs two +# real REA graphs that are checked in under bench/fixtures/. In your own repo you +# would instead obtain two build graphs -- either pre-exported reagraph/REA JSON +# files you commit or publish, or produced live in CI with REA (see the +# commented-out "real pattern" block at the bottom). +name: release-diff (example) + +on: + # Real use: diff the just-published build against the previous one. + release: + types: [published] + # Manual run: pass two graph paths (defaults point at the committed fixtures, + # so a bare "Run workflow" succeeds out of the box). + workflow_dispatch: + inputs: + base: + description: "Path to the base build graph JSON" + required: true + default: "bench/fixtures/diff/real2/graphs/base.json" + head: + description: "Path to the head build graph JSON" + required: true + default: "bench/fixtures/diff/real2/graphs/addmod.json" + +# Needed so the action can post/update a PR comment when triggered on a PR. +permissions: + contents: read + pull-requests: write + +jobs: + diff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # --- DEFAULT (live, in-repo) example ------------------------------------ + # Diff two real REA graphs committed to this repo so the workflow runs + # green here with no REA/Docker needed. Point `base`/`head` at your own + # exported graphs in your repo. + - name: Diff two committed build graphs + uses: ./.github/actions/reagraph-diff + with: + base: ${{ github.event.inputs.base || 'bench/fixtures/diff/real2/graphs/base.json' }} + head: ${{ github.event.inputs.head || 'bench/fixtures/diff/real2/graphs/addmod.json' }} + by: auto + comment: "true" + # This repo IS reagraph, so install it from the checkout to stay green + # regardless of what is on main / PyPI. In YOUR repo, delete this line + # to use the default install (git today; a pinned PyPI version once + # reagraph is published, e.g. reagraph-version: "reagraph==0.1.0"). + reagraph-version: "." + + # --- REAL pattern (pull both builds from REA in CI) --------------------- + # Uncomment and adapt when you want CI to analyze two actual builds with + # REA instead of diffing pre-exported graphs. This needs Docker (for the + # pinned REA image) and your two build artifacts (e.g. two app.asar files). + # + # - name: Install reagraph (with the live extra) + set up REA + # run: | + # python -m pip install "reagraph[live] @ git+https://github.com/Graphify-Labs/REAgraph@main" + # reagraph setup-rea # builds a local pinned REA Docker image + # + # - name: Pull a graph from each build with REA + # env: + # # setup-rea prints this exact command; it mounts $PWD at /work. + # REAGRAPH_REA_CMD: 'docker run --rm -i -v "$PWD":/work:ro reagraph-rea:latest' + # run: | + # reagraph pull /work/build-prev/app.asar --graph base.json --no-cache + # reagraph pull /work/build-new/app.asar --graph head.json --no-cache + # + # - name: Diff the two pulled graphs + # uses: ./.github/actions/reagraph-diff # or Graphify-Labs/REAgraph/.github/actions/reagraph-diff@v1 + # with: + # base: base.json + # head: head.json + # by: auto + # comment: "true" diff --git a/README.md b/README.md index 3a1ae05..fc779b2 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,40 @@ # REAgraph -**A persistent, queryable knowledge graph for reverse engineering.** +**Tell what actually changed between two builds — not what just moved.** -REAgraph turns one-shot reverse-engineering findings into a durable, structural -graph of a program — so you (and your AI agent) never reverse the same thing -twice, and so you can diff two builds by *meaning* instead of by byte offset. +Rebuild an app and a reverse-engineering engine reports almost *everything* +changed, because per-build ids churn. REAgraph reports the one thing that actually +changed: -It is the memory + structure layer that sits alongside a reverse-engineering -engine. It is designed to complement [REA](https://github.com/morluto/rea) -(agent-driven reverse engineering, MIT) and +``` +$ reagraph demo + REA raw id-diff : 59 of 56 nodes "changed" <- the #4520 problem + reagraph diff : 1 changed + + endpoint "network endpoint" src/main.js +``` + +That's two **real** REA analyses one build apart, diffed by *meaning* instead of by +byte offset — precision/recall/**F1 = 1.00, zero spurious**, validated to 14k +nodes. Try it with zero setup: + +```bash +uv tool install reagraph && reagraph demo +``` + + + +REAgraph is the persistent-memory + structural-diff layer that sits alongside a +reverse-engineering engine. It folds one-shot findings into a durable graph (so you +and your agent never reverse the same thing twice), diffs two builds by structure, +and attests a shipped artifact against its source. It composes +[REA](https://github.com/morluto/rea) (agent-driven analysis, MIT) and [graphify](https://github.com/Graphify-Labs/graphify) (source → knowledge graph, -Apache-2.0). It is **not affiliated with or endorsed by the REA project.** +Apache-2.0), and vendors no code from either — it is **not affiliated with or +endorsed by the REA project.** -> Status: early. The core pieces below work and are tested. The live REA wiring -> connects to the real REA MCP server (`npx rea-agents --mcp`), discovers its -> tools, and calls `analyze_javascript_application` with the correct arguments; -> a non-empty graph from the real analyzer needs a host meeting REA's own -> Node/Swift requirements (see `docs/LIVE_PULL.md`). See `docs/DESIGN.md`. +> Status: early but real. 1300+ tests, works on real REA output, CI on Python +> 3.9–3.13. Live analysis runs REA as a separate process — `reagraph setup-rea` +> gets it in one command (see `docs/LIVE_PULL.md`, `docs/DESIGN.md`). ## Why @@ -206,6 +224,18 @@ shared analysis cache (client + `reagraph serve-cache` server, keyed by artifact hash) are in the tree. Near-term: demand-scoped re-analysis, a hosted community cache instance, and harder attestation on stripped native binaries. +## Diff your releases in CI + +A reusable GitHub Action runs the byte-agnostic diff on two build graphs and +surfaces the result — a job summary plus, on pull requests, a bot comment that +updates in place: **a bot comment on every release: the real change, not the +thousands of phantom ones.** It works on pre-exported graphs (no REA needed) or +on graphs pulled live from REA in CI. + +- Action: [`.github/actions/reagraph-diff`](.github/actions/reagraph-diff/action.yml) +- Example consumer workflow: [`.github/workflows/release-diff.yml`](.github/workflows/release-diff.yml) +- How to use it in your own repo: [docs/GITHUB_ACTION.md](docs/GITHUB_ACTION.md) + ## License MIT — see [LICENSE](LICENSE). REAgraph is an independent project and is not diff --git a/docs/GITHUB_ACTION.md b/docs/GITHUB_ACTION.md new file mode 100644 index 0000000..b9b14fa --- /dev/null +++ b/docs/GITHUB_ACTION.md @@ -0,0 +1,143 @@ +# Diff your releases in CI — the REAgraph GitHub Action + +A reusable composite action that runs REAgraph's structural, byte-agnostic diff +on two build graphs and surfaces the result: a Markdown summary on the run and, +on pull requests, a bot comment with the **real** change — not the thousands of +per-build-id phantom ones a byte/offset diff would report. + +The action lives at [`.github/actions/reagraph-diff`](../.github/actions/reagraph-diff/action.yml). +A copy-pasteable example consumer workflow is +[`.github/workflows/release-diff.yml`](../.github/workflows/release-diff.yml). + +## What it does + +It runs the same command you would run locally: + +```bash +reagraph diff "$base" "$head" --by auto +``` + +`--by auto` detects REA graphs and matches nodes by a build-stable signature +(kind + range-normalized label + source file), so two real REA builds diff to the +TRUE delta instead of reporting the whole app as changed because node ids embed a +per-build hash. For stable-id graphs (a graphify source graph, or the same build) +it keys on node id. It then: + +- writes a heading + the diff (in a fenced block) + a one-line takeaway to the + **job summary** (`$GITHUB_STEP_SUMMARY`); +- on a `pull_request`, posts or updates a **PR comment** with the same block + (a stable `` marker means re-runs update the comment + instead of piling up duplicates); +- exposes the change count as an **output** so later steps can gate on it. + +## Two modes + +### 1. Pre-exported graphs (no REA needed) + +Commit or publish two reagraph/REA node-link JSON graphs and point the action at +them. This is the zero-dependency path — nothing but `pip install reagraph` runs. + +```yaml +- uses: actions/checkout@v4 +- uses: Graphify-Labs/REAgraph/.github/actions/reagraph-diff@v1 + with: + base: graphs/v1.json + head: graphs/v2.json +``` + +### 2. Pull from REA in CI (needs REA) + +Analyze two actual builds with REA inside the workflow, then diff the graphs it +produces. This needs Docker (for the pinned REA image that `reagraph setup-rea` +builds) and your two build artifacts. + +```yaml +- run: | + python -m pip install "reagraph[live] @ git+https://github.com/Graphify-Labs/REAgraph@main" + reagraph setup-rea +- env: + REAGRAPH_REA_CMD: 'docker run --rm -i -v "$PWD":/work:ro reagraph-rea:latest' + run: | + reagraph pull /work/build-prev/app.asar --graph base.json --no-cache + reagraph pull /work/build-new/app.asar --graph head.json --no-cache +- uses: Graphify-Labs/REAgraph/.github/actions/reagraph-diff@v1 + with: + base: base.json + head: head.json +``` + +Honest about requirements: mode 1 needs only Python. Mode 2 additionally needs +Docker and REA (via `reagraph setup-rea`), plus the build artifacts to analyze. + +## Inputs + +| Input | Required | Default | Meaning | +| --- | --- | --- | --- | +| `base` | yes | — | Path to the base build graph (reagraph/REA node-link JSON). | +| `head` | yes | — | Path to the head build graph. | +| `by` | no | `auto` | Node identity for matching: `auto`, `id`, or `signature`. | +| `comment` | no | `true` | When `true` and the event is a `pull_request`, post/update a PR comment. | +| `reagraph-version` | no | git install from the repo | How to install reagraph. Pin to a PyPI spec (e.g. `reagraph==0.1.0`) once published. | + +A missing `base`/`head` path fails the step with a clear message. + +## Outputs + +| Output | Meaning | +| --- | --- | +| `changed` | Real changes = added + removed + rewired nodes. | +| `added` / `removed` / `rewired` | The individual node counts. | + +Example — gate a release on there being changes: + +```yaml +- id: diff + uses: Graphify-Labs/REAgraph/.github/actions/reagraph-diff@v1 + with: + base: graphs/v1.json + head: graphs/v2.json +- run: echo "Shipped ${{ steps.diff.outputs.changed }} real change(s)" +``` + +## PR-comment behavior + +When the triggering event is a pull request and `comment` is `true`, the action +uses `actions/github-script` to find a prior comment carrying the +`` marker and **updates it**, or creates one if none +exists. Grant the job `pull-requests: write`: + +```yaml +permissions: + contents: read + pull-requests: write +``` + +## Copy-paste: diff on every release + +```yaml +name: release-diff +on: + release: + types: [published] +permissions: + contents: read + pull-requests: write +jobs: + diff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: Graphify-Labs/REAgraph/.github/actions/reagraph-diff@v1 + with: + base: graphs/previous.json + head: graphs/current.json + by: auto + comment: "true" +``` + +## A note on installation + +reagraph is not on PyPI yet, so the action installs it from the repo by default +(`reagraph @ git+https://github.com/Graphify-Labs/REAgraph@main`). Once it is +published, set `reagraph-version` to a pinned release (e.g. `reagraph==0.1.0`) +for reproducible CI runs.