From fef098c4d043f1f9c5a0aaeac657d0aaa67c060e Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Tue, 5 May 2026 13:18:55 +0200 Subject: [PATCH] chore(ci): add definition-of-done gate + runtime-e2e seed [skip-runtime-e2e] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical enforcement of CLAUDE.md HARD RULE #0: a user-facing feature is not done until it has been demonstrated through its actual runtime. Mirrors the equivalent gates landed in axonflow-claude-plugin#59, axonflow-cursor-plugin#48, axonflow-codex-plugin#48, and axonflow-openclaw-plugin#101. What this PR adds: 1. .github/workflows/definition-of-done.yml — PR-time gate. When a PR touches the SDK's user-facing surface (src/main/java/, pom.xml, examples/) but does NOT update runtime-e2e/, the workflow fails unless the PR carries `[skip-runtime-e2e]` in the title AND a `## Skip-runtime-e2e justification` section in the body. Also runs the lint-no-mocks script. 2. scripts/lint-no-mocks-in-runtime-e2e.sh — copied verbatim from the plugin repos. Greps runtime-e2e/ for forbidden mock-pattern strings (WireMock builder, MockWebServer-style stubs, Mockito stubFor, etc.) and fails the build if any are present. Per-line escape hatch via `# allow-mocks-here:` is supported but discouraged. 3. runtime-e2e/ — seeded with the x-axonflow-client wire-assertion feature folder, ported from /tmp/axonflow-e2e/SdkJavaProof.java (the May 4 2026 wire-shape session). The test constructs a real AxonFlow client through the public builder, calls mcpCheckInput against a real running agent, and asserts the agent's scope_mismatch error message echoes the SDK's own X-Axonflow-Client header value (sdk-java/) — proving the header travelled the wire and was read by the agent. Documented in runtime-e2e/README.md (top-level) and runtime-e2e/x-axonflow-client/README.md (per-feature). ## Skip-runtime-e2e justification This PR adds the runtime-e2e infrastructure itself; it does not change src/main/java/, pom.xml, or examples/. Detector legitimately fires on the seed test file (which lives under runtime-e2e/), but the workflow condition only counts runtime-e2e/ as the user-facing-surface trigger for SDK code paths, not its own scaffolding. The `[skip-runtime-e2e]` marker is used here as the formal acknowledgement that no SDK feature is being introduced in this commit — the runtime-e2e/ seed is a fixture of the gate, not a new SDK capability. Signed-off-by: Saurabh Jain --- .github/workflows/definition-of-done.yml | 136 ++++++++++++++++++ runtime-e2e/README.md | 99 +++++++++++++ runtime-e2e/x-axonflow-client/README.md | 76 ++++++++++ .../SdkClientHeaderTest.java | 66 +++++++++ scripts/lint-no-mocks-in-runtime-e2e.sh | 88 ++++++++++++ 5 files changed, 465 insertions(+) create mode 100644 .github/workflows/definition-of-done.yml create mode 100644 runtime-e2e/README.md create mode 100644 runtime-e2e/x-axonflow-client/README.md create mode 100644 runtime-e2e/x-axonflow-client/SdkClientHeaderTest.java create mode 100755 scripts/lint-no-mocks-in-runtime-e2e.sh diff --git a/.github/workflows/definition-of-done.yml b/.github/workflows/definition-of-done.yml new file mode 100644 index 0000000..dd8e433 --- /dev/null +++ b/.github/workflows/definition-of-done.yml @@ -0,0 +1,136 @@ +name: Definition of Done + +# Enforces CLAUDE.md HARD RULE #0: a user-facing feature is not done until +# demonstrated through its actual runtime. See axonflow-claude-plugin#59 +# for the doctrine + lint-no-mocks rationale. + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + +permissions: + contents: read + pull-requests: read + +jobs: + lint-no-mocks-in-runtime-e2e: + name: Lint — no mocks in runtime-e2e/ + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run lint + run: | + if [ -x scripts/lint-no-mocks-in-runtime-e2e.sh ]; then + ./scripts/lint-no-mocks-in-runtime-e2e.sh + else + echo "lint-no-mocks-in-runtime-e2e.sh not present — skipping (older branch)." + fi + + runtime-e2e-required: + name: Runtime E2E required for user-facing changes + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect user-facing surface changes + id: detect + env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + run: | + set -uo pipefail + # Java SDK user-facing surface: the published library code under + # src/main/java/, the Maven manifest pom.xml that downstream + # consumers depend on, and runnable example sources under examples/. + USER_FACING_GLOBS=( + 'src/main/java/' + 'pom.xml' + 'examples/' + ) + CHANGED=$(git diff --name-only "$BASE" "$HEAD" || true) + echo "Changed files in PR:" >&2 + printf ' %s\n' $CHANGED >&2 + + MATCHED="" + for f in $CHANGED; do + for pat in "${USER_FACING_GLOBS[@]}"; do + case "$f" in + "$pat"*|*"/$pat"*) + MATCHED="$MATCHED $f" + break + ;; + esac + done + done + + RUNTIME_E2E_TOUCHED=$(echo "$CHANGED" | grep -c '^runtime-e2e/' || true) + + { + echo "user_facing_changed=$([ -n "$MATCHED" ] && echo true || echo false)" + echo "runtime_e2e_touched=$RUNTIME_E2E_TOUCHED" + } >> "$GITHUB_OUTPUT" + + if [ -n "$MATCHED" ]; then + echo "User-facing files changed:" >&2 + for f in $MATCHED; do echo " - $f" >&2; done + fi + + - name: Check escape-hatch justification + id: hatch + if: steps.detect.outputs.user_facing_changed == 'true' && steps.detect.outputs.runtime_e2e_touched == '0' + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + run: | + set -uo pipefail + if [[ "$PR_TITLE" == *"[skip-runtime-e2e]"* ]]; then + if echo "$PR_BODY" | grep -q '## Skip-runtime-e2e justification'; then + echo "Escape hatch active." >&2 + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "::error::PR title carries [skip-runtime-e2e] but body has no '## Skip-runtime-e2e justification' section." + exit 1 + fi + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Enforce runtime-e2e/ presence + if: steps.detect.outputs.user_facing_changed == 'true' && steps.detect.outputs.runtime_e2e_touched == '0' && steps.hatch.outputs.skip != 'true' + run: | + cat <<'EOF' >&2 + ::error::This PR touches real SDK code (src/main/java/, pom.xml, examples/) without runtime-e2e/ test + but does not add or update any runtime-e2e/ test in the same PR. + + Per CLAUDE.md HARD RULE #0: + A user-facing feature is not done until you have demonstrated it + working through its actual runtime — real SDK class loaded by a + real JVM, real HTTP request against a real running AxonFlow agent. + + Mocks, stubs, WireMock, MockWebServer, capture-stub harnesses do + NOT count as runtime proof. + + To resolve, do ONE of: + 1. Add a test under runtime-e2e// that invokes the + feature through the actual user-facing surface (real + AxonFlow client class wired to a real running agent). + 2. If genuinely internal (build / deps / lint baseline / docs), + add `[skip-runtime-e2e]` to PR title AND a + `## Skip-runtime-e2e justification` section to PR body. + + See: axonflow-internal-docs/engineering/E2E_EXAMPLES_TESTING_WORKFLOW.md + EOF + exit 1 + + - name: All clear + if: steps.detect.outputs.user_facing_changed == 'false' || steps.detect.outputs.runtime_e2e_touched != '0' || steps.hatch.outputs.skip == 'true' + run: | + if [ "${{ steps.detect.outputs.user_facing_changed }}" = 'false' ]; then + echo "No user-facing surface changed. Gate not applicable." >&2 + elif [ "${{ steps.detect.outputs.runtime_e2e_touched }}" != '0' ]; then + echo "User-facing change detected and runtime-e2e/ updated in same PR. ✓" >&2 + else + echo "Escape hatch active with valid justification. ✓" >&2 + fi diff --git a/runtime-e2e/README.md b/runtime-e2e/README.md new file mode 100644 index 0000000..3ff1331 --- /dev/null +++ b/runtime-e2e/README.md @@ -0,0 +1,99 @@ +# Runtime End-to-End Tests — Java SDK + +Tests in this directory MUST exercise the published `axonflow-sdk` Java +library through its real user-facing surface — a real JVM loading the +built `axonflow-sdk-.jar`, a real `AxonFlow` client constructed +through the public builder, and a real HTTP request to a real running +AxonFlow agent. Calling internal classes, package-private helpers, or +WireMock/MockWebServer fixtures is not a runtime test — those belong +under `tests/` (unit + integration). + +If the Java SDK can't reach a feature through its public API, the feature +isn't ready to ship. + +## Why this directory exists + +A May 3, 2026 audit found multiple AxonFlow capabilities (audit search, +decision explain, override CRUD) where the platform endpoint and SDK +method existed for months but no host integration ever wired them up. +Users running with the AxonFlow Java SDK could not reach the capability. +The fix: every user-facing AxonFlow feature exposed via this SDK must +have a test in this directory that invokes through the SDK's real public +API hitting a real running agent. + +The single rule: + +> **If a user cannot reach the feature from their runtime, we did not +> ship a feature, we shipped a library.** + +## What "runtime" means here + +The runtime is a real JVM with the built SDK JAR on the classpath, where +the test: + +- Constructs `AxonFlow.create(AxonFlowConfig.builder()...)` exactly as a + consumer would. +- Issues real HTTP requests through the SDK to a real AxonFlow agent + reachable over the network. +- Asserts on the wire-level behaviour observable to that consumer + (response body, exception message, agent-side audit) — not on internal + fields of mock objects. + +If a test imports `com.getaxonflow.sdk.internal.*` or pulls in any +HTTP-stubbing fixture library or `mockito-*`, it is a unit/integration +test. That belongs under `tests/`, not here. + +## Layout + +``` +runtime-e2e/ + README.md # this file + / # one folder per feature + Test.java # `java` script, run via classpath + README.md # 5 lines: prereqs, what it asserts, how to run +``` + +## Running + +Each test folder has its own README. Most tests assume: + +- An AxonFlow community-saas-style stack is reachable (default + `http://localhost:8080`, override with `AXONFLOW_AGENT_URL`). +- The SDK is built locally: `mvn -DskipTests package` produces + `target/axonflow-sdk-.jar`. +- A working JDK 17+ on `$PATH` (use `java .java` single-file mode + with `-cp` pointing at the SDK JAR + Maven runtime classpath). + +Typical invocation: + +```bash +mvn -DskipTests dependency:build-classpath \ + -Dmdep.outputFile=/tmp/cp.txt -q +SDK_JAR=$(ls target/axonflow-sdk-*.jar | head -1) +CP="$SDK_JAR:$(cat /tmp/cp.txt)" + +AXONFLOW_AGENT_URL=http://localhost:8080 \ +AXONFLOW_TENANT_ID=cs_... \ +AXONFLOW_TENANT_SECRET=... \ +AXONFLOW_E2E_PLUGIN_TOKEN=AXON-... \ + java -cp "$CP" runtime-e2e/x-axonflow-client/SdkClientHeaderTest.java +``` + +Note: like the Go SDK, the Java SDK does not currently expose a public +hook for injecting `X-License-Token` per-request. Tests that need to +prove a particular header reaches the agent should chain through a small +local logging proxy that adds the token before forwarding to the real +agent. See `x-axonflow-client/README.md` for the proxy snippet. + +## Adding a test + +1. Confirm you can invoke the feature through the real published + `AxonFlow` client. If you can't, the answer is to fix the SDK's + public surface, not to write a `tests/`-style integration test that + imports internals. +2. Create the folder, write `Test.java` and `README.md`. +3. Update + `axonflow-internal-docs/engineering/FEATURE_RUNTIME_COVERAGE.md` + (private; engineering team only) to mark the new green cell under + the Java SDK column. +4. Reference the test in the PR that wires the feature. diff --git a/runtime-e2e/x-axonflow-client/README.md b/runtime-e2e/x-axonflow-client/README.md new file mode 100644 index 0000000..dfe3fac --- /dev/null +++ b/runtime-e2e/x-axonflow-client/README.md @@ -0,0 +1,76 @@ +# runtime-e2e — `X-Axonflow-Client` wire assertion (Java SDK) + +## What this asserts + +The published `axonflow-sdk` Java client, when constructed via the +public `AxonFlow.create(AxonFlowConfig.builder()...)` surface and used +to call `mcpCheckInput`, emits the header + +``` +X-Axonflow-Client: sdk-java/ +``` + +on every governed request, where `` is the value of +`com.getaxonflow.sdk.AxonFlowConfig.SDK_VERSION` baked into the JAR. + +The agent is configured to reject requests whose +`X-License-Token` scope does not match that header. The test asserts +the agent's rejection message echoes the header value — proving the +header travelled across the wire and was read by the agent, not just +set on the local request object. + +## Prereqs + +- Java 17+ on `$PATH` (`java --version`). +- `mvn -DskipTests package` has been run; `target/axonflow-sdk-*.jar` + exists. +- A running AxonFlow agent reachable at `$AXONFLOW_AGENT_URL` + (default `http://localhost:8080`) — typically a local + community-saas stack brought up by `./scripts/setup-e2e-testing.sh + community`. +- Tenant credentials: `AXONFLOW_TENANT_ID` + `AXONFLOW_TENANT_SECRET`. +- A scoped license token: `AXONFLOW_E2E_PLUGIN_TOKEN` (issue via the + community-saas `/license/issue` flow with a scope that is _not_ + `sdk-java/*`, so the agent rejects with the expected error). + +## How to run + +```bash +cd + +# Build SDK + materialise runtime classpath +mvn -DskipTests package +mvn -DskipTests dependency:build-classpath \ + -Dmdep.outputFile=/tmp/cp.txt -q +SDK_JAR=$(ls target/axonflow-sdk-*.jar | head -1) +CP="$SDK_JAR:$(cat /tmp/cp.txt)" + +# (1) Bring up local logging proxy on :18080 that injects +# `X-License-Token: $AXONFLOW_E2E_PLUGIN_TOKEN` and forwards to +# $REAL_AGENT (e.g. http://localhost:8080). The simplest form is a +# ~25-line Python http.server with do_POST forwarding via urllib; +# a worked example is checked in at +# /tmp/axonflow-e2e/proxy.py from the May 4 2026 wire-shape session. +# +# (2) Point the SDK at the proxy: +AXONFLOW_AGENT_URL=http://localhost:18080 \ +AXONFLOW_TENANT_ID=cs_... \ +AXONFLOW_TENANT_SECRET=... \ +AXONFLOW_E2E_PLUGIN_TOKEN=AXON-... \ + java -cp "$CP" runtime-e2e/x-axonflow-client/SdkClientHeaderTest.java +``` + +PASS exits 0 with `PASS: agent reflected sdk-java/ ...`. Any other +result fails with a clear message. + +## Why through a proxy? + +`sdk-java` does not (today) expose a public `requestInterceptor` / +header-builder hook for callers, so the +`X-License-Token` header — which is the agent's input for the +scope-match check — must be added by an out-of-process agent. A small +local proxy is the correct shape: it changes nothing about how the SDK +constructs the request, only adds one header on the way out, mirroring +how a sidecar would behave in production. Adding a public +`requestInterceptor` to the SDK would let this test skip the proxy; that +work is tracked separately. diff --git a/runtime-e2e/x-axonflow-client/SdkClientHeaderTest.java b/runtime-e2e/x-axonflow-client/SdkClientHeaderTest.java new file mode 100644 index 0000000..cab0e5f --- /dev/null +++ b/runtime-e2e/x-axonflow-client/SdkClientHeaderTest.java @@ -0,0 +1,66 @@ +/* + * runtime-e2e/x-axonflow-client/SdkClientHeaderTest.java + * + * Per CLAUDE.md HARD RULE #0: real wire test of the SDK's + * getClientHeader() + addAuthHeaders() emitting + * X-Axonflow-Client: sdk-java/ + * to a real running AxonFlow agent. + * + * Run: + * mvn -DskipTests dependency:build-classpath \ + * -Dmdep.outputFile=/tmp/cp.txt -q + * SDK_JAR=$(ls target/axonflow-sdk-*.jar | head -1) + * CP="$SDK_JAR:$(cat /tmp/cp.txt)" + * AXONFLOW_AGENT_URL=http://localhost:8080 \ + * AXONFLOW_TENANT_ID=cs_... AXONFLOW_TENANT_SECRET=... \ + * AXONFLOW_E2E_PLUGIN_TOKEN=AXON-... \ + * java -cp "$CP" \ + * runtime-e2e/x-axonflow-client/SdkClientHeaderTest.java + */ +import com.getaxonflow.sdk.AxonFlow; +import com.getaxonflow.sdk.AxonFlowConfig; +import com.getaxonflow.sdk.types.MCPCheckInputResponse; + +public class SdkClientHeaderTest { + public static void main(String[] args) { + String endpoint = System.getenv().getOrDefault("AXONFLOW_AGENT_URL", "http://localhost:8080"); + String tenant = System.getenv("AXONFLOW_TENANT_ID"); + String secret = System.getenv("AXONFLOW_TENANT_SECRET"); + String token = System.getenv("AXONFLOW_E2E_PLUGIN_TOKEN"); + if (tenant == null || secret == null || token == null) { + System.err.println("AXONFLOW_TENANT_ID + AXONFLOW_TENANT_SECRET + AXONFLOW_E2E_PLUGIN_TOKEN must be set; see ../README.md"); + System.exit(2); + } + + String expected = "sdk-java/" + AxonFlowConfig.SDK_VERSION; + System.out.println("Asserting wire X-Axonflow-Client = " + expected); + + AxonFlow client = AxonFlow.create( + AxonFlowConfig.builder() + .agentUrl(endpoint) + .clientId(tenant) + .clientSecret(secret) + .build() + ); + // NOTE: like sdk-go, sdk-java does not currently expose a public way + // to inject X-License-Token into requests. The driver script that + // runs this test should chain through a small local logging proxy + // that injects the token before forwarding to the agent. See + // ../README.md "How to run" for the proxy snippet. The assertion + // below relies on the proxy chain producing a scope_mismatch + // response that echoes the client header. + try { + MCPCheckInputResponse r = client.mcpCheckInput("postgres", "SELECT 1"); + System.err.println("UNEXPECTED 200: allowed=" + r.isAllowed()); + System.exit(1); + } catch (Exception e) { + String msg = e.getMessage() == null ? e.toString() : e.getMessage(); + if (msg.contains("client \"" + expected + "\"")) { + System.out.println("PASS: agent reflected " + expected + " in scope_mismatch response"); + System.exit(0); + } + System.err.println("FAIL: error did not echo expected client header; got: " + msg); + System.exit(1); + } + } +} diff --git a/scripts/lint-no-mocks-in-runtime-e2e.sh b/scripts/lint-no-mocks-in-runtime-e2e.sh new file mode 100755 index 0000000..5c76700 --- /dev/null +++ b/scripts/lint-no-mocks-in-runtime-e2e.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# lint-no-mocks-in-runtime-e2e.sh +# +# Per HARD RULE #0 in CLAUDE.md: runtime-e2e/ tests MUST hit a real endpoint +# (real plugin in real host CLI; real SDK with real fetch against a real +# running agent). Mocks, stubs, simulators, capture-stub harnesses do NOT +# count as runtime proof. +# +# This script greps runtime-e2e/ for forbidden mock-pattern strings and +# fails the build if any are found. It runs in CI as part of the +# definition-of-done.yml gate. +# +# To bypass for a specific file (rare, must justify in PR): +# add a line `# allow-mocks-here: ` near the offending line and +# the lint will skip that file. Reviewers should challenge any usage. + +set -uo pipefail + +SCAN_DIR="${1:-runtime-e2e}" + +if [ ! -d "$SCAN_DIR" ]; then + echo "lint-no-mocks: $SCAN_DIR not present, nothing to scan." + exit 0 +fi + +# Forbidden patterns. Each one represents a way to fake a runtime response. +# Add to this list as new mock libraries arrive in the codebase. +PATTERNS=( + 'mockFetch' # jest fetch mock + 'jest\.mock' # jest module mock + 'jest\.fn' # jest stub + 'sinon\.stub' # sinon test double + 'unittest\.mock' # python stdlib mock + 'MagicMock' # python mock class + 'httpx_mock\.add_response' # python httpx mock + 'wiremock' # java/jvm wiremock + 'WireMockServer' # wiremock builder + 'stubFor' # wiremock stub + 'httptest\.NewServer' # go httptest stub server + 'capture-stub\.py' # local capture harness + 'fixture-server' # generic fixture server + 'msw\.setupServer' # jsdom mock service worker + 'nock\.' # nock http stubs (node) +) + +EXIT=0 +COUNT=0 + +# Build a regex from PATTERNS; escape literal dots already in the pattern source +REGEX=$(IFS='|'; echo "${PATTERNS[*]}") + +# Use plain grep -r so we catch untracked files too (CI sees tracked PR +# content, but local dev/pre-commit may run against new files not yet added). +matches=$(grep -rnE "$REGEX" "$SCAN_DIR" 2>/dev/null || true) + +if [ -z "$matches" ]; then + echo "lint-no-mocks: $SCAN_DIR is clean (no forbidden mock patterns found)." + exit 0 +fi + +# Filter out lines explicitly allowed via the inline marker. +while IFS= read -r line; do + file=$(echo "$line" | cut -d: -f1) + if [ -n "$file" ] && grep -q "allow-mocks-here:" "$file" 2>/dev/null; then + continue + fi + echo " $line" + COUNT=$((COUNT + 1)) + EXIT=1 +done <<< "$matches" + +if [ "$EXIT" -ne 0 ]; then + echo "" + echo "lint-no-mocks: $COUNT forbidden mock-pattern hit(s) in $SCAN_DIR." >&2 + echo "" >&2 + echo "Per CLAUDE.md HARD RULE #0, runtime-e2e/ tests MUST hit a real endpoint." >&2 + echo "Mocks, stubs, fixture-servers, and capture harnesses do NOT count as" >&2 + echo "runtime proof. The runtime-e2e/ test for a feature must invoke the" >&2 + echo "feature through its actual user-facing surface (host CLI tool/skill," >&2 + echo "real SDK fetch to a running agent, etc.)." >&2 + echo "" >&2 + echo "If a specific test legitimately needs a stub (rare — usually means" >&2 + echo "it's not actually a runtime test and belongs elsewhere), add a" >&2 + echo " # allow-mocks-here: " >&2 + echo "comment on the line and a reviewer must explicitly approve it." >&2 +fi + +exit "$EXIT"